219 lines
11 KiB
HTML
219 lines
11 KiB
HTML
{% extends "layouts/base.html" %}
|
|
{% load i18n %}
|
|
|
|
{% block title %}{{ task.title }} - PX360{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="max-w-5xl mx-auto px-4 py-6">
|
|
<!-- Back link -->
|
|
<a href="{% url 'projects:project_detail' pk=project.pk %}" class="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-navy mb-4">
|
|
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
|
{% trans "Back to project" %}
|
|
</a>
|
|
|
|
<!-- Task header -->
|
|
<div class="page-header-gradient mb-6">
|
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
|
<div>
|
|
<p class="text-xs uppercase tracking-wide opacity-80 mb-1">{{ project.name }}</p>
|
|
<h1 class="text-2xl font-bold">{{ task.title }}</h1>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
{% if can_edit %}
|
|
<a href="{% url 'projects:task_edit' project_pk=project.pk task_pk=task.pk %}" class="px-3 py-1.5 bg-white/15 hover:bg-white/25 rounded-lg text-sm font-semibold transition inline-flex items-center gap-1">
|
|
<i data-lucide="pencil" class="w-4 h-4"></i> {% trans "Edit" %}
|
|
</a>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-4 mt-4 text-sm">
|
|
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold uppercase
|
|
{% if task.status == 'completed' %}bg-green-100 text-green-800
|
|
{% elif task.status == 'active' %}bg-blue-100 text-blue-800
|
|
{% elif task.status == 'pending' %}bg-amber-100 text-amber-800
|
|
{% elif task.status == 'cancelled' %}bg-slate-100 text-slate-600
|
|
{% else %}bg-slate-100 text-slate-600{% endif %}">
|
|
{{ task.get_status_display }}
|
|
</span>
|
|
<span class="inline-flex items-center gap-1">
|
|
<i data-lucide="user" class="w-4 h-4"></i>
|
|
{{ task.assigned_to.get_full_name|default:"-" }}
|
|
</span>
|
|
{% if task.due_date %}
|
|
<span class="inline-flex items-center gap-1 {% if task.due_date < now|date:'Y-m-d' and task.status != 'completed' %}text-red-300 font-semibold{% endif %}">
|
|
<i data-lucide="calendar" class="w-4 h-4"></i>
|
|
{% trans "Due" %}: {{ task.due_date|date:"Y-m-d" }}
|
|
</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<!-- Description -->
|
|
<div class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100">
|
|
<h3 class="font-bold text-navy mb-2 flex items-center gap-2">
|
|
<i data-lucide="align-left" class="w-5 h-5 text-blue"></i>
|
|
{% trans "Description" %}
|
|
</h3>
|
|
<p class="text-sm text-slate-700 leading-relaxed whitespace-pre-wrap">
|
|
{{ task.description|default:"-" }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Notes -->
|
|
<div>
|
|
{% include "partials/notes_panel.html" with notes=notes content_type_id=content_type_id object_id=object_id %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Attachments -->
|
|
<div class="bg-white rounded-2xl p-4 shadow-sm border border-slate-100 mt-6">
|
|
<h3 class="font-bold text-navy mb-4 flex items-center gap-2">
|
|
<i data-lucide="paperclip" class="w-5 h-5 text-blue"></i>
|
|
{% trans "Attachments" %}
|
|
<span id="attachmentCount" class="text-xs font-normal text-slate-400">({{ attachments|length }})</span>
|
|
</h3>
|
|
|
|
{% if can_contribute %}
|
|
<form id="attachmentForm" class="mb-5" enctype="multipart/form-data">
|
|
{% csrf_token %}
|
|
<div class="flex flex-col sm:flex-row gap-2">
|
|
<input type="file" name="file" id="attachmentFile" required
|
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.zip"
|
|
class="text-sm file:mr-3 file:py-2 file:px-3 file:rounded-lg file:border-0 file:bg-navy file:text-white file:font-semibold file:cursor-pointer border border-slate-200 rounded-lg w-full">
|
|
<input type="text" name="description" placeholder="{% trans 'Optional description...' %}"
|
|
class="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-navy/20 outline-none">
|
|
<button type="submit" class="px-4 py-2 bg-navy text-white rounded-lg font-semibold hover:bg-blue transition text-sm inline-flex items-center gap-1 whitespace-nowrap">
|
|
<i data-lucide="upload" class="w-4 h-4"></i> {% trans "Upload" %}
|
|
</button>
|
|
</div>
|
|
<p id="attachmentError" class="text-red-500 text-xs mt-2 hidden"></p>
|
|
</form>
|
|
{% else %}
|
|
<p class="text-xs text-slate-400 italic mb-4">
|
|
{% trans "Only project contributors can upload attachments." %}
|
|
</p>
|
|
{% endif %}
|
|
|
|
<div id="attachmentList" class="space-y-2">
|
|
{% for att in attachments %}
|
|
<div id="att-{{ att.id }}" class="flex items-center justify-between gap-3 border border-slate-100 rounded-xl p-3 bg-slate-50">
|
|
<div class="flex items-center gap-3 min-w-0">
|
|
<div class="w-9 h-9 rounded-lg bg-navy/10 flex items-center justify-center shrink-0">
|
|
<i data-lucide="file-text" class="w-4 h-4 text-navy"></i>
|
|
</div>
|
|
<div class="min-w-0">
|
|
<a href="{{ att.file.url }}" target="_blank" class="text-sm font-semibold text-navy hover:underline truncate block">
|
|
{{ att.filename }}
|
|
</a>
|
|
<p class="text-xs text-slate-400">
|
|
{{ att.uploaded_by.get_full_name|default:"-" }} · {{ att.created_at|date:"M d, Y h:i A" }}
|
|
{% if att.file_size %} · {{ att.file_size|filesizeformat }}{% endif %}
|
|
{% if att.description %} · {{ att.description }}{% endif %}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{% if can_contribute %}
|
|
<button type="button" onclick="deleteAttachment('{{ att.id }}')"
|
|
class="w-8 h-8 rounded-lg text-slate-400 hover:bg-red-50 hover:text-red-600 transition shrink-0"
|
|
title="{% trans 'Delete' %}">
|
|
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
{% empty %}
|
|
<div id="attachmentEmpty" class="text-center py-8">
|
|
<i data-lucide="paperclip" class="w-12 h-12 mx-auto text-slate-300 mb-3"></i>
|
|
<p class="text-slate text-sm">{% trans "No attachments yet" %}</p>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
(function () {
|
|
const form = document.getElementById('attachmentForm');
|
|
const fileList = document.getElementById('attachmentList');
|
|
const emptyEl = document.getElementById('attachmentEmpty');
|
|
const countEl = document.getElementById('attachmentCount');
|
|
const errorEl = document.getElementById('attachmentError');
|
|
const uploadUrl = "{% url 'projects:task_attachment_upload' task_pk=task.pk %}";
|
|
|
|
function fmtSize(bytes) {
|
|
if (!bytes) return '';
|
|
const units = ['B', 'KB', 'MB', 'GB'];
|
|
let i = 0;
|
|
while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; }
|
|
return bytes.toFixed(bytes >= 10 || i === 0 ? 0 : 1) + ' ' + units[i];
|
|
}
|
|
|
|
function updateCount(n) {
|
|
if (countEl) countEl.textContent = '(' + n + ')';
|
|
if (emptyEl) emptyEl.style.display = n === 0 ? '' : 'none';
|
|
}
|
|
|
|
function rowHtml(a) {
|
|
return '<div id="att-' + a.id + '" class="flex items-center justify-between gap-3 border border-slate-100 rounded-xl p-3 bg-slate-50">'
|
|
+ '<div class="flex items-center gap-3 min-w-0">'
|
|
+ '<div class="w-9 h-9 rounded-lg bg-navy/10 flex items-center justify-center shrink-0"><i data-lucide="file-text" class="w-4 h-4 text-navy"></i></div>'
|
|
+ '<div class="min-w-0">'
|
|
+ '<a href="' + a.file_url + '" target="_blank" class="text-sm font-semibold text-navy hover:underline truncate block">' + escapeHtml(a.filename) + '</a>'
|
|
+ '<p class="text-xs text-slate-400">' + escapeHtml(a.uploaded_by) + ' · ' + escapeHtml(a.uploaded_at)
|
|
+ (a.file_size ? ' · ' + fmtSize(a.file_size) : '')
|
|
+ (a.description ? ' · ' + escapeHtml(a.description) : '') + '</p>'
|
|
+ '</div></div>'
|
|
+ '<button type="button" onclick="deleteAttachment(\'' + a.id + '\')" class="w-8 h-8 rounded-lg text-slate-400 hover:bg-red-50 hover:text-red-600 transition shrink-0" title="{% trans 'Delete' %}"><i data-lucide="trash-2" class="w-4 h-4"></i></button>'
|
|
+ '</div>';
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
const d = document.createElement('div');
|
|
d.textContent = s == null ? '' : s;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
if (form) {
|
|
form.addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
if (errorEl) { errorEl.textContent = ''; errorEl.classList.add('hidden'); }
|
|
const data = new FormData(form);
|
|
fetch(uploadUrl, { method: 'POST', body: data, headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
fileList.insertAdjacentHTML('afterbegin', rowHtml(data.attachment));
|
|
updateCount(data.attachment_count);
|
|
form.reset();
|
|
if (window.lucide) lucide.createIcons();
|
|
} else {
|
|
if (errorEl) { errorEl.textContent = data.error || 'Upload failed'; errorEl.classList.remove('hidden'); }
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (errorEl) { errorEl.textContent = 'Upload failed'; errorEl.classList.remove('hidden'); }
|
|
});
|
|
});
|
|
}
|
|
|
|
window.deleteAttachment = function (id) {
|
|
if (!confirm("{% trans 'Delete this attachment?' %}")) return;
|
|
const url = "{% url 'projects:task_attachment_delete' task_pk=task.pk attachment_pk='00000000-0000-0000-0000-000000000000' %}".replace('00000000-0000-0000-0000-000000000000', id);
|
|
fetch(url, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest', 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value }})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
const row = document.getElementById('att-' + id);
|
|
if (row) row.remove();
|
|
updateCount(data.attachment_count);
|
|
} else {
|
|
alert(data.error || 'Delete failed');
|
|
}
|
|
});
|
|
};
|
|
})();
|
|
</script>
|
|
{% endblock %}
|