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
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:
parent
e41187478b
commit
d742b6676c
@ -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)
|
||||
|
||||
107
database.py
107
database.py
@ -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'
|
||||
|
||||
60
database/migrations/049_board_meetings.sql
Normal file
60
database/migrations/049_board_meetings.sql
Normal 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';
|
||||
621
templates/board/meeting_form.html
Normal file
621
templates/board/meeting_form.html
Normal 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 %}
|
||||
545
templates/board/meeting_view.html
Normal file
545
templates/board/meeting_view.html
Normal 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 %}
|
||||
288
templates/board/meetings_list.html
Normal file
288
templates/board/meetings_list.html
Normal 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 %}
|
||||
Loading…
Reference in New Issue
Block a user