feat(board): Add PDF download for meetings, remove documents section
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Replace redundant documents tab with PDF generation from meeting data using weasyprint. Meetings become the main /rada/ view with board members section. Remove upload/view/download document routes and templates. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
58444ddb0d
commit
a5196bc1e2
@ -2,428 +2,49 @@
|
||||
Board Routes (Rada Izby)
|
||||
========================
|
||||
|
||||
Routes for board document management and meeting forms.
|
||||
|
||||
Endpoints - Documents:
|
||||
- GET /rada/ - Document list + board members
|
||||
- GET/POST /rada/upload - Upload new document (office_manager+)
|
||||
- GET /rada/<id>/view - View document in browser
|
||||
- GET /rada/<id>/download - Download document
|
||||
- POST /rada/<id>/delete - Soft delete document (office_manager+)
|
||||
Routes for board meeting management and PDF generation.
|
||||
|
||||
Endpoints - Meetings:
|
||||
- GET /rada/posiedzenia - List all meetings
|
||||
- GET/POST /rada/posiedzenia/nowe - Create new meeting (office_manager+)
|
||||
- GET/POST /rada/posiedzenia/<id>/edytuj - Edit meeting (office_manager+)
|
||||
- GET /rada/posiedzenia/<id> - View meeting details
|
||||
- POST /rada/posiedzenia/<id>/publikuj-program - Publish agenda (office_manager+)
|
||||
- POST /rada/posiedzenia/<id>/publikuj-protokol - Publish protocol (office_manager+)
|
||||
- GET /rada/ - List all meetings + board members
|
||||
- GET /rada/posiedzenia - Redirect to /rada/
|
||||
- GET/POST /rada/posiedzenia/nowe - Create new meeting (office_manager+)
|
||||
- GET/POST /rada/posiedzenia/<id>/edytuj - Edit meeting (office_manager+)
|
||||
- GET /rada/posiedzenia/<id> - View meeting details
|
||||
- POST /rada/posiedzenia/<id>/publikuj-program - Publish agenda (office_manager+)
|
||||
- POST /rada/posiedzenia/<id>/publikuj-protokol - Publish protocol (office_manager+)
|
||||
- GET /rada/posiedzenia/<id>/pdf-program - Download agenda PDF
|
||||
- GET /rada/posiedzenia/<id>/pdf-protokol - Download protocol PDF
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from flask import (
|
||||
render_template, request, redirect, url_for, flash,
|
||||
send_file, current_app, Response
|
||||
current_app, Response
|
||||
)
|
||||
from flask_login import login_required, current_user
|
||||
from sqlalchemy import desc, extract
|
||||
from sqlalchemy import desc
|
||||
|
||||
from . import bp
|
||||
from database import SessionLocal, BoardDocument, BoardMeeting, SystemRole, User
|
||||
from database import SessionLocal, BoardMeeting, SystemRole, User
|
||||
from utils.decorators import rada_member_required, office_manager_required
|
||||
from services.document_upload_service import DocumentUploadService
|
||||
from datetime import date, time
|
||||
|
||||
try:
|
||||
import weasyprint
|
||||
HAS_WEASYPRINT = True
|
||||
except ImportError:
|
||||
HAS_WEASYPRINT = False
|
||||
|
||||
def convert_docx_to_pdf(docx_path: str) -> str | None:
|
||||
"""
|
||||
Convert DOCX to PDF using LibreOffice headless.
|
||||
Returns path to generated PDF or None on failure.
|
||||
PDF is stored alongside the original with .pdf extension.
|
||||
"""
|
||||
# Generate PDF path (same location, .pdf extension)
|
||||
pdf_path = docx_path.rsplit('.', 1)[0] + '.pdf'
|
||||
|
||||
# Check if PDF already exists (cached)
|
||||
if os.path.exists(pdf_path):
|
||||
return pdf_path
|
||||
|
||||
# Convert using LibreOffice
|
||||
try:
|
||||
output_dir = os.path.dirname(docx_path)
|
||||
result = subprocess.run(
|
||||
[
|
||||
'soffice',
|
||||
'--headless',
|
||||
'--convert-to', 'pdf',
|
||||
'--outdir', output_dir,
|
||||
docx_path
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60 # 60 seconds timeout
|
||||
)
|
||||
|
||||
if result.returncode == 0 and os.path.exists(pdf_path):
|
||||
# Ensure www-data can read it
|
||||
os.chmod(pdf_path, 0o644)
|
||||
return pdf_path
|
||||
else:
|
||||
return None
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# =============================================================================
|
||||
# MEETING ROUTES
|
||||
# =============================================================================
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
@rada_member_required
|
||||
def index():
|
||||
"""Display list of board documents and board members"""
|
||||
# Get filter parameters
|
||||
year = request.args.get('year', type=int)
|
||||
month = request.args.get('month', type=int)
|
||||
doc_type = request.args.get('type', '')
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Build query for documents
|
||||
query = db.query(BoardDocument).filter(BoardDocument.is_active == True)
|
||||
|
||||
if year:
|
||||
query = query.filter(extract('year', BoardDocument.meeting_date) == year)
|
||||
if month:
|
||||
query = query.filter(extract('month', BoardDocument.meeting_date) == month)
|
||||
if doc_type and doc_type in BoardDocument.DOCUMENT_TYPES:
|
||||
query = query.filter(BoardDocument.document_type == doc_type)
|
||||
|
||||
# Order by meeting date descending
|
||||
documents = query.order_by(desc(BoardDocument.meeting_date)).all()
|
||||
|
||||
# Get available years for filter
|
||||
all_docs = db.query(BoardDocument).filter(BoardDocument.is_active == True).all()
|
||||
available_years = sorted(set(
|
||||
doc.meeting_date.year for doc in all_docs if doc.meeting_date
|
||||
), reverse=True)
|
||||
|
||||
# Get board members (is_rada_member = True)
|
||||
board_members = db.query(User).filter(
|
||||
User.is_rada_member == True,
|
||||
User.is_active == True
|
||||
).order_by(User.name).all()
|
||||
|
||||
# Check if user can upload (office_manager or admin)
|
||||
can_upload = current_user.has_role(SystemRole.OFFICE_MANAGER)
|
||||
|
||||
return render_template(
|
||||
'board/index.html',
|
||||
documents=documents,
|
||||
available_years=available_years,
|
||||
document_types=BoardDocument.DOCUMENT_TYPES,
|
||||
type_labels=BoardDocument.DOCUMENT_TYPE_LABELS,
|
||||
current_year=year,
|
||||
current_month=month,
|
||||
current_type=doc_type,
|
||||
can_upload=can_upload,
|
||||
board_members=board_members
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/<int:doc_id>/view')
|
||||
@login_required
|
||||
@rada_member_required
|
||||
def view(doc_id):
|
||||
"""View document in browser (PDF inline, DOCX converted to HTML)"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
document = db.query(BoardDocument).filter(
|
||||
BoardDocument.id == doc_id,
|
||||
BoardDocument.is_active == True
|
||||
).first()
|
||||
|
||||
if not document:
|
||||
flash('Dokument nie został znaleziony.', 'error')
|
||||
return redirect(url_for('board.index'))
|
||||
|
||||
# Get file path
|
||||
file_path = DocumentUploadService.get_file_path(
|
||||
document.stored_filename,
|
||||
document.uploaded_at
|
||||
)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
current_app.logger.error(f"Board document file not found: {file_path}")
|
||||
flash('Plik dokumentu nie został znaleziony.', 'error')
|
||||
return redirect(url_for('board.index'))
|
||||
|
||||
# Log view
|
||||
current_app.logger.info(
|
||||
f"Board document viewed: {document.title} (ID: {doc_id}) by user {current_user.id}"
|
||||
)
|
||||
|
||||
# Handle based on file type
|
||||
if document.file_extension == 'pdf':
|
||||
# Display PDF inline in browser
|
||||
return send_file(
|
||||
file_path,
|
||||
as_attachment=False,
|
||||
download_name=document.original_filename,
|
||||
mimetype='application/pdf'
|
||||
)
|
||||
|
||||
elif document.file_extension in ('docx', 'doc'):
|
||||
# Convert DOCX to PDF using LibreOffice (preserves formatting)
|
||||
pdf_path = convert_docx_to_pdf(file_path)
|
||||
|
||||
if pdf_path and os.path.exists(pdf_path):
|
||||
# Serve the converted PDF inline
|
||||
pdf_filename = document.original_filename.rsplit('.', 1)[0] + '.pdf'
|
||||
return send_file(
|
||||
pdf_path,
|
||||
as_attachment=False,
|
||||
download_name=pdf_filename,
|
||||
mimetype='application/pdf'
|
||||
)
|
||||
else:
|
||||
# Fallback to mammoth HTML conversion
|
||||
current_app.logger.warning(
|
||||
f"LibreOffice conversion failed for {document.title}, falling back to mammoth"
|
||||
)
|
||||
try:
|
||||
import mammoth
|
||||
|
||||
with open(file_path, 'rb') as docx_file:
|
||||
result = mammoth.convert_to_html(docx_file)
|
||||
html_content = result.value
|
||||
|
||||
return render_template(
|
||||
'board/view_document.html',
|
||||
document=document,
|
||||
html_content=html_content,
|
||||
conversion_messages=result.messages
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Failed to convert DOCX: {e}")
|
||||
flash('Błąd podczas konwersji dokumentu.', 'error')
|
||||
return redirect(url_for('board.index'))
|
||||
|
||||
else:
|
||||
# Unknown format - redirect to download
|
||||
flash('Podgląd tego typu pliku nie jest obsługiwany.', 'warning')
|
||||
return redirect(url_for('board.download', doc_id=doc_id))
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/upload', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@office_manager_required
|
||||
def upload():
|
||||
"""Upload new board document"""
|
||||
if request.method == 'POST':
|
||||
# Get form data
|
||||
title = request.form.get('title', '').strip()
|
||||
description = request.form.get('description', '').strip()
|
||||
document_type = request.form.get('document_type', 'protocol')
|
||||
meeting_date_str = request.form.get('meeting_date', '')
|
||||
meeting_number = request.form.get('meeting_number', type=int)
|
||||
|
||||
# Validate required fields
|
||||
errors = []
|
||||
if not title:
|
||||
errors.append('Tytuł dokumentu jest wymagany.')
|
||||
if not meeting_date_str:
|
||||
errors.append('Data posiedzenia jest wymagana.')
|
||||
|
||||
# Parse meeting date
|
||||
meeting_date = None
|
||||
if meeting_date_str:
|
||||
try:
|
||||
meeting_date = datetime.strptime(meeting_date_str, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
errors.append('Nieprawidłowy format daty.')
|
||||
|
||||
# Validate document type
|
||||
if document_type not in BoardDocument.DOCUMENT_TYPES:
|
||||
document_type = 'other'
|
||||
|
||||
# Get uploaded file
|
||||
file = request.files.get('document')
|
||||
if not file or file.filename == '':
|
||||
errors.append('Plik dokumentu jest wymagany.')
|
||||
|
||||
# Validate file
|
||||
if file and file.filename:
|
||||
is_valid, error_msg = DocumentUploadService.validate_file(file)
|
||||
if not is_valid:
|
||||
errors.append(error_msg)
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
flash(error, 'error')
|
||||
return render_template(
|
||||
'board/upload.html',
|
||||
document_types=BoardDocument.DOCUMENT_TYPES,
|
||||
type_labels=BoardDocument.DOCUMENT_TYPE_LABELS,
|
||||
form_data={
|
||||
'title': title,
|
||||
'description': description,
|
||||
'document_type': document_type,
|
||||
'meeting_date': meeting_date_str,
|
||||
'meeting_number': meeting_number
|
||||
}
|
||||
)
|
||||
|
||||
# Save file
|
||||
try:
|
||||
stored_filename, file_path, file_size, mime_type = DocumentUploadService.save_file(file)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Failed to save board document: {e}")
|
||||
flash('Błąd podczas zapisywania pliku. Spróbuj ponownie.', 'error')
|
||||
return redirect(url_for('board.upload'))
|
||||
|
||||
# Get file extension
|
||||
file_extension = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else ''
|
||||
|
||||
# Create database record
|
||||
document = BoardDocument(
|
||||
title=title,
|
||||
description=description if description else None,
|
||||
document_type=document_type,
|
||||
meeting_date=meeting_date,
|
||||
meeting_number=meeting_number if meeting_number else None,
|
||||
original_filename=file.filename,
|
||||
stored_filename=stored_filename,
|
||||
file_extension=file_extension,
|
||||
file_size=file_size,
|
||||
mime_type=mime_type,
|
||||
uploaded_by=current_user.id
|
||||
)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(document)
|
||||
db.commit()
|
||||
flash(f'Dokument "{title}" został dodany.', 'success')
|
||||
current_app.logger.info(f"Board document uploaded: {title} by user {current_user.id}")
|
||||
return redirect(url_for('board.index'))
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
# Clean up uploaded file
|
||||
DocumentUploadService.delete_file(stored_filename)
|
||||
current_app.logger.error(f"Failed to create board document record: {e}")
|
||||
flash('Błąd podczas tworzenia rekordu. Spróbuj ponownie.', 'error')
|
||||
return redirect(url_for('board.upload'))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# GET request - show upload form
|
||||
return render_template(
|
||||
'board/upload.html',
|
||||
document_types=BoardDocument.DOCUMENT_TYPES,
|
||||
type_labels=BoardDocument.DOCUMENT_TYPE_LABELS,
|
||||
form_data={}
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/<int:doc_id>/download')
|
||||
@login_required
|
||||
@rada_member_required
|
||||
def download(doc_id):
|
||||
"""Download board document (secure, authenticated)"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
document = db.query(BoardDocument).filter(
|
||||
BoardDocument.id == doc_id,
|
||||
BoardDocument.is_active == True
|
||||
).first()
|
||||
|
||||
if not document:
|
||||
flash('Dokument nie został znaleziony.', 'error')
|
||||
return redirect(url_for('board.index'))
|
||||
|
||||
# Get file path
|
||||
file_path = DocumentUploadService.get_file_path(
|
||||
document.stored_filename,
|
||||
document.uploaded_at
|
||||
)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
current_app.logger.error(f"Board document file not found: {file_path}")
|
||||
flash('Plik dokumentu nie został znaleziony.', 'error')
|
||||
return redirect(url_for('board.index'))
|
||||
|
||||
# Log download
|
||||
current_app.logger.info(
|
||||
f"Board document downloaded: {document.title} (ID: {doc_id}) by user {current_user.id}"
|
||||
)
|
||||
|
||||
# Send file with original filename
|
||||
return send_file(
|
||||
file_path,
|
||||
as_attachment=True,
|
||||
download_name=document.original_filename,
|
||||
mimetype=document.mime_type
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/<int:doc_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@office_manager_required
|
||||
def delete(doc_id):
|
||||
"""Soft delete board document"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
document = db.query(BoardDocument).filter(
|
||||
BoardDocument.id == doc_id,
|
||||
BoardDocument.is_active == True
|
||||
).first()
|
||||
|
||||
if not document:
|
||||
flash('Dokument nie został znaleziony.', 'error')
|
||||
return redirect(url_for('board.index'))
|
||||
|
||||
# Soft delete
|
||||
document.is_active = False
|
||||
document.updated_at = datetime.now()
|
||||
document.updated_by = current_user.id
|
||||
db.commit()
|
||||
|
||||
flash(f'Dokument "{document.title}" został usunięty.', 'success')
|
||||
current_app.logger.info(
|
||||
f"Board document deleted: {document.title} (ID: {doc_id}) by user {current_user.id}"
|
||||
)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
current_app.logger.error(f"Failed to delete board document: {e}")
|
||||
flash('Błąd podczas usuwania dokumentu.', 'error')
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return redirect(url_for('board.index'))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MEETING ROUTES (Agenda & Protocol Forms)
|
||||
# =============================================================================
|
||||
|
||||
@bp.route('/posiedzenia')
|
||||
@login_required
|
||||
@rada_member_required
|
||||
def meetings_list():
|
||||
"""List all board meetings"""
|
||||
"""Display list of board meetings and board members (main page)"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
meetings = db.query(BoardMeeting).order_by(
|
||||
@ -431,18 +52,32 @@ def meetings_list():
|
||||
desc(BoardMeeting.meeting_number)
|
||||
).all()
|
||||
|
||||
# Check if user can manage meetings
|
||||
# Get board members
|
||||
board_members = db.query(User).filter(
|
||||
User.is_rada_member == True,
|
||||
User.is_active == True
|
||||
).order_by(User.name).all()
|
||||
|
||||
can_manage = current_user.has_role(SystemRole.OFFICE_MANAGER)
|
||||
|
||||
return render_template(
|
||||
'board/meetings_list.html',
|
||||
meetings=meetings,
|
||||
board_members=board_members,
|
||||
can_manage=can_manage
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/posiedzenia')
|
||||
@login_required
|
||||
@rada_member_required
|
||||
def meetings_list():
|
||||
"""Redirect to main board page for backwards compatibility"""
|
||||
return redirect(url_for('board.index'))
|
||||
|
||||
|
||||
@bp.route('/posiedzenia/nowe', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@office_manager_required
|
||||
@ -516,7 +151,7 @@ def meeting_edit(meeting_id):
|
||||
|
||||
if not meeting:
|
||||
flash('Posiedzenie nie zostało znalezione.', 'error')
|
||||
return redirect(url_for('board.meetings_list'))
|
||||
return redirect(url_for('board.index'))
|
||||
|
||||
if request.method == 'POST':
|
||||
return _handle_meeting_form(db, meeting=meeting)
|
||||
@ -571,7 +206,7 @@ def meeting_view(meeting_id):
|
||||
|
||||
if not meeting:
|
||||
flash('Posiedzenie nie zostało znalezione.', 'error')
|
||||
return redirect(url_for('board.meetings_list'))
|
||||
return redirect(url_for('board.index'))
|
||||
|
||||
# Get board members for attendance display
|
||||
board_members = db.query(User).filter(
|
||||
@ -604,7 +239,7 @@ def meeting_publish_agenda(meeting_id):
|
||||
|
||||
if not meeting:
|
||||
flash('Posiedzenie nie zostało znalezione.', 'error')
|
||||
return redirect(url_for('board.meetings_list'))
|
||||
return redirect(url_for('board.index'))
|
||||
|
||||
if meeting.status != BoardMeeting.STATUS_DRAFT:
|
||||
flash('Program został już opublikowany.', 'warning')
|
||||
@ -631,8 +266,6 @@ def meeting_publish_agenda(meeting_id):
|
||||
f"Meeting agenda published: {meeting.meeting_identifier} by user {current_user.id}"
|
||||
)
|
||||
|
||||
# TODO: Send email notifications to board members
|
||||
|
||||
return redirect(url_for('board.meeting_view', meeting_id=meeting_id))
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
@ -656,7 +289,7 @@ def meeting_publish_protocol(meeting_id):
|
||||
|
||||
if not meeting:
|
||||
flash('Posiedzenie nie zostało znalezione.', 'error')
|
||||
return redirect(url_for('board.meetings_list'))
|
||||
return redirect(url_for('board.index'))
|
||||
|
||||
if meeting.status == BoardMeeting.STATUS_PROTOCOL_PUBLISHED:
|
||||
flash('Protokół został już opublikowany.', 'warning')
|
||||
@ -693,6 +326,90 @@ def meeting_publish_protocol(meeting_id):
|
||||
db.close()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MEETING PDF GENERATION
|
||||
# =============================================================================
|
||||
|
||||
def _generate_meeting_pdf(meeting_id, pdf_type):
|
||||
"""Generate PDF for meeting agenda or protocol.
|
||||
pdf_type: 'agenda' or 'protocol'
|
||||
"""
|
||||
if not HAS_WEASYPRINT:
|
||||
flash('Generowanie PDF nie jest dostępne (brak biblioteki weasyprint).', 'error')
|
||||
return redirect(url_for('board.meeting_view', meeting_id=meeting_id))
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
meeting = db.query(BoardMeeting).filter(
|
||||
BoardMeeting.id == meeting_id
|
||||
).first()
|
||||
|
||||
if not meeting:
|
||||
flash('Posiedzenie nie zostało znalezione.', 'error')
|
||||
return redirect(url_for('board.index'))
|
||||
|
||||
# Get board members for protocol attendance
|
||||
board_members = db.query(User).filter(
|
||||
User.is_rada_member == True,
|
||||
User.is_active == True
|
||||
).order_by(User.name).all()
|
||||
|
||||
# Render HTML template
|
||||
html_content = render_template(
|
||||
'board/meeting_pdf.html',
|
||||
meeting=meeting,
|
||||
board_members=board_members,
|
||||
pdf_type=pdf_type
|
||||
)
|
||||
|
||||
# Generate PDF
|
||||
pdf_bytes = weasyprint.HTML(string=html_content).write_pdf()
|
||||
|
||||
# Build filename
|
||||
if pdf_type == 'agenda':
|
||||
filename = f"Program_Posiedzenia_{meeting.meeting_identifier.replace('/', '_')}.pdf"
|
||||
else:
|
||||
filename = f"Protokol_Posiedzenia_{meeting.meeting_identifier.replace('/', '_')}.pdf"
|
||||
|
||||
current_app.logger.info(
|
||||
f"Meeting PDF generated: {pdf_type} for {meeting.meeting_identifier} by user {current_user.id}"
|
||||
)
|
||||
|
||||
return Response(
|
||||
pdf_bytes,
|
||||
mimetype='application/pdf',
|
||||
headers={
|
||||
'Content-Disposition': f'attachment; filename="{filename}"'
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Failed to generate meeting PDF: {e}")
|
||||
flash('Błąd podczas generowania PDF.', 'error')
|
||||
return redirect(url_for('board.meeting_view', meeting_id=meeting_id))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/posiedzenia/<int:meeting_id>/pdf-program')
|
||||
@login_required
|
||||
@rada_member_required
|
||||
def meeting_pdf_agenda(meeting_id):
|
||||
"""Download meeting agenda as PDF"""
|
||||
return _generate_meeting_pdf(meeting_id, 'agenda')
|
||||
|
||||
|
||||
@bp.route('/posiedzenia/<int:meeting_id>/pdf-protokol')
|
||||
@login_required
|
||||
@rada_member_required
|
||||
def meeting_pdf_protocol(meeting_id):
|
||||
"""Download meeting protocol as PDF"""
|
||||
return _generate_meeting_pdf(meeting_id, 'protocol')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MEETING FORM HANDLER
|
||||
# =============================================================================
|
||||
|
||||
def _handle_meeting_form(db, meeting=None):
|
||||
"""Handle meeting form submission (create or update)"""
|
||||
import json
|
||||
|
||||
@ -54,6 +54,7 @@ pyotp==2.9.0
|
||||
|
||||
# PDF generation
|
||||
pillow==12.1.0
|
||||
weasyprint==68.0
|
||||
|
||||
# ===========================================
|
||||
# Testing Dependencies
|
||||
|
||||
@ -1,547 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Strefa RADA - Norda Biznes Partner{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.board-header {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.board-header h1 {
|
||||
font-size: var(--font-size-3xl);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.board-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.board-info-banner {
|
||||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.board-info-banner svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #d97706;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.board-info-banner .info-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.board-info-banner .info-title {
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.board-info-banner .info-text {
|
||||
color: #a16207;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.board-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.board-filters {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.board-filters select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
background: white;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.btn-upload {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-upload:hover {
|
||||
background: var(--primary-dark);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-upload svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.documents-table {
|
||||
width: 100%;
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.documents-table thead {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.documents-table th {
|
||||
padding: var(--spacing-md);
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.documents-table td {
|
||||
padding: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.documents-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.documents-table tbody tr:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.doc-title {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.doc-type-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.doc-type-badge.protocol {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.doc-type-badge.minutes {
|
||||
background: #d1fae5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.doc-type-badge.resolution {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.doc-type-badge.report {
|
||||
background: #e0e7ff;
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.doc-type-badge.other {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.doc-meta {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.doc-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.btn-download {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--success);
|
||||
color: white;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-download:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.btn-download svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.btn-view {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-view:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.btn-view svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-delete svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--spacing-3xl);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: var(--spacing-md);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin-bottom: var(--spacing-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Board Members Section */
|
||||
.board-members-section {
|
||||
margin-top: var(--spacing-3xl);
|
||||
padding-top: var(--spacing-xl);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.board-members-section h2 {
|
||||
font-size: var(--font-size-xl);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.board-members-section h2 svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.members-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.member-card {
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
font-size: var(--font-size-lg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.member-company {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.member-count {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-muted);
|
||||
margin-left: var(--spacing-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.documents-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.board-actions {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.members-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="board-header">
|
||||
<h1>
|
||||
<svg width="32" height="32" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="color: #f59e0b;">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
Strefa RADA
|
||||
</h1>
|
||||
<p>Dokumenty i protokoły z posiedzeń Rady Izby NORDA</p>
|
||||
</div>
|
||||
|
||||
<div class="board-info-banner">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||
</svg>
|
||||
<div class="info-content">
|
||||
<div class="info-title">Strefa z ograniczonym dostępem</div>
|
||||
<div class="info-text">
|
||||
Ta sekcja jest dostępna wyłącznie dla członków Rady Izby NORDA.
|
||||
Wszystkie dokumenty są poufne i przeznaczone tylko do użytku wewnętrznego.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meetings-nav" style="display: flex; gap: var(--spacing-md); margin-bottom: var(--spacing-lg); border-bottom: 1px solid var(--border-color); padding-bottom: var(--spacing-md);">
|
||||
<a href="{{ url_for('board.index') }}" style="padding: 8px 16px; background: var(--primary); color: white; text-decoration: none; border-radius: var(--radius-md);">Dokumenty</a>
|
||||
<a href="{{ url_for('board.meetings_list') }}" style="padding: 8px 16px; color: var(--text-secondary); text-decoration: none; border-radius: var(--radius-md); transition: all 0.2s;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background='transparent'">Posiedzenia</a>
|
||||
</div>
|
||||
|
||||
<div class="board-actions">
|
||||
<form class="board-filters" method="GET" action="{{ url_for('board.index') }}">
|
||||
<select name="year" onchange="this.form.submit()">
|
||||
<option value="">Wszystkie lata</option>
|
||||
{% for year in available_years %}
|
||||
<option value="{{ year }}" {% if current_year == year %}selected{% endif %}>{{ year }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<select name="month" onchange="this.form.submit()">
|
||||
<option value="">Wszystkie miesiące</option>
|
||||
{% for m in range(1, 13) %}
|
||||
<option value="{{ m }}" {% if current_month == m %}selected{% endif %}>
|
||||
{{ ['Styczeń', 'Luty', 'Marzec', 'Kwiecień', 'Maj', 'Czerwiec', 'Lipiec', 'Sierpień', 'Wrzesień', 'Październik', 'Listopad', 'Grudzień'][m-1] }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<select name="type" onchange="this.form.submit()">
|
||||
<option value="">Wszystkie typy</option>
|
||||
{% for doc_type in document_types %}
|
||||
<option value="{{ doc_type }}" {% if current_type == doc_type %}selected{% endif %}>{{ type_labels[doc_type] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
|
||||
{% if can_upload %}
|
||||
<a href="{{ url_for('board.upload') }}" class="btn-upload">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
Dodaj dokument
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if documents %}
|
||||
<table class="documents-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tytuł</th>
|
||||
<th>Typ</th>
|
||||
<th>Data posiedzenia</th>
|
||||
<th>Rozmiar</th>
|
||||
<th>Dodano</th>
|
||||
<th>Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for doc in documents %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="doc-title">{{ doc.title }}</div>
|
||||
{% if doc.description %}
|
||||
<div class="doc-meta">{{ doc.description[:80] }}{% if doc.description|length > 80 %}...{% endif %}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="doc-type-badge {{ doc.document_type }}">{{ doc.type_label }}</span>
|
||||
</td>
|
||||
<td class="doc-meta">{{ doc.meeting_date.strftime('%d.%m.%Y') }}</td>
|
||||
<td class="doc-meta">{{ doc.size_display }}</td>
|
||||
<td class="doc-meta">{{ doc.uploaded_at.strftime('%d.%m.%Y') }}</td>
|
||||
<td>
|
||||
<div class="doc-actions">
|
||||
<a href="{{ url_for('board.view', doc_id=doc.id) }}" class="btn-view" target="_blank">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
Podgląd
|
||||
</a>
|
||||
<a href="{{ url_for('board.download', doc_id=doc.id) }}" class="btn-download">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
Pobierz
|
||||
</a>
|
||||
{% if can_upload %}
|
||||
<form action="{{ url_for('board.delete', doc_id=doc.id) }}" method="POST" style="display: inline;"
|
||||
onsubmit="return confirm('Czy na pewno chcesz usunąć ten dokument?');">
|
||||
<button type="submit" class="btn-delete">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/>
|
||||
</svg>
|
||||
<h3>Brak dokumentów</h3>
|
||||
<p>Nie znaleziono dokumentów spełniających kryteria wyszukiwania.</p>
|
||||
{% if can_upload %}
|
||||
<a href="{{ url_for('board.upload') }}" class="btn-upload" style="margin-top: var(--spacing-md);">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
Dodaj pierwszy dokument
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Board Members Section -->
|
||||
<div class="board-members-section">
|
||||
<h2>
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
Członkowie Rady Izby
|
||||
<span class="member-count">({{ board_members|length }} osób)</span>
|
||||
</h2>
|
||||
|
||||
<div class="members-grid">
|
||||
{% for member in board_members %}
|
||||
<div class="member-card">
|
||||
<div class="member-avatar">
|
||||
{{ member.name[:1].upper() if member.name else member.email[:1].upper() }}
|
||||
</div>
|
||||
<div class="member-info">
|
||||
<div class="member-name">{{ member.name or member.email.split('@')[0] }}</div>
|
||||
{% if member.company %}
|
||||
<div class="member-company">{{ member.company.name }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color: var(--text-muted);">Brak przypisanych członków Rady.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -502,7 +502,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<a href="{{ url_for('board.meetings_list') }}" class="back-link">
|
||||
<a href="{{ url_for('board.index') }}" class="back-link">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
@ -715,7 +715,7 @@
|
||||
</svg>
|
||||
{% if is_edit %}Zapisz zmiany{% else %}Utwórz posiedzenie{% endif %}
|
||||
</button>
|
||||
<a href="{{ url_for('board.meetings_list') }}" class="btn-cancel">Anuluj</a>
|
||||
<a href="{{ url_for('board.index') }}" class="btn-cancel">Anuluj</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
283
templates/board/meeting_pdf.html
Normal file
283
templates/board/meeting_pdf.html
Normal file
@ -0,0 +1,283 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 25mm 20mm 25mm 20mm;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
font-size: 11pt;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 2px solid #333;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 16pt;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: #666;
|
||||
margin: 4px 0;
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 13pt;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
flex: 1 1 45%;
|
||||
padding: 8px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.info-item strong {
|
||||
display: block;
|
||||
font-size: 9pt;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f5f5f5;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
.agenda-table td:first-child {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.agenda-table td:nth-child(2) {
|
||||
width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.proceeding {
|
||||
margin-bottom: 18px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
border-left: 3px solid #2563eb;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.proceeding h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
.proceeding h4 {
|
||||
margin: 8px 0 4px 0;
|
||||
font-size: 10pt;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.proceeding p {
|
||||
margin: 0 0 6px 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.proceeding ul {
|
||||
margin: 4px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.proceeding li {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.signature-section {
|
||||
margin-top: 60px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.signature {
|
||||
width: 40%;
|
||||
text-align: center;
|
||||
padding-top: 40px;
|
||||
border-top: 1px solid #333;
|
||||
font-size: 10pt;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{% if pdf_type == 'agenda' %}
|
||||
{# ==================== AGENDA PDF ==================== #}
|
||||
<div class="header">
|
||||
<h1>Program Posiedzenia Rady Izby Przedsiębiorców NORDA</h1>
|
||||
<p>Posiedzenie nr {{ meeting.meeting_identifier }}</p>
|
||||
<p>{{ meeting.meeting_date.strftime('%d.%m.%Y') }} | {{ meeting.location or 'Siedziba Izby' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<strong>Prowadzący</strong>
|
||||
{{ meeting.chairperson.name if meeting.chairperson else '—' }}
|
||||
</div>
|
||||
{% if meeting.start_time %}
|
||||
<div class="info-item">
|
||||
<strong>Godzina</strong>
|
||||
{{ meeting.start_time.strftime('%H:%M') }}{% if meeting.end_time %} - {{ meeting.end_time.strftime('%H:%M') }}{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if meeting.agenda_items %}
|
||||
<table class="agenda-table">
|
||||
<thead>
|
||||
<tr><th>Lp.</th><th>Godzina</th><th>Punkt programu</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in meeting.agenda_items %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td>{{ item.time_start or '—' }}{% if item.time_end %} - {{ item.time_end }}{% endif %}</td>
|
||||
<td>{{ item.title }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% elif pdf_type == 'protocol' %}
|
||||
{# ==================== PROTOCOL PDF ==================== #}
|
||||
<div class="header">
|
||||
<h1>PROTOKÓŁ</h1>
|
||||
<p>z Posiedzenia Rady Izby Przedsiębiorców NORDA</p>
|
||||
<p>Posiedzenie nr {{ meeting.meeting_identifier }}</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="info-grid">
|
||||
<div class="info-item"><strong>Data</strong>{{ meeting.meeting_date.strftime('%d.%m.%Y') }}</div>
|
||||
<div class="info-item">
|
||||
<strong>Godzina</strong>
|
||||
{{ meeting.start_time.strftime('%H:%M') if meeting.start_time else '—' }}{% if meeting.end_time %} - {{ meeting.end_time.strftime('%H:%M') }}{% endif %}
|
||||
</div>
|
||||
<div class="info-item"><strong>Miejsce</strong>{{ meeting.location or 'Siedziba Izby' }}</div>
|
||||
<div class="info-item"><strong>Prowadzący</strong>{{ meeting.chairperson.name if meeting.chairperson else '—' }}</div>
|
||||
<div class="info-item"><strong>Protokolant</strong>{{ meeting.secretary.name if meeting.secretary else '—' }}</div>
|
||||
<div class="info-item"><strong>Goście</strong>{{ meeting.guests or 'brak' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if meeting.attendance and board_members %}
|
||||
<div class="section">
|
||||
<h2>Lista obecności</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Lp.</th><th>Imię i nazwisko</th><th>Inicjały</th><th>Obecność</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for member in board_members %}
|
||||
{% set att = meeting.attendance.get(member.id|string, {}) %}
|
||||
{% set status = att.get('status', 'unknown') %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td>{{ member.name or member.email.split('@')[0] }}</td>
|
||||
<td>{{ att.get('initials', '') }}</td>
|
||||
<td>{{ 'obecny' if status == 'present' or att.get('present') else ('nieobecny' if status == 'absent' else '—') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if meeting.quorum_count is not none %}
|
||||
<p><strong>Kworum:</strong> {{ meeting.quorum_count }} / {{ board_members|length }} obecnych
|
||||
{% if meeting.quorum_confirmed %} — Kworum osiągnięte{% else %} — Brak kworum{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if meeting.proceedings %}
|
||||
<div class="section">
|
||||
<h2>Przebieg posiedzenia</h2>
|
||||
{% for proc in meeting.proceedings %}
|
||||
{% set agenda_item = meeting.agenda_items[proc.agenda_item] if meeting.agenda_items and proc.agenda_item < meeting.agenda_items|length else none %}
|
||||
<div class="proceeding">
|
||||
<h3>Ad. {{ proc.agenda_item + 1 }}. {{ proc.title or (agenda_item.title if agenda_item else 'Punkt programu') }}</h3>
|
||||
|
||||
{% set discussion_text = proc.discussion or proc.discussed %}
|
||||
{% if discussion_text %}
|
||||
<h4>Omówiono:</h4>
|
||||
<p>{{ discussion_text }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if proc.decisions is iterable and proc.decisions is not string and proc.decisions|length > 0 %}
|
||||
<h4>Ustalono / decyzje:</h4>
|
||||
<ul>
|
||||
{% for decision in proc.decisions %}
|
||||
<li>{{ decision }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% elif proc.decisions is string and proc.decisions %}
|
||||
<h4>Ustalono / decyzje:</h4>
|
||||
<p>{{ proc.decisions }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if proc.tasks is iterable and proc.tasks is not string and proc.tasks|length > 0 %}
|
||||
<h4>Zadania:</h4>
|
||||
<ul>
|
||||
{% for task in proc.tasks %}
|
||||
<li>{{ task }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="signature-section">
|
||||
<div class="signature">
|
||||
Prowadzący posiedzenie<br>
|
||||
<small>{{ meeting.chairperson.name if meeting.chairperson else '—' }}</small>
|
||||
</div>
|
||||
<div class="signature">
|
||||
Protokolant<br>
|
||||
<small>{{ meeting.secretary.name if meeting.secretary else '—' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@ -377,6 +377,11 @@
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
a.btn-print {
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-print svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
@ -434,7 +439,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="meeting-container">
|
||||
<a href="{{ url_for('board.meetings_list') }}" class="back-link">
|
||||
<a href="{{ url_for('board.index') }}" class="back-link">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
@ -507,8 +512,22 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Print buttons - visible for all members -->
|
||||
<!-- Print & PDF buttons - visible for all members -->
|
||||
<div class="print-buttons">
|
||||
<a href="{{ url_for('board.meeting_pdf_agenda', meeting_id=meeting.id) }}" class="btn-print">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
Pobierz program (PDF)
|
||||
</a>
|
||||
{% if meeting.proceedings %}
|
||||
<a href="{{ url_for('board.meeting_pdf_protocol', meeting_id=meeting.id) }}" class="btn-print">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
Pobierz protokół (PDF)
|
||||
</a>
|
||||
{% endif %}
|
||||
<button type="button" class="btn-print" onclick="printAgenda()">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"/>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Posiedzenia Rady - Strefa RADA{% endblock %}
|
||||
{% block title %}Strefa RADA - Norda Biznes Partner{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
@ -45,31 +45,6 @@
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.meetings-nav {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.meetings-nav a {
|
||||
padding: 8px 16px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.meetings-nav a:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.meetings-nav a.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.meetings-grid {
|
||||
display: grid;
|
||||
gap: var(--spacing-md);
|
||||
@ -249,6 +224,91 @@
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Board Members Section */
|
||||
.board-members-section {
|
||||
margin-top: var(--spacing-3xl);
|
||||
padding-top: var(--spacing-xl);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.board-members-section h2 {
|
||||
font-size: var(--font-size-xl);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.board-members-section h2 svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.members-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.member-card {
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
font-size: var(--font-size-lg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.member-company {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.member-count {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-muted);
|
||||
margin-left: var(--spacing-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.members-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@ -258,7 +318,7 @@
|
||||
<svg width="32" height="32" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
Posiedzenia Rady Izby
|
||||
Strefa RADA
|
||||
</h1>
|
||||
{% if can_manage %}
|
||||
<a href="{{ url_for('board.meeting_create') }}" class="btn-new-meeting">
|
||||
@ -270,11 +330,6 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="meetings-nav">
|
||||
<a href="{{ url_for('board.index') }}">Dokumenty</a>
|
||||
<a href="{{ url_for('board.meetings_list') }}" class="active">Posiedzenia</a>
|
||||
</div>
|
||||
|
||||
{% if meetings %}
|
||||
<div class="meetings-grid">
|
||||
{% for meeting in meetings %}
|
||||
@ -362,4 +417,36 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Board Members Section -->
|
||||
<div class="board-members-section">
|
||||
<h2>
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
Członkowie Rady Izby
|
||||
<span class="member-count">({{ board_members|length }} osób)</span>
|
||||
</h2>
|
||||
|
||||
<div class="members-grid">
|
||||
{% for member in board_members %}
|
||||
<div class="member-card">
|
||||
<div class="member-avatar">
|
||||
{{ member.name[:1].upper() if member.name else member.email[:1].upper() }}
|
||||
</div>
|
||||
<div class="member-info">
|
||||
<div class="member-name">{{ member.name or member.email.split('@')[0] }}</div>
|
||||
{% if member.company %}
|
||||
<div class="member-company">{{ member.company.name }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-state">Brak przypisanych członków Rady.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,382 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dodaj dokument - Strefa RADA{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.upload-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.upload-header {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.upload-header h1 {
|
||||
font-size: var(--font-size-2xl);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.upload-header p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: var(--font-size-sm);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.back-link svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.upload-form {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--spacing-xl);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.form-group label .required {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="date"],
|
||||
.form-group input[type="number"],
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-base);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-muted);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.file-upload-area {
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-xl);
|
||||
text-align: center;
|
||||
background: var(--bg-secondary);
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-upload-area:hover,
|
||||
.file-upload-area.dragover {
|
||||
border-color: var(--primary);
|
||||
background: rgba(37, 99, 235, 0.05);
|
||||
}
|
||||
|
||||
.file-upload-area svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.file-upload-area p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.file-upload-area .file-types {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.file-upload-area input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.selected-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
.selected-file svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.selected-file .file-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.selected-file .file-size {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.btn-remove-file {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--danger);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
margin-top: var(--spacing-xl);
|
||||
padding-top: var(--spacing-lg);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
font-size: var(--font-size-base);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.btn-submit svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 12px 24px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="upload-container">
|
||||
<a href="{{ url_for('board.index') }}" class="back-link">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Powrot do listy dokumentow
|
||||
</a>
|
||||
|
||||
<div class="upload-header">
|
||||
<h1>Dodaj nowy dokument</h1>
|
||||
<p>Dodaj protokol lub inny dokument z posiedzenia Rady Izby</p>
|
||||
</div>
|
||||
|
||||
<form class="upload-form" method="POST" enctype="multipart/form-data">
|
||||
<div class="form-group">
|
||||
<label for="title">Tytul dokumentu <span class="required">*</span></label>
|
||||
<input type="text" id="title" name="title" required
|
||||
value="{{ form_data.get('title', '') }}"
|
||||
placeholder="np. Protokol z posiedzenia Rady Izby - Luty 2026">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="document_type">Typ dokumentu</label>
|
||||
<select id="document_type" name="document_type">
|
||||
{% for doc_type in document_types %}
|
||||
<option value="{{ doc_type }}"
|
||||
{% if form_data.get('document_type') == doc_type %}selected{% endif %}>
|
||||
{{ type_labels[doc_type] }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="meeting_date">Data posiedzenia <span class="required">*</span></label>
|
||||
<input type="date" id="meeting_date" name="meeting_date" required
|
||||
value="{{ form_data.get('meeting_date', '') }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="meeting_number">Numer posiedzenia (opcjonalnie)</label>
|
||||
<input type="number" id="meeting_number" name="meeting_number" min="1"
|
||||
value="{{ form_data.get('meeting_number', '') }}"
|
||||
placeholder="np. 12">
|
||||
<div class="form-hint">Numer kolejny posiedzenia w danym roku</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Opis (opcjonalnie)</label>
|
||||
<textarea id="description" name="description"
|
||||
placeholder="Krotki opis zawartosci dokumentu...">{{ form_data.get('description', '') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Plik dokumentu <span class="required">*</span></label>
|
||||
<div class="file-upload-area" id="fileUploadArea" onclick="document.getElementById('document').click()">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
|
||||
</svg>
|
||||
<p>Kliknij lub przeciagnij plik tutaj</p>
|
||||
<span class="file-types">PDF, DOCX lub DOC (max 50MB)</span>
|
||||
<input type="file" id="document" name="document" accept=".pdf,.docx,.doc" required>
|
||||
</div>
|
||||
<div id="selectedFile" class="selected-file" style="display: none;">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span class="file-name" id="fileName"></span>
|
||||
<span class="file-size" id="fileSize"></span>
|
||||
<button type="button" class="btn-remove-file" onclick="removeFile()">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-submit">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
|
||||
</svg>
|
||||
Dodaj dokument
|
||||
</button>
|
||||
<a href="{{ url_for('board.index') }}" class="btn-cancel">Anuluj</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
const fileInput = document.getElementById('document');
|
||||
const fileUploadArea = document.getElementById('fileUploadArea');
|
||||
const selectedFile = document.getElementById('selectedFile');
|
||||
const fileName = document.getElementById('fileName');
|
||||
const fileSize = document.getElementById('fileSize');
|
||||
|
||||
fileInput.addEventListener('change', function(e) {
|
||||
if (this.files.length > 0) {
|
||||
showSelectedFile(this.files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
fileUploadArea.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
this.classList.add('dragover');
|
||||
});
|
||||
|
||||
fileUploadArea.addEventListener('dragleave', function(e) {
|
||||
e.preventDefault();
|
||||
this.classList.remove('dragover');
|
||||
});
|
||||
|
||||
fileUploadArea.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
this.classList.remove('dragover');
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
fileInput.files = files;
|
||||
showSelectedFile(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
function showSelectedFile(file) {
|
||||
fileName.textContent = file.name;
|
||||
fileSize.textContent = formatFileSize(file.size);
|
||||
selectedFile.style.display = 'flex';
|
||||
fileUploadArea.style.display = 'none';
|
||||
}
|
||||
|
||||
function removeFile() {
|
||||
fileInput.value = '';
|
||||
selectedFile.style.display = 'none';
|
||||
fileUploadArea.style.display = 'block';
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
{% endblock %}
|
||||
@ -1,247 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ document.title }} - Strefa RADA{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.document-viewer {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.document-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding-bottom: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.document-info h1 {
|
||||
font-size: var(--font-size-2xl);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.document-meta {
|
||||
display: flex;
|
||||
gap: var(--spacing-lg);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.document-meta span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.document-meta svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.document-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-action svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.btn-download {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-download:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.document-content {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--spacing-xl);
|
||||
box-shadow: var(--shadow);
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
/* Styling for converted DOCX content */
|
||||
.document-content h1 {
|
||||
font-size: var(--font-size-2xl);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.document-content h2 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-top: var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-md);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.document-content h3 {
|
||||
font-size: var(--font-size-lg);
|
||||
margin-top: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.document-content p {
|
||||
margin-bottom: var(--spacing-md);
|
||||
line-height: 1.7;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.document-content ul, .document-content ol {
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding-left: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.document-content li {
|
||||
margin-bottom: var(--spacing-xs);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.document-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.document-content th, .document-content td {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.document-content th {
|
||||
background: var(--bg-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.document-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: var(--spacing-md) 0;
|
||||
}
|
||||
|
||||
.document-content strong, .document-content b {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.document-content em, .document-content i {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.conversion-warning {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
font-size: var(--font-size-sm);
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.conversion-warning svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
vertical-align: middle;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.document-header {
|
||||
display: none;
|
||||
}
|
||||
.document-content {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="document-viewer">
|
||||
<div class="document-header">
|
||||
<div class="document-info">
|
||||
<h1>{{ document.title }}</h1>
|
||||
<div class="document-meta">
|
||||
<span>
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
{{ document.meeting_date.strftime('%d.%m.%Y') }}
|
||||
</span>
|
||||
<span>
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
{{ document.type_label }}
|
||||
</span>
|
||||
<span>
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4"/>
|
||||
</svg>
|
||||
{{ document.size_display }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="document-actions">
|
||||
<a href="{{ url_for('board.download', doc_id=document.id) }}" class="btn-action btn-download">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
Pobierz
|
||||
</a>
|
||||
<a href="{{ url_for('board.index') }}" class="btn-action btn-back">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Powrot
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if conversion_messages %}
|
||||
<div class="conversion-warning">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
Niektore elementy dokumentu mogly nie zostac poprawnie skonwertowane.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="document-content">
|
||||
{{ html_content | safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Loading…
Reference in New Issue
Block a user