reports: документы отдела с правкой через OnlyOffice

- ReportDocument (+ миграция 0005): файлы отдела в Nextcloud (06_Отчёты).
- blank_office.py: генерация пустых Word/Excel для новых документов.
- documents.py: доступ/путь/создание, права view/edit по отделу.
- редактор OnlyOffice учится открывать report_doc (view/edit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 16:44:40 +03:00
parent 83ebf8f12a
commit 2781a6e2b8
13 changed files with 596 additions and 4 deletions
+47
View File
@@ -243,6 +243,36 @@ def document_upload(request):
def _office_payload_from_request(request):
"""Резолв файла для редактора из query (раздел или аптека)."""
if request.GET.get('report_doc'):
from apps.reports.documents import (
report_document_relpath,
user_can_edit_report_document,
user_can_view_report_document,
)
from apps.reports.models import ReportDocument
raw = request.GET.get('report_doc', '')
if not raw.isdigit():
return None, HttpResponseBadRequest('Некорректный документ.')
doc = get_object_or_404(ReportDocument, pk=int(raw))
if not user_can_view_report_document(request.user, doc):
return None, HttpResponseForbidden()
name = doc.filename
if not is_office_file(name):
return None, HttpResponseBadRequest('Этот тип файла OnlyOffice не открывает.')
return {
'filename': name,
'relpath': report_document_relpath(doc),
'writable': user_can_edit_report_document(request.user, doc) and can_edit_file(name),
'back': {'kind': 'report_doc', 'label': 'Отчёты'},
'token_payload': {
'kind': 'report_doc',
'doc_id': doc.pk,
'name': name,
'uid': request.user.pk,
},
}, None
if request.GET.get('pharmacy'):
raw = request.GET.get('pharmacy', '')
if not raw.isdigit():
@@ -313,6 +343,23 @@ def _resolve_token_relpath(payload: dict) -> tuple[str, str, bool] | None:
if pharmacy is None:
return None
return f'{pharmacy_folder_relpath(pharmacy)}/{name}', name, True
if kind == 'report_doc':
from apps.reports.documents import (
report_document_relpath,
user_can_edit_report_document,
user_can_view_report_document,
)
from apps.reports.models import ReportDocument
from django.contrib.auth import get_user_model
user = get_user_model().objects.filter(pk=payload.get('uid')).first()
doc = ReportDocument.objects.filter(pk=payload.get('doc_id')).first()
if user is None or doc is None or not user_can_view_report_document(user, doc):
return None
if doc.filename != name:
return None
writable = user_can_edit_report_document(user, doc) and can_edit_file(name)
return report_document_relpath(doc), name, writable
if kind == 'section':
from django.contrib.auth import get_user_model
user = get_user_model().objects.filter(pk=payload.get('uid')).first()
@@ -143,7 +143,25 @@ class NextcloudWebDAVService:
self._raise(resp, relpath)
return resp.content, resp.headers.get('content-type', 'application/octet-stream')
def ensure_parent_collections(self, relpath: str) -> None:
"""Создаёт промежуточные папки WebDAV (MKCOL), если их ещё нет."""
parts = [p for p in (relpath or '').strip('/').split('/') if p]
if len(parts) < 2:
return
acc: list[str] = []
for segment in parts[:-1]:
acc.append(segment)
folder = '/'.join(acc)
try:
resp = self._client.request('MKCOL', self._url(folder))
except httpx.HTTPError as e:
raise NextcloudServiceError(f'WebDAV MKCOL «{folder}»: {e}') from e
if resp.status_code in (201, 405, 301, 200, 409):
continue
self._raise(resp, folder)
def upload(self, relpath: str, content: bytes, content_type: str | None = None) -> None:
self.ensure_parent_collections(relpath)
headers = {'Content-Type': content_type} if content_type else {}
try:
resp = self._client.put(self._url(relpath), content=content, headers=headers)
+11 -1
View File
@@ -1,6 +1,6 @@
from django.contrib import admin
from apps.reports.models import ReportPublication, ReportTemplate
from apps.reports.models import ReportDocument, ReportPublication, ReportTemplate
@admin.register(ReportTemplate)
@@ -20,3 +20,13 @@ class ReportPublicationAdmin(admin.ModelAdmin):
search_fields = ['department__name', 'department__code']
raw_id_fields = ['department', 'published_by']
readonly_fields = ['updated_at']
@admin.register(ReportDocument)
class ReportDocumentAdmin(admin.ModelAdmin):
list_display = ['title', 'department', 'kind', 'filename', 'created_by', 'updated_at']
list_filter = ['kind']
search_fields = ['title', 'filename', 'department__name']
raw_id_fields = ['department', 'created_by']
readonly_fields = ['created_at', 'updated_at']
+44
View File
@@ -0,0 +1,44 @@
"""Пустые Word/Excel для новых документов отдела."""
from __future__ import annotations
from io import BytesIO
import zipfile
def empty_xlsx() -> bytes:
from openpyxl import Workbook
buf = BytesIO()
Workbook().save(buf)
return buf.getvalue()
def empty_docx() -> bytes:
"""Минимальный OOXML, который открывает OnlyOffice / Word."""
files = {
'[Content_Types].xml': (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/word/document.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
'</Types>'
),
'_rels/.rels': (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
'</Relationships>'
),
'word/document.xml': (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
'<w:body><w:p><w:r><w:t></w:t></w:r></w:p></w:body></w:document>'
),
}
buf = BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
for name, data in files.items():
zf.writestr(name, data)
return buf.getvalue()
+80
View File
@@ -0,0 +1,80 @@
"""Документы отдела на /reports/: доступ, путь в Nextcloud, создание файлов."""
from __future__ import annotations
from django.utils.text import slugify
from apps.nextcloud_connector.access import safe_filename
from apps.nextcloud_connector.services.sync_service import safe_component
from apps.reports.it_monthly.builder import (
user_can_manage_it_monthly_report,
user_can_view_all_department_reports,
)
from apps.reports.models import ReportDocument
from apps.reports.registry import visible_reports
from core.portal_access import user_can_access
REPORTS_DAV_ROOT = '06_Отчёты'
def user_can_open_reports_hub(user) -> bool:
if not getattr(user, 'is_authenticated', False):
return False
if user_can_access(user, 'reports'):
return True
return bool(visible_reports(user))
def user_can_view_report_document(user, doc: ReportDocument) -> bool:
if not user_can_open_reports_hub(user):
return False
if user_can_view_all_department_reports(user):
return True
return getattr(user, 'department_id', None) == doc.department_id
def user_can_edit_report_document(user, doc: ReportDocument) -> bool:
if not user_can_view_report_document(user, doc):
return False
if user_can_view_all_department_reports(user):
return True
if user_can_manage_it_monthly_report(user):
return getattr(user, 'department_id', None) == doc.department_id
return False
def user_can_create_report_document(user) -> bool:
if not user_can_open_reports_hub(user):
return False
if not getattr(user, 'department_id', None):
return False
return user_can_manage_it_monthly_report(user) or user_can_view_all_department_reports(user)
def visible_report_documents(user):
if not user_can_open_reports_hub(user):
return ReportDocument.objects.none()
qs = ReportDocument.objects.select_related('department', 'created_by')
if user_can_view_all_department_reports(user):
return qs
dept_id = getattr(user, 'department_id', None)
if not dept_id:
return ReportDocument.objects.none()
return qs.filter(department_id=dept_id)
def report_document_relpath(doc: ReportDocument) -> str:
folder = safe_component(doc.department.code or doc.department.name)
return f'{REPORTS_DAV_ROOT}/{folder}/{doc.filename}'
def unique_filename(department_id: int, title: str, ext: str) -> str:
base = slugify(title, allow_unicode=True) or 'document'
candidate = safe_filename(f'{base}.{ext}') or f'document.{ext}'
if not ReportDocument.objects.filter(department_id=department_id, filename=candidate).exists():
return candidate
n = 2
while True:
name = safe_filename(f'{base}-{n}.{ext}') or f'document-{n}.{ext}'
if not ReportDocument.objects.filter(department_id=department_id, filename=name).exists():
return name
n += 1
@@ -0,0 +1,37 @@
# Generated by Django 6.0.3 on 2026-09-10 11:02
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0020_alter_connectorconfig_extra_config'),
('reports', '0004_report_publication'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ReportDocument',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200, verbose_name='Название')),
('filename', models.CharField(max_length=220, verbose_name='Имя файла')),
('kind', models.CharField(choices=[('extra', 'Документ'), ('snapshot', 'Снимок отчёта')], default='extra', max_length=16, verbose_name='Тип')),
('source_month', models.CharField(blank=True, max_length=7, verbose_name='Период отчёта')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='report_documents_created', to=settings.AUTH_USER_MODEL, verbose_name='Кто создал')),
('department', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='report_documents', to='core.directory', verbose_name='Отдел')),
],
options={
'verbose_name': 'Документ отчётов',
'verbose_name_plural': 'Документы отчётов',
'ordering': ['-updated_at'],
'constraints': [models.UniqueConstraint(fields=('department', 'filename'), name='reports_document_unique_filename')],
},
),
]
+47
View File
@@ -150,3 +150,50 @@ class ReportPublication(models.Model):
@property
def is_published(self) -> bool:
return self.status == self.STATUS_PUBLISHED
class ReportDocument(models.Model):
"""Файл отдела на странице отчётов: доп. документ или снимок сформированного отчёта."""
KIND_EXTRA = 'extra'
KIND_SNAPSHOT = 'snapshot'
KIND_CHOICES = [
(KIND_EXTRA, _('Документ')),
(KIND_SNAPSHOT, _('Снимок отчёта')),
]
department = models.ForeignKey(
'core.Directory',
on_delete=models.CASCADE,
related_name='report_documents',
verbose_name=_('Отдел'),
)
title = models.CharField(_('Название'), max_length=200)
filename = models.CharField(_('Имя файла'), max_length=220)
kind = models.CharField(_('Тип'), max_length=16, choices=KIND_CHOICES, default=KIND_EXTRA)
source_month = models.CharField(_('Период отчёта'), max_length=7, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='report_documents_created',
verbose_name=_('Кто создал'),
)
class Meta:
verbose_name = _('Документ отчётов')
verbose_name_plural = _('Документы отчётов')
ordering = ['-updated_at']
constraints = [
models.UniqueConstraint(
fields=['department', 'filename'],
name='reports_document_unique_filename',
),
]
def __str__(self):
return self.title
+47
View File
@@ -472,3 +472,50 @@ class ReportTemplateTests(TestCase):
})
self.assertEqual(bad.status_code, 400)
self.assertIn('Ошибка', bad.content.decode())
class ReportDocumentTests(TestCase):
def setUp(self):
dept_type, _ = DirectoryType.objects.get_or_create(
code='department', defaults={'name': 'Відділи', 'hierarchy_type': 'strict'},
)
self.dept = Directory.objects.create(
directory_type=dept_type, code='it-docs', name='IT-docs',
)
self.head = make_employee(
last_name='Глава', first_name='Отдел', username='head.docs',
department=self.dept, user_location_type='office',
)
self.member = make_employee(
last_name='Сотр', first_name='Отдел', username='mem.docs',
department=self.dept, manager=self.head, user_location_type='office',
)
it_group = Group.objects.get(name='IT')
self.head.groups.add(it_group)
self.member.groups.add(it_group)
self.client = Client()
def test_hub_shows_documents_block_for_head(self):
login_2fa(self.client, self.head)
r = self.client.get('/reports/')
self.assertEqual(r.status_code, 200)
self.assertContains(r, 'Документы отдела')
self.assertContains(r, 'Создать')
def test_member_cannot_create(self):
login_2fa(self.client, self.member)
r = self.client.post('/reports/documents/create/', {'title': 'План', 'file_type': 'docx'})
self.assertEqual(r.status_code, 403)
def test_head_creates_docx_when_storage_configured(self):
from unittest.mock import patch
from apps.reports.models import ReportDocument
login_2fa(self.client, self.head)
with patch('apps.reports.views._store_report_file', return_value=None):
r = self.client.post('/reports/documents/create/', {'title': 'План работ', 'file_type': 'docx'})
self.assertEqual(r.status_code, 302)
doc = ReportDocument.objects.get(title='План работ')
self.assertTrue(doc.filename.endswith('.docx'))
self.assertEqual(doc.kind, ReportDocument.KIND_EXTRA)
self.assertIn('/reports/', r['Location'])
+20
View File
@@ -28,6 +28,26 @@ urlpatterns = [
views.it_monthly_unpublish_view,
name='reports_it_monthly_unpublish',
),
path(
'reports/it/monthly/<str:month_id>/save-document/',
views.it_monthly_save_document_view,
name='reports_it_monthly_save_document',
),
path(
'reports/documents/create/',
views.report_document_create_view,
name='report_document_create',
),
path(
'reports/documents/<int:pk>/download/',
views.report_document_download_view,
name='report_document_download',
),
path(
'reports/documents/<int:pk>/delete/',
views.report_document_delete_view,
name='report_document_delete',
),
path(
'reports/it/monthly/<str:month_id>.md',
views.it_monthly_md_view,
+159 -3
View File
@@ -5,7 +5,9 @@ import json
import re
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseRedirect
from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseRedirect,
)
from django.shortcuts import get_object_or_404
from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.decorators.http import require_POST
@@ -19,7 +21,15 @@ from apps.reports.it_monthly import (
user_can_manage_it_monthly_report,
user_can_view_it_monthly_report,
)
from apps.reports.models import ReportTemplate
from apps.reports.documents import (
report_document_relpath,
unique_filename,
user_can_create_report_document,
user_can_edit_report_document,
user_can_view_report_document,
visible_report_documents,
)
from apps.reports.models import ReportDocument, ReportTemplate
from apps.reports.publication import (
available_months_for_user,
publication_for_user_month,
@@ -46,6 +56,12 @@ def _default_it_template() -> ReportTemplate | None:
return ReportTemplate.resolve(ReportTemplate.REPORT_IT_MONTHLY)
def _onlyoffice_ready() -> bool:
from apps.nextcloud_connector.onlyoffice import onlyoffice_is_ready
return onlyoffice_is_ready()
def _resolve_it_month(request, month_raw: str | None) -> tuple[int, int] | HttpResponse:
"""Выбрать месяц с учётом публикации; 403 если нет доступа к периоду."""
months = available_months_for_user(request.user)
@@ -84,6 +100,11 @@ def reports_hub_view(request):
return render_page(request, 'reports.html', {
'report_specs': specs,
'report_rows': report_list_rows(request.user),
'report_documents': list(visible_report_documents(request.user)),
'can_create_report_document': user_can_create_report_document(request.user),
'onlyoffice_ready': _onlyoffice_ready(),
'reports_message': request.session.pop('reports_message', ''),
'reports_error': request.session.pop('reports_error', ''),
'can_manage_templates': user_can_manage_templates(request.user),
})
@@ -98,7 +119,11 @@ def it_monthly_view(request):
if isinstance(resolved, HttpResponse):
return resolved
year, month = resolved
return render_page(request, 'reports-it-monthly.html', _it_monthly_page_context(request, year, month))
return render_page(request, 'reports-it-monthly.html', {
**_it_monthly_page_context(request, year, month),
'can_save_report_document': user_can_create_report_document(request.user),
'onlyoffice_ready': _onlyoffice_ready(),
})
def _export_with_template(request, month_id, fmt: str):
@@ -199,6 +224,137 @@ def redirect_legacy_it_report(request, month_id, fmt: str):
return HttpResponseRedirect(f'/reports/it/monthly/{month_id}.{fmt}')
def _store_report_file(relpath: str, content: bytes, content_type: str | None = None) -> str | None:
from apps.nextcloud_connector.nextcloud_service import NextcloudConfigError, NextcloudServiceError
from apps.nextcloud_connector.webdav_service import NextcloudWebDAVService
try:
NextcloudWebDAVService().upload(relpath, content, content_type)
except NextcloudConfigError:
return 'Файловое хранилище (Nextcloud) ещё не настроено.'
except NextcloudServiceError:
return 'Не удалось сохранить файл в хранилище.'
return None
@login_required
@require_POST
def report_document_create_view(request):
if not user_can_create_report_document(request.user):
return HttpResponseForbidden()
title = (request.POST.get('title') or '').strip()
ext = (request.POST.get('file_type') or 'docx').strip().lower()
if ext not in ('docx', 'xlsx'):
ext = 'docx'
if not title:
request.session['reports_error'] = 'Укажите название документа.'
return HttpResponseRedirect('/reports/')
from apps.reports.blank_office import empty_docx, empty_xlsx
filename = unique_filename(request.user.department_id, title, ext)
doc = ReportDocument.objects.create(
department_id=request.user.department_id,
title=title,
filename=filename,
kind=ReportDocument.KIND_EXTRA,
created_by=request.user,
)
payload = empty_xlsx() if ext == 'xlsx' else empty_docx()
ctype = (
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
if ext == 'xlsx'
else 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
)
err = _store_report_file(report_document_relpath(doc), payload, ctype)
if err:
doc.delete()
request.session['reports_error'] = err
return HttpResponseRedirect('/reports/')
if _onlyoffice_ready():
return HttpResponseRedirect(f'/documents/edit/?report_doc={doc.pk}')
request.session['reports_message'] = f'Документ «{title}» создан. OnlyOffice пока не настроен — откройте файл позже.'
return HttpResponseRedirect('/reports/')
@login_required
@require_POST
def report_document_delete_view(request, pk):
doc = get_object_or_404(ReportDocument, pk=pk)
if not user_can_edit_report_document(request.user, doc):
return HttpResponseForbidden()
from apps.nextcloud_connector.nextcloud_service import NextcloudConfigError, NextcloudServiceError
from apps.nextcloud_connector.webdav_service import NextcloudWebDAVService
relpath = report_document_relpath(doc)
try:
NextcloudWebDAVService().delete(relpath)
except (NextcloudConfigError, NextcloudServiceError):
pass
title = doc.title
doc.delete()
request.session['reports_message'] = f'Документ «{title}» удалён.'
return HttpResponseRedirect('/reports/')
@login_required
def report_document_download_view(request, pk):
doc = get_object_or_404(ReportDocument, pk=pk)
if not user_can_view_report_document(request.user, doc):
return HttpResponseForbidden()
from apps.nextcloud_connector.nextcloud_service import NextcloudConfigError, NextcloudServiceError
from apps.nextcloud_connector.webdav_service import NextcloudWebDAVService
try:
content, ctype = NextcloudWebDAVService().download(report_document_relpath(doc))
except (NextcloudConfigError, NextcloudServiceError):
raise Http404('Файл недоступен.')
resp = HttpResponse(content, content_type=ctype)
resp['Content-Disposition'] = f'attachment; filename*=UTF-8\'\'{doc.filename}'
return resp
@login_required
@require_POST
def it_monthly_save_document_view(request, month_id):
if not user_can_create_report_document(request.user):
return HttpResponseForbidden()
year, month = parse_month_id(month_id)
if not user_can_access_it_monthly_month(request.user, year, month):
return HttpResponseForbidden()
report = build_monthly_report(year, month)
tpl = _default_it_template()
try:
payload = render_report(report, tpl, 'docx')
except TemplateRenderError as exc:
request.session['reports_error'] = f'Не удалось собрать Word: {exc}'
return HttpResponseRedirect(f'/reports/it/monthly/?month={year:04d}-{month:02d}')
title = f'IT-отчёт {report["title"]}'
filename = unique_filename(request.user.department_id, title, 'docx')
doc = ReportDocument.objects.create(
department_id=request.user.department_id,
title=title,
filename=filename,
kind=ReportDocument.KIND_SNAPSHOT,
source_month=report['id'],
created_by=request.user,
)
err = _store_report_file(
report_document_relpath(doc),
payload,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
)
if err:
doc.delete()
request.session['reports_error'] = err
return HttpResponseRedirect(f'/reports/it/monthly/?month={year:04d}-{month:02d}')
if _onlyoffice_ready():
return HttpResponseRedirect(f'/documents/edit/?report_doc={doc.pk}')
request.session['reports_message'] = f'Отчёт сохранён в документы: «{title}».'
return HttpResponseRedirect('/reports/')
# ── Шаблоны ──────────────────────────────────────────────────────────────
+4
View File
@@ -11,6 +11,10 @@
{% if back.kind == 'pharmacy' %}
<a href="/pharmacies/{{ back.pharmacy_id }}/"
class="btn btn-secondary" style="text-decoration:none;">{% trans "К аптеке" %}</a>
{% elif back.kind == 'report_doc' %}
<a href="/reports/"
hx-get="/reports/" hx-target="#main-content" hx-push-url="/reports/" hx-indicator="#loader"
class="btn btn-secondary" style="text-decoration:none;">{% trans "К отчётам" %}</a>
{% else %}
<a href="/documents/"
hx-get="/documents/" hx-target="#main-content" hx-push-url="/documents/" hx-indicator="#loader"
+6
View File
@@ -45,6 +45,12 @@
</form>
{% endif %}
{% endif %}
{% if can_save_report_document %}
<form method="post" action="/reports/it/monthly/{{ report.id }}/save-document/" style="display:inline;">
{% csrf_token %}
<button type="submit" class="btn btn-secondary">{% trans "Сохранить в документы" %}</button>
</form>
{% endif %}
<button type="button" class="btn btn-secondary"
onclick="itReportPreviewOpen('/reports/it/monthly/{{ report.id }}/preview/', '{{ report.title|escapejs }}')">
{% trans "Предпросмотр" %}
+76
View File
@@ -62,4 +62,80 @@
</div>
{% endif %}
{% if reports_message %}
<div style="margin-top:1rem; padding:0.65rem 0.9rem; border-radius:0.5rem; background:color-mix(in srgb, #16a34a 12%, var(--color-card)); border:1px solid color-mix(in srgb, #16a34a 30%, var(--color-border)); font-size:0.8125rem;">{{ reports_message }}</div>
{% endif %}
{% if reports_error %}
<div style="margin-top:1rem; padding:0.65rem 0.9rem; border-radius:0.5rem; background:color-mix(in srgb, #dc2626 12%, var(--color-card)); border:1px solid color-mix(in srgb, #dc2626 30%, var(--color-border)); font-size:0.8125rem;">{{ reports_error }}</div>
{% endif %}
<div style="margin-top:1.5rem; background:var(--color-card); border-radius:0.75rem; border:1px solid var(--color-border); box-shadow:0 1px 2px rgb(0 0 0/0.05); overflow:hidden;">
<div style="display:flex; flex-wrap:wrap; align-items:flex-start; justify-content:space-between; gap:0.75rem; padding:0.875rem 1.25rem; border-bottom:1px solid var(--color-border);">
<div>
<div style="font-size:0.9375rem; font-weight:500;">{% trans "Документы отдела" %}</div>
<p style="font-size:0.75rem; color:var(--color-muted-foreground); margin:0.25rem 0 0;">
{% trans "Дополнительные файлы и сохранённые отчёты. Правка — в OnlyOffice." %}
</p>
</div>
{% if can_create_report_document %}
<form method="post" action="{% url 'report_document_create' %}"
style="display:flex; gap:0.5rem; flex-wrap:wrap; align-items:center; margin:0;">
{% csrf_token %}
<input type="text" name="title" required class="input" maxlength="200"
placeholder="{% trans 'Название документа' %}" style="min-width:14rem;">
<select name="file_type" class="input">
<option value="docx">{% trans "Word" %}</option>
<option value="xlsx">{% trans "Excel" %}</option>
</select>
<button type="submit" class="btn btn-primary" style="font-size:0.75rem;">{% trans "Создать" %}</button>
</form>
{% endif %}
</div>
{% if report_documents %}
<table style="width:100%; border-collapse:collapse; font-size:0.8125rem;">
<thead>
<tr style="background:var(--color-muted);">
<th style="padding:0.625rem 1.25rem; text-align:left; font-size:0.7rem; text-transform:uppercase; letter-spacing:0.04em; color:var(--color-muted-foreground);">{% trans "Документ" %}</th>
<th style="padding:0.625rem 1rem; text-align:left; font-size:0.7rem; text-transform:uppercase; letter-spacing:0.04em; color:var(--color-muted-foreground);">{% trans "Отдел" %}</th>
<th style="padding:0.625rem 1rem; text-align:left; font-size:0.7rem; text-transform:uppercase; letter-spacing:0.04em; color:var(--color-muted-foreground);">{% trans "Обновлён" %}</th>
<th style="padding:0.625rem 1rem;"></th>
</tr>
</thead>
<tbody>
{% for doc in report_documents %}
<tr class="tr-hover" style="border-top:1px solid var(--color-border);">
<td style="padding:0.75rem 1.25rem;">
<div style="font-weight:600;">{{ doc.title }}</div>
<div style="font-size:0.75rem; color:var(--color-muted-foreground); margin-top:0.15rem;">
{% if doc.kind == 'snapshot' %}{% trans "Снимок отчёта" %}{% else %}{% trans "Документ" %}{% endif %}
· {{ doc.filename }}
</div>
</td>
<td style="padding:0.75rem 1rem; white-space:nowrap;">{{ doc.department.name }}</td>
<td style="padding:0.75rem 1rem; white-space:nowrap; color:var(--color-muted-foreground);">{{ doc.updated_at|date:"d.m.Y H:i" }}</td>
<td style="padding:0.75rem 1.25rem; text-align:right; white-space:nowrap;">
{% if onlyoffice_ready %}
<a href="/documents/edit/?report_doc={{ doc.pk }}" class="btn btn-secondary" style="font-size:0.75rem; text-decoration:none; border:1px solid var(--color-border);">{% trans "Открыть" %}</a>
{% endif %}
<a href="{% url 'report_document_download' doc.pk %}" class="btn btn-secondary" style="font-size:0.75rem; text-decoration:none; border:1px solid var(--color-border);">{% trans "Скачать" %}</a>
{% if can_create_report_document %}
<form method="post" action="{% url 'report_document_delete' doc.pk %}" style="display:inline;"
onsubmit="return confirm('{% trans "Удалить документ?" %}');">
{% csrf_token %}
<button type="submit" class="btn btn-ghost" style="font-size:0.75rem;">{% trans "Удалить" %}</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="padding:1.25rem; font-size:0.8125rem; color:var(--color-muted-foreground);">
{% trans "Пока нет дополнительных документов." %}
{% if can_create_report_document %}{% trans "Создайте Word или Excel — файл откроется в OnlyOffice." %}{% endif %}
</div>
{% endif %}
</div>
</div>