feat: Add meeting agenda/protocol form system for Board Council
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

- Add BoardMeeting model with JSON fields for flexible data storage
- Add migration 049_board_meetings.sql
- Add routes for creating, editing, viewing meetings
- Add publish workflows for agenda and protocol
- Add templates: meetings_list, meeting_form (with tabs), meeting_view
- Support for: agenda items, attendance tracking, proceedings
- Pre-filled defaults for chairperson, secretary, location
- Quorum calculation (9/16 for majority)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-03 20:10:50 +01:00
parent e41187478b
commit d742b6676c
6 changed files with 2033 additions and 3 deletions

View File

@ -2,14 +2,22 @@
Board Routes (Rada Izby)
========================
Routes for board document management.
Routes for board document management and meeting forms.
Endpoints:
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+)
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+)
"""
import os
@ -25,9 +33,10 @@ from flask_login import login_required, current_user
from sqlalchemy import desc, extract
from . import bp
from database import SessionLocal, BoardDocument, SystemRole, User
from database import SessionLocal, BoardDocument, 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
def convert_docx_to_pdf(docx_path: str) -> str | None:
@ -404,3 +413,403 @@ def delete(doc_id):
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"""
db = SessionLocal()
try:
meetings = db.query(BoardMeeting).order_by(
desc(BoardMeeting.year),
desc(BoardMeeting.meeting_number)
).all()
# Check if user can manage meetings
can_manage = current_user.has_role(SystemRole.OFFICE_MANAGER)
return render_template(
'board/meetings_list.html',
meetings=meetings,
can_manage=can_manage
)
finally:
db.close()
@bp.route('/posiedzenia/nowe', methods=['GET', 'POST'])
@login_required
@office_manager_required
def meeting_create():
"""Create new board meeting"""
db = SessionLocal()
try:
if request.method == 'POST':
return _handle_meeting_form(db, meeting=None)
# GET - show form with defaults
# Get next meeting number for current year
current_year = datetime.now().year
last_meeting = db.query(BoardMeeting).filter(
BoardMeeting.year == current_year
).order_by(desc(BoardMeeting.meeting_number)).first()
next_number = (last_meeting.meeting_number + 1) if last_meeting else 1
# Get board members for attendance list
board_members = db.query(User).filter(
User.is_rada_member == True,
User.is_active == True
).order_by(User.name).all()
# Default chairperson (Prezes - Leszek Glaza)
default_chairperson = db.query(User).filter(
User.email == 'leszek@rotor.pl'
).first()
# Default secretary (Magdalena Klóska)
default_secretary = db.query(User).filter(
User.email.like('%kloska%')
).first()
return render_template(
'board/meeting_form.html',
meeting=None,
form_data={
'meeting_number': next_number,
'year': current_year,
'location': 'Siedziba Izby',
'start_time': '16:00',
'chairperson_id': default_chairperson.id if default_chairperson else None,
'secretary_id': default_secretary.id if default_secretary else None,
},
board_members=board_members,
is_edit=False
)
finally:
db.close()
@bp.route('/posiedzenia/<int:meeting_id>/edytuj', methods=['GET', 'POST'])
@login_required
@office_manager_required
def meeting_edit(meeting_id):
"""Edit existing board meeting"""
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.meetings_list'))
if request.method == 'POST':
return _handle_meeting_form(db, meeting=meeting)
# GET - show form with existing data
board_members = db.query(User).filter(
User.is_rada_member == True,
User.is_active == True
).order_by(User.name).all()
return render_template(
'board/meeting_form.html',
meeting=meeting,
form_data={
'meeting_number': meeting.meeting_number,
'year': meeting.year,
'meeting_date': meeting.meeting_date.isoformat() if meeting.meeting_date else '',
'start_time': meeting.start_time.strftime('%H:%M') if meeting.start_time else '',
'end_time': meeting.end_time.strftime('%H:%M') if meeting.end_time else '',
'location': meeting.location,
'chairperson_id': meeting.chairperson_id,
'secretary_id': meeting.secretary_id,
'guests': meeting.guests,
'agenda_items': meeting.agenda_items or [],
'attendance': meeting.attendance or {},
'proceedings': meeting.proceedings or [],
},
board_members=board_members,
is_edit=True
)
finally:
db.close()
@bp.route('/posiedzenia/<int:meeting_id>')
@login_required
@rada_member_required
def meeting_view(meeting_id):
"""View board meeting details"""
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.meetings_list'))
# Get board members for attendance display
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/meeting_view.html',
meeting=meeting,
board_members=board_members,
can_manage=can_manage
)
finally:
db.close()
@bp.route('/posiedzenia/<int:meeting_id>/publikuj-program', methods=['POST'])
@login_required
@office_manager_required
def meeting_publish_agenda(meeting_id):
"""Publish meeting agenda"""
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.meetings_list'))
if meeting.status != BoardMeeting.STATUS_DRAFT:
flash('Program został już opublikowany.', 'warning')
return redirect(url_for('board.meeting_view', meeting_id=meeting_id))
# Validate required fields
if not meeting.meeting_date:
flash('Data posiedzenia jest wymagana przed publikacją.', 'error')
return redirect(url_for('board.meeting_edit', meeting_id=meeting_id))
if not meeting.agenda_items:
flash('Program posiedzenia jest wymagany przed publikacją.', 'error')
return redirect(url_for('board.meeting_edit', meeting_id=meeting_id))
# Publish
meeting.status = BoardMeeting.STATUS_AGENDA_PUBLISHED
meeting.agenda_published_at = datetime.now()
meeting.updated_by = current_user.id
meeting.updated_at = datetime.now()
db.commit()
flash(f'Program posiedzenia {meeting.meeting_identifier} został opublikowany.', 'success')
current_app.logger.info(
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()
current_app.logger.error(f"Failed to publish meeting agenda: {e}")
flash('Błąd podczas publikowania programu.', 'error')
return redirect(url_for('board.meeting_view', meeting_id=meeting_id))
finally:
db.close()
@bp.route('/posiedzenia/<int:meeting_id>/publikuj-protokol', methods=['POST'])
@login_required
@office_manager_required
def meeting_publish_protocol(meeting_id):
"""Publish meeting protocol"""
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.meetings_list'))
if meeting.status == BoardMeeting.STATUS_PROTOCOL_PUBLISHED:
flash('Protokół został już opublikowany.', 'warning')
return redirect(url_for('board.meeting_view', meeting_id=meeting_id))
# Validate required fields for protocol
if not meeting.attendance:
flash('Lista obecności jest wymagana przed publikacją protokołu.', 'error')
return redirect(url_for('board.meeting_edit', meeting_id=meeting_id))
if not meeting.proceedings:
flash('Przebieg posiedzenia jest wymagany przed publikacją protokołu.', 'error')
return redirect(url_for('board.meeting_edit', meeting_id=meeting_id))
# Publish
meeting.status = BoardMeeting.STATUS_PROTOCOL_PUBLISHED
meeting.protocol_published_at = datetime.now()
meeting.updated_by = current_user.id
meeting.updated_at = datetime.now()
db.commit()
flash(f'Protokół z posiedzenia {meeting.meeting_identifier} został opublikowany.', 'success')
current_app.logger.info(
f"Meeting protocol published: {meeting.meeting_identifier} by user {current_user.id}"
)
return redirect(url_for('board.meeting_view', meeting_id=meeting_id))
except Exception as e:
db.rollback()
current_app.logger.error(f"Failed to publish meeting protocol: {e}")
flash('Błąd podczas publikowania protokołu.', 'error')
return redirect(url_for('board.meeting_view', meeting_id=meeting_id))
finally:
db.close()
def _handle_meeting_form(db, meeting=None):
"""Handle meeting form submission (create or update)"""
import json
# Get form data
meeting_number = request.form.get('meeting_number', type=int)
year = request.form.get('year', type=int)
meeting_date_str = request.form.get('meeting_date', '')
start_time_str = request.form.get('start_time', '')
end_time_str = request.form.get('end_time', '')
location = request.form.get('location', '').strip()
chairperson_id = request.form.get('chairperson_id', type=int)
secretary_id = request.form.get('secretary_id', type=int)
guests = request.form.get('guests', '').strip()
# Parse agenda items from JSON
agenda_items_json = request.form.get('agenda_items', '[]')
try:
agenda_items = json.loads(agenda_items_json)
except json.JSONDecodeError:
agenda_items = []
# Parse attendance from form checkboxes
attendance = {}
for key, value in request.form.items():
if key.startswith('attendance_'):
user_id = key.replace('attendance_', '')
initials = request.form.get(f'initials_{user_id}', '')
attendance[user_id] = {
'present': value == 'present',
'initials': initials
}
# Parse proceedings from JSON
proceedings_json = request.form.get('proceedings', '[]')
try:
proceedings = json.loads(proceedings_json)
except json.JSONDecodeError:
proceedings = []
# Validate
errors = []
if not meeting_number:
errors.append('Numer posiedzenia jest wymagany.')
if not year:
errors.append('Rok jest wymagany.')
# Parse date/time
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.')
start_time = None
if start_time_str:
try:
start_time = datetime.strptime(start_time_str, '%H:%M').time()
except ValueError:
errors.append('Nieprawidłowy format godziny rozpoczęcia.')
end_time = None
if end_time_str:
try:
end_time = datetime.strptime(end_time_str, '%H:%M').time()
except ValueError:
errors.append('Nieprawidłowy format godziny zakończenia.')
if errors:
for error in errors:
flash(error, 'error')
return redirect(request.url)
try:
if meeting:
# Update existing
meeting.meeting_number = meeting_number
meeting.year = year
meeting.meeting_date = meeting_date
meeting.start_time = start_time
meeting.end_time = end_time
meeting.location = location or 'Siedziba Izby'
meeting.chairperson_id = chairperson_id
meeting.secretary_id = secretary_id
meeting.guests = guests or None
meeting.agenda_items = agenda_items if agenda_items else None
meeting.attendance = attendance if attendance else None
meeting.proceedings = proceedings if proceedings else None
meeting.updated_by = current_user.id
meeting.updated_at = datetime.now()
# Update quorum count
if attendance:
meeting.quorum_count = sum(1 for a in attendance.values() if a.get('present'))
meeting.quorum_confirmed = meeting.quorum_count >= 9 # Majority of 16
db.commit()
flash(f'Posiedzenie {meeting.meeting_identifier} zostało zaktualizowane.', 'success')
return redirect(url_for('board.meeting_view', meeting_id=meeting.id))
else:
# Create new
new_meeting = BoardMeeting(
meeting_number=meeting_number,
year=year,
meeting_date=meeting_date,
start_time=start_time,
end_time=end_time,
location=location or 'Siedziba Izby',
chairperson_id=chairperson_id,
secretary_id=secretary_id,
guests=guests or None,
agenda_items=agenda_items if agenda_items else None,
attendance=attendance if attendance else None,
proceedings=proceedings if proceedings else None,
status=BoardMeeting.STATUS_DRAFT,
created_by=current_user.id
)
if attendance:
new_meeting.quorum_count = sum(1 for a in attendance.values() if a.get('present'))
new_meeting.quorum_confirmed = new_meeting.quorum_count >= 9
db.add(new_meeting)
db.commit()
flash(f'Posiedzenie {new_meeting.meeting_identifier} zostało utworzone.', 'success')
return redirect(url_for('board.meeting_view', meeting_id=new_meeting.id))
except Exception as e:
db.rollback()
current_app.logger.error(f"Failed to save meeting: {e}")
flash('Błąd podczas zapisywania posiedzenia.', 'error')
return redirect(request.url)

View File

@ -1584,6 +1584,113 @@ class BoardDocument(Base):
return f"/data/board-docs/{date.year}/{date.month:02d}/{self.stored_filename}"
class BoardMeeting(Base):
"""
Board Council meetings with agenda and protocol data.
Stores meeting metadata, agenda items, attendance, and proceedings.
"""
__tablename__ = 'board_meetings'
id = Column(Integer, primary_key=True)
# Meeting identification
meeting_number = Column(Integer, nullable=False) # Sequential number (e.g., 1 for first meeting of year)
year = Column(Integer, nullable=False) # Year for numbering (e.g., 2026)
# Meeting schedule
meeting_date = Column(Date, nullable=False)
start_time = Column(Time) # e.g., 16:00
end_time = Column(Time) # e.g., 18:30
location = Column(String(255), default='Siedziba Izby')
# Meeting roles
chairperson_id = Column(Integer, ForeignKey('users.id')) # Prowadzący
secretary_id = Column(Integer, ForeignKey('users.id')) # Protokolant
# Guests (non-members attending)
guests = Column(Text) # Free text or JSON array
# Agenda items - JSON array of objects:
# [{"time_start": "16:00", "time_end": "16:10", "title": "...", "description": "..."}]
agenda_items = Column(JSON)
# Attendance records - JSON object:
# {"user_id": {"present": true/false, "initials": "LG", "notes": ""}}
attendance = Column(JSON)
# Quorum info
quorum_count = Column(Integer) # Number of present members
quorum_confirmed = Column(Boolean) # Was quorum achieved?
# Proceedings - JSON array of objects:
# [{"agenda_item": 1, "discussed": "...", "decisions": "...", "votes": {...}}]
proceedings = Column(JSON)
# Status workflow
status = Column(String(20), default='draft')
# draft -> agenda_published -> protocol_draft -> protocol_published
# Audit fields
created_by = Column(Integer, ForeignKey('users.id'), nullable=False)
created_at = Column(DateTime, default=datetime.now)
updated_by = Column(Integer, ForeignKey('users.id'))
updated_at = Column(DateTime, onupdate=datetime.now)
agenda_published_at = Column(DateTime)
protocol_published_at = Column(DateTime)
# Relationships
chairperson = relationship('User', foreign_keys=[chairperson_id])
secretary = relationship('User', foreign_keys=[secretary_id])
creator = relationship('User', foreign_keys=[created_by])
editor = relationship('User', foreign_keys=[updated_by])
# Status constants
STATUS_DRAFT = 'draft'
STATUS_AGENDA_PUBLISHED = 'agenda_published'
STATUS_PROTOCOL_DRAFT = 'protocol_draft'
STATUS_PROTOCOL_PUBLISHED = 'protocol_published'
STATUS_LABELS = {
'draft': 'Szkic',
'agenda_published': 'Program opublikowany',
'protocol_draft': 'Protokół w przygotowaniu',
'protocol_published': 'Protokół opublikowany'
}
@property
def status_label(self):
"""Get Polish label for status"""
return self.STATUS_LABELS.get(self.status, 'Nieznany')
@property
def meeting_identifier(self):
"""Get meeting identifier like '1/2026'"""
return f"{self.meeting_number}/{self.year}"
def get_attendance_list(self, db_session):
"""Get attendance list with user details"""
from sqlalchemy.orm import Session
if not self.attendance:
return []
user_ids = [int(uid) for uid in self.attendance.keys()]
users = db_session.query(User).filter(User.id.in_(user_ids)).all()
user_map = {u.id: u for u in users}
result = []
for user_id_str, data in self.attendance.items():
user_id = int(user_id_str)
user = user_map.get(user_id)
if user:
result.append({
'user': user,
'present': data.get('present', False),
'initials': data.get('initials', ''),
'notes': data.get('notes', '')
})
return sorted(result, key=lambda x: x['user'].name or '')
class ForumTopicSubscription(Base):
"""Forum topic subscriptions for notifications"""
__tablename__ = 'forum_topic_subscriptions'

View File

@ -0,0 +1,60 @@
-- Migration: Board Meetings (Agenda & Protocol Forms)
-- Date: 2026-02-03
-- Description: Creates table for structured board meeting data (agenda, attendance, proceedings)
-- Create board_meetings table
CREATE TABLE IF NOT EXISTS board_meetings (
id SERIAL PRIMARY KEY,
-- Meeting identification
meeting_number INTEGER NOT NULL,
year INTEGER NOT NULL,
-- Meeting schedule
meeting_date DATE NOT NULL,
start_time TIME,
end_time TIME,
location VARCHAR(255) DEFAULT 'Siedziba Izby',
-- Meeting roles
chairperson_id INTEGER REFERENCES users(id),
secretary_id INTEGER REFERENCES users(id),
-- Guests (non-members)
guests TEXT,
-- Structured data (JSON)
agenda_items JSONB,
attendance JSONB,
proceedings JSONB,
-- Quorum
quorum_count INTEGER,
quorum_confirmed BOOLEAN,
-- Status workflow: draft -> agenda_published -> protocol_draft -> protocol_published
status VARCHAR(20) DEFAULT 'draft',
-- Audit fields
created_by INTEGER NOT NULL REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_by INTEGER REFERENCES users(id),
updated_at TIMESTAMP,
agenda_published_at TIMESTAMP,
protocol_published_at TIMESTAMP,
-- Unique constraint for meeting number per year
CONSTRAINT uq_board_meeting_number_year UNIQUE (meeting_number, year)
);
-- Indexes
CREATE INDEX idx_board_meetings_year ON board_meetings(year);
CREATE INDEX idx_board_meetings_date ON board_meetings(meeting_date);
CREATE INDEX idx_board_meetings_status ON board_meetings(status);
-- Grant permissions
GRANT ALL ON TABLE board_meetings TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE board_meetings_id_seq TO nordabiz_app;
-- Add comment
COMMENT ON TABLE board_meetings IS 'Stores structured board meeting data including agenda, attendance, and proceedings';

View File

@ -0,0 +1,621 @@
{% extends "base.html" %}
{% block title %}{% if is_edit %}Edytuj posiedzenie{% else %}Nowe posiedzenie{% endif %} - Strefa RADA{% endblock %}
{% block extra_css %}
<style>
.form-container {
max-width: 900px;
margin: 0 auto;
}
.form-header {
margin-bottom: var(--spacing-xl);
}
.form-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.form-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;
}
.form-section {
background: white;
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
margin-bottom: var(--spacing-lg);
}
.form-section h2 {
font-size: var(--font-size-lg);
color: var(--text-primary);
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-sm);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.form-section h2 svg {
width: 20px;
height: 20px;
color: #f59e0b;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-md);
}
.form-group {
margin-bottom: var(--spacing-md);
}
.form-group label {
display: block;
font-weight: 500;
color: var(--text-primary);
margin-bottom: var(--spacing-xs);
}
.form-group label .required {
color: var(--danger);
}
.form-group input,
.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-hint {
font-size: var(--font-size-sm);
color: var(--text-muted);
margin-top: var(--spacing-xs);
}
/* Agenda Items */
.agenda-items-list {
margin-top: var(--spacing-md);
}
.agenda-item {
display: grid;
grid-template-columns: 80px 80px 1fr auto;
gap: var(--spacing-sm);
align-items: center;
padding: var(--spacing-sm);
background: var(--bg-secondary);
border-radius: var(--radius-md);
margin-bottom: var(--spacing-sm);
}
.agenda-item input {
padding: 8px 10px;
font-size: var(--font-size-sm);
}
.btn-remove-item {
background: none;
border: none;
color: var(--danger);
cursor: pointer;
padding: 4px;
}
.btn-add-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px dashed var(--border-color);
border-radius: var(--radius-md);
cursor: pointer;
font-size: var(--font-size-sm);
margin-top: var(--spacing-sm);
}
.btn-add-item:hover {
border-color: var(--primary);
color: var(--primary);
}
/* Attendance */
.attendance-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--spacing-sm);
}
.attendance-row {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm);
background: var(--bg-secondary);
border-radius: var(--radius-md);
}
.attendance-row input[type="checkbox"] {
width: 18px;
height: 18px;
}
.attendance-row .member-name {
flex: 1;
font-weight: 500;
}
.attendance-row input[type="text"] {
width: 50px;
padding: 4px 8px;
text-align: center;
font-size: var(--font-size-sm);
}
/* Proceedings */
.proceeding-item {
background: var(--bg-secondary);
border-radius: var(--radius-md);
padding: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.proceeding-item h4 {
margin-bottom: var(--spacing-sm);
color: var(--text-primary);
}
.proceeding-item textarea {
min-height: 80px;
}
/* Form Actions */
.form-actions {
display: flex;
gap: var(--spacing-md);
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;
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);
text-decoration: none;
transition: all 0.2s;
}
.btn-cancel:hover {
background: var(--bg-secondary);
}
/* Tabs */
.form-tabs {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-lg);
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--spacing-md);
}
.form-tab {
padding: 8px 16px;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-radius: var(--radius-md);
font-size: var(--font-size-base);
}
.form-tab:hover {
background: var(--bg-secondary);
}
.form-tab.active {
background: var(--primary);
color: white;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
</style>
{% endblock %}
{% block content %}
<div class="form-container">
<a href="{{ url_for('board.meetings_list') }}" 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 posiedzen
</a>
<div class="form-header">
<h1>{% if is_edit %}Edytuj posiedzenie {{ meeting.meeting_identifier }}{% else %}Nowe posiedzenie Rady Izby{% endif %}</h1>
<p>{% if is_edit %}Zaktualizuj dane posiedzenia{% else %}Wypelnij dane programu i protokolu posiedzenia{% endif %}</p>
</div>
<form method="POST" id="meetingForm">
<!-- Tabs -->
<div class="form-tabs">
<button type="button" class="form-tab active" data-tab="basic">Dane podstawowe</button>
<button type="button" class="form-tab" data-tab="agenda">Program</button>
<button type="button" class="form-tab" data-tab="attendance">Obecnosc</button>
<button type="button" class="form-tab" data-tab="proceedings">Przebieg</button>
</div>
<!-- Basic Data Tab -->
<div class="tab-content active" id="tab-basic">
<div class="form-section">
<h2>
<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>
Dane posiedzenia
</h2>
<div class="form-row">
<div class="form-group">
<label for="meeting_number">Numer posiedzenia <span class="required">*</span></label>
<input type="number" id="meeting_number" name="meeting_number" required min="1"
value="{{ form_data.get('meeting_number', '') }}">
</div>
<div class="form-group">
<label for="year">Rok <span class="required">*</span></label>
<input type="number" id="year" name="year" required min="2020" max="2030"
value="{{ form_data.get('year', '') }}">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="meeting_date">Data posiedzenia</label>
<input type="date" id="meeting_date" name="meeting_date"
value="{{ form_data.get('meeting_date', '') }}">
</div>
<div class="form-group">
<label for="start_time">Godzina rozpoczecia</label>
<input type="time" id="start_time" name="start_time"
value="{{ form_data.get('start_time', '16:00') }}">
</div>
<div class="form-group">
<label for="end_time">Godzina zakonczenia</label>
<input type="time" id="end_time" name="end_time"
value="{{ form_data.get('end_time', '') }}">
</div>
</div>
<div class="form-group">
<label for="location">Miejsce</label>
<input type="text" id="location" name="location"
value="{{ form_data.get('location', 'Siedziba Izby') }}"
placeholder="np. Siedziba Izby">
</div>
<div class="form-row">
<div class="form-group">
<label for="chairperson_id">Prowadzacy posiedzenie</label>
<select id="chairperson_id" name="chairperson_id">
<option value="">-- Wybierz --</option>
{% for member in board_members %}
<option value="{{ member.id }}" {% if form_data.get('chairperson_id') == member.id %}selected{% endif %}>
{{ member.name or member.email }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="secretary_id">Protokolant</label>
<select id="secretary_id" name="secretary_id">
<option value="">-- Wybierz --</option>
{% for member in board_members %}
<option value="{{ member.id }}" {% if form_data.get('secretary_id') == member.id %}selected{% endif %}>
{{ member.name or member.email }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-group">
<label for="guests">Goscie (osoby spoza Rady)</label>
<textarea id="guests" name="guests" rows="2"
placeholder="Wpisz imiona i nazwiska gosci...">{{ form_data.get('guests', '') }}</textarea>
</div>
</div>
</div>
<!-- Agenda Tab -->
<div class="tab-content" id="tab-agenda">
<div class="form-section">
<h2>
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
</svg>
Program posiedzenia
</h2>
<p class="form-hint">Dodaj punkty programu z planowanymi godzinami.</p>
<div class="agenda-items-list" id="agendaItemsList">
<!-- Default items will be added by JS -->
</div>
<button type="button" class="btn-add-item" onclick="addAgendaItem()">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M12 4v16m8-8H4"/>
</svg>
Dodaj punkt programu
</button>
<input type="hidden" name="agenda_items" id="agendaItemsJson">
</div>
</div>
<!-- Attendance Tab -->
<div class="tab-content" id="tab-attendance">
<div class="form-section">
<h2>
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 00-3-3.87"/>
<path d="M16 3.13a4 4 0 010 7.75"/>
</svg>
Lista obecnosci
</h2>
<p class="form-hint">Zaznacz obecnych czlonkow Rady i wpisz ich inicjaly do protokolu.</p>
<div class="attendance-grid">
{% for member in board_members %}
{% set member_attendance = form_data.get('attendance', {}).get(member.id|string, {}) %}
<div class="attendance-row">
<input type="checkbox" name="attendance_{{ member.id }}" value="present"
{% if member_attendance.get('present') %}checked{% endif %}>
<span class="member-name">{{ member.name or member.email.split('@')[0] }}</span>
<input type="text" name="initials_{{ member.id }}" placeholder="XX"
value="{{ member_attendance.get('initials', '') }}"
maxlength="4">
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Proceedings Tab -->
<div class="tab-content" id="tab-proceedings">
<div class="form-section">
<h2>
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
Przebieg posiedzenia i ustalenia
</h2>
<p class="form-hint">Dla kazdego punktu programu opisz przebieg dyskusji i podjete ustalenia.</p>
<div id="proceedingsList">
<!-- Will be populated by JS based on agenda items -->
</div>
<input type="hidden" name="proceedings" id="proceedingsJson">
</div>
</div>
<!-- Form Actions -->
<div class="form-section">
<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="M5 13l4 4L19 7"/>
</svg>
{% if is_edit %}Zapisz zmiany{% else %}Utworz posiedzenie{% endif %}
</button>
<a href="{{ url_for('board.meetings_list') }}" class="btn-cancel">Anuluj</a>
</div>
</div>
</form>
</div>
{% endblock %}
{% block extra_js %}
// Tab switching
document.querySelectorAll('.form-tab').forEach(tab => {
tab.addEventListener('click', function() {
document.querySelectorAll('.form-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
this.classList.add('active');
document.getElementById('tab-' + this.dataset.tab).classList.add('active');
});
});
// Agenda Items
let agendaItems = {{ form_data.get('agenda_items', [])|tojson|safe }};
// Default items if empty
if (agendaItems.length === 0) {
agendaItems = [
{ time_start: '16:00', time_end: '16:10', title: 'Otwarcie posiedzenia i akceptacja programu' },
{ time_start: '16:10', time_end: '16:15', title: 'Zbieranie kworum' },
{ time_start: '', time_end: '', title: '' },
{ time_start: '', time_end: '', title: 'Wolne wnioski i sprawy rozne' },
{ time_start: '', time_end: '', title: 'Ustalenie daty kolejnego posiedzenia' },
{ time_start: '', time_end: '', title: 'Zamkniecie posiedzenia' }
];
}
function renderAgendaItems() {
const list = document.getElementById('agendaItemsList');
list.innerHTML = '';
agendaItems.forEach((item, index) => {
const div = document.createElement('div');
div.className = 'agenda-item';
div.innerHTML = `
<input type="time" value="${item.time_start || ''}" onchange="updateAgendaItem(${index}, 'time_start', this.value)" placeholder="Od">
<input type="time" value="${item.time_end || ''}" onchange="updateAgendaItem(${index}, 'time_end', this.value)" placeholder="Do">
<input type="text" value="${item.title || ''}" onchange="updateAgendaItem(${index}, 'title', this.value)" placeholder="Tytul punktu programu">
<button type="button" class="btn-remove-item" onclick="removeAgendaItem(${index})">
<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>
`;
list.appendChild(div);
});
updateAgendaJson();
renderProceedings();
}
function addAgendaItem() {
agendaItems.push({ time_start: '', time_end: '', title: '' });
renderAgendaItems();
}
function removeAgendaItem(index) {
agendaItems.splice(index, 1);
renderAgendaItems();
}
function updateAgendaItem(index, field, value) {
agendaItems[index][field] = value;
updateAgendaJson();
if (field === 'title') {
renderProceedings();
}
}
function updateAgendaJson() {
document.getElementById('agendaItemsJson').value = JSON.stringify(agendaItems);
}
// Proceedings
let proceedings = {{ form_data.get('proceedings', [])|tojson|safe }};
function renderProceedings() {
const list = document.getElementById('proceedingsList');
list.innerHTML = '';
agendaItems.forEach((item, index) => {
if (!item.title) return;
const proc = proceedings.find(p => p.agenda_item === index) || { discussed: '', decisions: '' };
const div = document.createElement('div');
div.className = 'proceeding-item';
div.innerHTML = `
<h4>Ad. ${index + 1}. ${item.title}</h4>
<div class="form-group">
<label>Omowiono:</label>
<textarea onchange="updateProceeding(${index}, 'discussed', this.value)" placeholder="Opis przebiegu dyskusji...">${proc.discussed || ''}</textarea>
</div>
<div class="form-group">
<label>Ustalono / decyzje:</label>
<textarea onchange="updateProceeding(${index}, 'decisions', this.value)" placeholder="Podjete ustalenia i decyzje...">${proc.decisions || ''}</textarea>
</div>
`;
list.appendChild(div);
});
}
function updateProceeding(agendaIndex, field, value) {
let proc = proceedings.find(p => p.agenda_item === agendaIndex);
if (!proc) {
proc = { agenda_item: agendaIndex, discussed: '', decisions: '' };
proceedings.push(proc);
}
proc[field] = value;
updateProceedingsJson();
}
function updateProceedingsJson() {
document.getElementById('proceedingsJson').value = JSON.stringify(proceedings);
}
// Initialize
renderAgendaItems();
// Update JSON before submit
document.getElementById('meetingForm').addEventListener('submit', function() {
updateAgendaJson();
updateProceedingsJson();
});
{% endblock %}

View File

@ -0,0 +1,545 @@
{% extends "base.html" %}
{% block title %}Posiedzenie {{ meeting.meeting_identifier }} - Strefa RADA{% endblock %}
{% block extra_css %}
<style>
.meeting-container {
max-width: 900px;
margin: 0 auto;
}
.meeting-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-xl);
padding-bottom: var(--spacing-lg);
border-bottom: 1px solid var(--border-color);
}
.meeting-title-section h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.meeting-meta {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-lg);
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.meeting-meta span {
display: flex;
align-items: center;
gap: 6px;
}
.meeting-meta svg {
width: 16px;
height: 16px;
}
.meeting-actions {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
align-items: flex-end;
}
.meeting-status {
display: inline-flex;
align-items: center;
padding: 6px 14px;
border-radius: var(--radius-full);
font-size: var(--font-size-sm);
font-weight: 600;
}
.meeting-status.draft {
background: #fef3c7;
color: #92400e;
}
.meeting-status.agenda_published {
background: #dbeafe;
color: #1e40af;
}
.meeting-status.protocol_draft {
background: #e0e7ff;
color: #3730a3;
}
.meeting-status.protocol_published {
background: #d1fae5;
color: #065f46;
}
.action-buttons {
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;
border: none;
cursor: pointer;
}
.btn-action svg {
width: 16px;
height: 16px;
}
.btn-edit {
background: var(--bg-secondary);
color: var(--text-primary);
}
.btn-edit:hover {
background: var(--bg-tertiary);
}
.btn-publish {
background: var(--success);
color: white;
}
.btn-publish:hover {
background: #059669;
}
.btn-back {
background: var(--bg-secondary);
color: var(--text-secondary);
}
.btn-back:hover {
background: var(--bg-tertiary);
}
.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;
}
.meeting-section {
background: white;
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
margin-bottom: var(--spacing-lg);
}
.meeting-section h2 {
font-size: var(--font-size-lg);
color: var(--text-primary);
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-sm);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.meeting-section h2 svg {
width: 20px;
height: 20px;
color: #f59e0b;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-md);
}
.info-item {
padding: var(--spacing-sm);
}
.info-item label {
display: block;
font-size: var(--font-size-sm);
color: var(--text-muted);
margin-bottom: 4px;
}
.info-item .value {
font-weight: 500;
color: var(--text-primary);
}
/* Agenda */
.agenda-list {
list-style: none;
padding: 0;
margin: 0;
}
.agenda-list li {
display: flex;
gap: var(--spacing-md);
padding: var(--spacing-sm) 0;
border-bottom: 1px solid var(--border-color);
}
.agenda-list li:last-child {
border-bottom: none;
}
.agenda-time {
font-size: var(--font-size-sm);
color: var(--text-muted);
min-width: 100px;
}
.agenda-title {
flex: 1;
color: var(--text-primary);
}
/* Attendance */
.attendance-table {
width: 100%;
border-collapse: collapse;
}
.attendance-table th,
.attendance-table td {
padding: var(--spacing-sm) var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.attendance-table th {
background: var(--bg-secondary);
font-weight: 600;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.attendance-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--font-size-sm);
}
.attendance-status.present {
color: var(--success);
}
.attendance-status.absent {
color: var(--text-muted);
}
.attendance-status svg {
width: 16px;
height: 16px;
}
.quorum-info {
margin-top: var(--spacing-md);
padding: var(--spacing-md);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
}
.quorum-info.confirmed {
background: #d1fae5;
color: #065f46;
}
.quorum-info.not-confirmed {
background: #fee2e2;
color: #991b1b;
}
/* Proceedings */
.proceeding-item {
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-lg);
border-bottom: 1px solid var(--border-color);
}
.proceeding-item:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.proceeding-item h3 {
font-size: var(--font-size-base);
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.proceeding-item h4 {
font-size: var(--font-size-sm);
color: var(--text-muted);
margin-top: var(--spacing-sm);
margin-bottom: var(--spacing-xs);
}
.proceeding-item p {
color: var(--text-secondary);
line-height: 1.6;
white-space: pre-wrap;
}
/* Empty state */
.empty-note {
color: var(--text-muted);
font-style: italic;
}
</style>
{% endblock %}
{% block content %}
<div class="meeting-container">
<a href="{{ url_for('board.meetings_list') }}" 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 posiedzen
</a>
<div class="meeting-header">
<div class="meeting-title-section">
<h1>Posiedzenie Rady Izby nr {{ meeting.meeting_identifier }}</h1>
<div class="meeting-meta">
{% if meeting.meeting_date %}
<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>
{{ meeting.meeting_date.strftime('%d.%m.%Y') }}
</span>
{% endif %}
{% if meeting.start_time %}
<span>
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ meeting.start_time.strftime('%H:%M') }}{% if meeting.end_time %} - {{ meeting.end_time.strftime('%H:%M') }}{% endif %}
</span>
{% endif %}
<span>
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
<path d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
{{ meeting.location or 'Siedziba Izby' }}
</span>
</div>
</div>
<div class="meeting-actions">
<span class="meeting-status {{ meeting.status }}">{{ meeting.status_label }}</span>
{% if can_manage %}
<div class="action-buttons">
<a href="{{ url_for('board.meeting_edit', meeting_id=meeting.id) }}" class="btn-action btn-edit">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
Edytuj
</a>
{% if meeting.status == 'draft' %}
<form action="{{ url_for('board.meeting_publish_agenda', meeting_id=meeting.id) }}" method="POST" style="display: inline;">
<button type="submit" class="btn-action btn-publish" onclick="return confirm('Czy na pewno chcesz opublikowac program posiedzenia?')">
<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>
Opublikuj program
</button>
</form>
{% elif meeting.status in ['agenda_published', 'protocol_draft'] %}
<form action="{{ url_for('board.meeting_publish_protocol', meeting_id=meeting.id) }}" method="POST" style="display: inline;">
<button type="submit" class="btn-action btn-publish" onclick="return confirm('Czy na pewno chcesz opublikowac protokol?')">
<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>
Opublikuj protokol
</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
</div>
<!-- Basic Info -->
<div class="meeting-section">
<h2>
<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>
Dane posiedzenia
</h2>
<div class="info-grid">
<div class="info-item">
<label>Prowadzacy</label>
<span class="value">{{ meeting.chairperson.name if meeting.chairperson else '—' }}</span>
</div>
<div class="info-item">
<label>Protokolant</label>
<span class="value">{{ meeting.secretary.name if meeting.secretary else '—' }}</span>
</div>
<div class="info-item">
<label>Goscie</label>
<span class="value">{{ meeting.guests or 'brak' }}</span>
</div>
</div>
</div>
<!-- Agenda -->
<div class="meeting-section">
<h2>
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
</svg>
Program posiedzenia
</h2>
{% if meeting.agenda_items %}
<ol class="agenda-list">
{% for item in meeting.agenda_items %}
<li>
<span class="agenda-time">
{% if item.time_start %}{{ item.time_start }}{% endif %}
{% if item.time_end %} - {{ item.time_end }}{% endif %}
</span>
<span class="agenda-title">{{ item.title }}</span>
</li>
{% endfor %}
</ol>
{% else %}
<p class="empty-note">Program posiedzenia nie zostal jeszcze uzupelniony.</p>
{% endif %}
</div>
<!-- Attendance -->
<div class="meeting-section">
<h2>
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 00-3-3.87"/>
<path d="M16 3.13a4 4 0 010 7.75"/>
</svg>
Lista obecnosci
</h2>
{% if meeting.attendance %}
<table class="attendance-table">
<thead>
<tr>
<th>Lp.</th>
<th>Imie i nazwisko</th>
<th>Inicjaly</th>
<th>Obecnosc</th>
</tr>
</thead>
<tbody>
{% for member in board_members %}
{% set att = meeting.attendance.get(member.id|string, {}) %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ member.name or member.email.split('@')[0] }}</td>
<td>{{ att.get('initials', '') }}</td>
<td>
{% if att.get('present') %}
<span class="attendance-status present">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M5 13l4 4L19 7"/>
</svg>
obecny
</span>
{% else %}
<span class="attendance-status absent">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M6 18L18 6M6 6l12 12"/>
</svg>
nieobecny
</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if meeting.quorum_count is not none %}
<div class="quorum-info {% if meeting.quorum_confirmed %}confirmed{% else %}not-confirmed{% endif %}">
<strong>Kworum:</strong> {{ meeting.quorum_count }} / {{ board_members|length }} obecnych
{% if meeting.quorum_confirmed %}
— Kworum osiagniete
{% else %}
— Brak kworum
{% endif %}
</div>
{% endif %}
{% else %}
<p class="empty-note">Lista obecnosci nie zostala jeszcze uzupelniona.</p>
{% endif %}
</div>
<!-- Proceedings -->
{% if meeting.proceedings %}
<div class="meeting-section">
<h2>
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
Przebieg posiedzenia i ustalenia
</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-item">
<h3>Ad. {{ proc.agenda_item + 1 }}. {{ agenda_item.title if agenda_item else 'Punkt programu' }}</h3>
{% if proc.discussed %}
<h4>Omowiono:</h4>
<p>{{ proc.discussed }}</p>
{% endif %}
{% if proc.decisions %}
<h4>Ustalono / decyzje:</h4>
<p>{{ proc.decisions }}</p>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,288 @@
{% extends "base.html" %}
{% block title %}Posiedzenia Rady - Strefa RADA{% endblock %}
{% block extra_css %}
<style>
.meetings-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
}
.meetings-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.meetings-header h1 svg {
color: #f59e0b;
}
.btn-new-meeting {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: var(--primary);
color: white;
border-radius: var(--radius-md);
text-decoration: none;
font-weight: 500;
transition: all 0.2s;
}
.btn-new-meeting:hover {
background: var(--primary-dark);
}
.btn-new-meeting svg {
width: 18px;
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);
}
.meeting-card {
background: white;
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s;
}
.meeting-card:hover {
border-color: var(--primary);
box-shadow: var(--shadow);
}
.meeting-info {
flex: 1;
}
.meeting-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-xs);
}
.meeting-meta {
display: flex;
gap: var(--spacing-lg);
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.meeting-meta span {
display: flex;
align-items: center;
gap: 6px;
}
.meeting-meta svg {
width: 16px;
height: 16px;
}
.meeting-status {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: 600;
}
.meeting-status.draft {
background: #fef3c7;
color: #92400e;
}
.meeting-status.agenda_published {
background: #dbeafe;
color: #1e40af;
}
.meeting-status.protocol_draft {
background: #e0e7ff;
color: #3730a3;
}
.meeting-status.protocol_published {
background: #d1fae5;
color: #065f46;
}
.meeting-actions {
display: flex;
gap: var(--spacing-sm);
}
.btn-view-meeting {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: var(--bg-secondary);
color: var(--text-primary);
border-radius: var(--radius-md);
text-decoration: none;
font-size: var(--font-size-sm);
transition: all 0.2s;
}
.btn-view-meeting:hover {
background: var(--bg-tertiary);
}
.btn-view-meeting svg {
width: 16px;
height: 16px;
}
.empty-state {
text-align: center;
padding: var(--spacing-3xl);
color: var(--text-muted);
}
.empty-state svg {
width: 64px;
height: 64px;
margin-bottom: var(--spacing-md);
opacity: 0.5;
}
.empty-state h3 {
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
</style>
{% endblock %}
{% block content %}
<div class="meetings-header">
<h1>
<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
</h1>
{% if can_manage %}
<a href="{{ url_for('board.meeting_create') }}" class="btn-new-meeting">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M12 4v16m8-8H4"/>
</svg>
Nowe posiedzenie
</a>
{% 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 %}
<div class="meeting-card">
<div class="meeting-info">
<div class="meeting-title">
Posiedzenie nr {{ meeting.meeting_identifier }}
</div>
<div class="meeting-meta">
{% if meeting.meeting_date %}
<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>
{{ meeting.meeting_date.strftime('%d.%m.%Y') }}
</span>
{% endif %}
{% if meeting.start_time %}
<span>
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ meeting.start_time.strftime('%H:%M') }}{% if meeting.end_time %} - {{ meeting.end_time.strftime('%H:%M') }}{% endif %}
</span>
{% endif %}
<span>
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
<path d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
{{ meeting.location or 'Siedziba Izby' }}
</span>
</div>
</div>
<div class="meeting-actions">
<span class="meeting-status {{ meeting.status }}">
{{ meeting.status_label }}
</span>
<a href="{{ url_for('board.meeting_view', meeting_id=meeting.id) }}" class="btn-view-meeting">
<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>
Zobacz
</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<svg fill="none" stroke="currentColor" stroke-width="1.5" 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>
<h3>Brak posiedzen</h3>
<p>Nie utworzono jeszcze zadnego posiedzenia Rady Izby.</p>
{% if can_manage %}
<a href="{{ url_for('board.meeting_create') }}" class="btn-new-meeting" 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>
Utworz pierwsze posiedzenie
</a>
{% endif %}
</div>
{% endif %}
{% endblock %}