nordabiz/templates/board/meeting_form.html
Maciej Pienczyn 6c248b4773
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
fix(classifieds,admin): blokada duplikatów przez double/triple-click
B2B ogłoszenia mogły zostać stworzone 3x (user 81 Bormax 14.04.2026
w ciągu 2 sekund) — brak dedup window server-side i disable submit
button. Rozszerzam zabezpieczenie także na announcements i board
meeting form.

- classifieds POST /nowe: odrzuć duplikat z ostatnich 60s (ten sam
  author+company+title) → redirect do istniejącego z flash info
- classifieds new.html: disable submitBtn + "Wysyłanie..." po
  walidacji; ponowne kliknięcie blokowane event.preventDefault
- announcements_form.html + board/meeting_form.html: jednolity
  handler disable wszystkich button[type="submit"] po pierwszym
  submit

Forum topic/reply już miały analogiczne zabezpieczenie (bez zmian).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:35:14 +02:00

995 lines
33 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% 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 .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);
}
.attendance-status {
display: flex;
gap: 4px;
}
.status-btn {
padding: 4px 10px;
border: 1px solid var(--border-color);
background: white;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
cursor: pointer;
transition: all 0.2s;
}
.status-btn:hover {
border-color: var(--primary);
}
.status-btn.present.active {
background: #d1fae5;
border-color: #059669;
color: #065f46;
}
.status-btn.absent.active {
background: #fee2e2;
border-color: #dc2626;
color: #991b1b;
}
.status-btn.unknown.active {
background: #f3f4f6;
border-color: #9ca3af;
color: #4b5563;
}
.quorum-info {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border: 1px solid #f59e0b;
border-radius: var(--radius-md);
padding: var(--spacing-md);
margin-bottom: var(--spacing-lg);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.quorum-info svg {
width: 20px;
height: 20px;
color: #b45309;
flex-shrink: 0;
}
.quorum-info .quorum-text {
flex: 1;
}
.quorum-info .quorum-count {
font-weight: 600;
font-size: var(--font-size-lg);
color: #b45309;
}
.quorum-achieved {
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
border-color: #059669;
}
.quorum-achieved svg,
.quorum-achieved .quorum-count {
color: #065f46;
}
/* Proceedings */
.proceeding-item {
background: white;
border: 1px solid var(--border-color);
border-left: 4px solid var(--primary);
border-radius: var(--radius-md);
padding: 0;
margin-bottom: var(--spacing-lg);
overflow: hidden;
}
.proceeding-item:nth-child(even) {
border-left-color: #8b5cf6;
}
.proceeding-item:nth-child(3n) {
border-left-color: #059669;
}
.proceeding-header {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-md) var(--spacing-lg);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
cursor: pointer;
-webkit-user-select: none;
user-select: none;
}
.proceeding-header:hover {
background: #e5e7eb;
}
.proceeding-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: var(--primary);
color: white;
border-radius: 50%;
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
}
.proceeding-item:nth-child(even) .proceeding-number {
background: #8b5cf6;
}
.proceeding-item:nth-child(3n) .proceeding-number {
background: #059669;
}
.proceeding-title {
flex: 1;
font-weight: 600;
font-size: var(--font-size-base);
color: var(--text-primary);
}
.proceeding-status-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.proceeding-status-icon.empty {
color: #d1d5db;
}
.proceeding-status-icon.filled {
color: #059669;
}
.proceeding-toggle {
width: 20px;
height: 20px;
color: var(--text-muted);
transition: transform 0.2s;
flex-shrink: 0;
}
.proceeding-item.collapsed .proceeding-toggle {
transform: rotate(-90deg);
}
.proceeding-body {
padding: var(--spacing-lg);
}
.proceeding-item.collapsed .proceeding-body {
display: none;
}
.proceeding-field {
margin-bottom: var(--spacing-md);
}
.proceeding-field:last-child {
margin-bottom: 0;
}
.proceeding-field label {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-weight: 600;
font-size: var(--font-size-sm);
color: var(--text-primary);
margin-bottom: var(--spacing-xs);
}
.proceeding-field label svg {
width: 14px;
height: 14px;
color: var(--text-muted);
}
.proceeding-field textarea {
width: 100%;
min-height: 80px;
padding: 10px 14px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-size: var(--font-size-base);
font-family: inherit;
line-height: 1.5;
resize: vertical;
transition: border-color 0.2s;
}
.proceeding-field textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.proceeding-field textarea.field-discussion {
min-height: 120px;
}
.proceeding-field .field-hint {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
}
/* 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.index') }}" class="back-link">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M15 19l-7-7 7-7"/>
</svg>
Powrót do listy posiedzeń
</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 %}Wypełnij dane programu i protokołu posiedzenia{% endif %}</p>
</div>
<form method="POST" id="meetingForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- 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">Obecność</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 rozpoczęcia</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 zakończenia</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">Prowadzący 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 staff in staff_users %}
<option value="{{ staff.id }}" {% if form_data.get('secretary_id') == staff.id %}selected{% endif %}>
{{ staff.name or staff.email }}
</option>
{% endfor %}
</select>
<p class="form-hint">Pracownik biura odpowiedzialny za sporządzenie protokołu</p>
</div>
</div>
<div class="form-group">
<label for="guests">Goście (osoby spoza Rady)</label>
<textarea id="guests" name="guests" rows="2"
placeholder="Wpisz imiona i nazwiska gości...">{{ 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 obecności
</h2>
<div class="quorum-info" id="quorumInfo">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="quorum-text">
<strong>Kworum:</strong> Minimum 9 z 16 członków Rady (większość bezwzględna).
<br><small>Kworum oblicza się automatycznie na podstawie listy obecności.</small>
</div>
<div class="quorum-count" id="quorumCount">0/16</div>
</div>
<p class="form-hint">Dla każdego członka wybierz status obecności i wpisz inicjały do protokołu.</p>
<div class="attendance-grid">
{% for member in board_members %}
{% set member_attendance = form_data.get('attendance', {}).get(member.id|string, {}) %}
{% set status = member_attendance.get('status', 'unknown') %}
<div class="attendance-row" data-member-id="{{ member.id }}">
<div class="attendance-status">
<button type="button" class="status-btn present {% if status == 'present' %}active{% endif %}"
onclick="setAttendanceStatus({{ member.id }}, 'present', this)"
title="Obecny"></button>
<button type="button" class="status-btn absent {% if status == 'absent' %}active{% endif %}"
onclick="setAttendanceStatus({{ member.id }}, 'absent', this)"
title="Nieobecny"></button>
<button type="button" class="status-btn unknown {% if status == 'unknown' or not status %}active{% endif %}"
onclick="setAttendanceStatus({{ member.id }}, 'unknown', this)"
title="Nieoznaczony">?</button>
</div>
<input type="hidden" name="attendance_status_{{ member.id }}" value="{{ status or 'unknown' }}" class="attendance-status-input">
<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 każdego punktu programu opisz przebieg dyskusji i podjęte 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 %}Utwórz posiedzenie{% endif %}
</button>
<a href="{{ url_for('board.index') }}" class="btn-cancel">Anuluj</a>
</div>
</div>
</form>
</div>
{% endblock %}
{% block extra_js %}
// Anty double/triple-click — blokada wielokrotnego submitu
(function() {
const form = document.getElementById('meetingForm');
if (!form) return;
form.addEventListener('submit', function(e) {
const btns = form.querySelectorAll('button[type="submit"]');
if (Array.from(btns).some(function(b) { return b.disabled; })) {
e.preventDefault();
return;
}
btns.forEach(function(b) {
b.dataset.originalText = b.innerHTML;
b.disabled = true;
b.textContent = 'Wysyłanie...';
});
});
})();
// 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 różne' },
{ time_start: '', time_end: '', title: 'Ustalenie daty kolejnego posiedzenia' },
{ time_start: '', time_end: '', title: 'Zamknięcie 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="Tytuł 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 }};
// Helper: convert array to text (one item per line)
function arrayToText(arr) {
if (!arr) return '';
if (typeof arr === 'string') return arr;
if (Array.isArray(arr)) return arr.join('\n');
return '';
}
// Helper: convert text to array (split by newlines, filter empty)
function textToArray(text) {
if (!text) return [];
return text.split('\n').map(s => s.trim()).filter(s => s.length > 0);
}
// Helper: get discussion text (supports both field names)
function getDiscussion(proc) {
return proc.discussion || proc.discussed || '';
}
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) || {};
const discussion = getDiscussion(proc);
const decisionsText = arrayToText(proc.decisions);
const tasksText = arrayToText(proc.tasks);
const isFilled = discussion || decisionsText || tasksText;
const div = document.createElement('div');
div.className = 'proceeding-item';
div.dataset.index = index;
div.innerHTML = `
<div class="proceeding-header" onclick="toggleProceeding(this)">
<span class="proceeding-number">${index + 1}</span>
<span class="proceeding-title">${escapeHtml(item.title)}</span>
<svg class="proceeding-status-icon ${isFilled ? 'filled' : 'empty'}" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
${isFilled
? '<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>'
: '<circle cx="12" cy="12" r="9"/>'}
</svg>
<svg class="proceeding-toggle" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 9l-7 7-7-7"/>
</svg>
</div>
<div class="proceeding-body">
<div class="proceeding-field">
<label>
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
Omówiono:
</label>
<textarea class="field-discussion"
oninput="updateProceeding(${index}, 'discussion', this.value); updateStatusIcon(this)"
placeholder="Opis przebiegu dyskusji w tym punkcie...">${escapeHtml(discussion)}</textarea>
</div>
<div class="proceeding-field">
<label>
<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>
Ustalono / decyzje:
</label>
<textarea oninput="updateProceeding(${index}, 'decisions', this.value); updateStatusIcon(this)"
placeholder="Każda decyzja w nowej linii...">${escapeHtml(decisionsText)}</textarea>
<div class="field-hint">Każda decyzja w osobnej linii</div>
</div>
<div class="proceeding-field">
<label>
<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-6 9l2 2 4-4"/></svg>
Zadania:
</label>
<textarea oninput="updateProceeding(${index}, 'tasks', this.value); updateStatusIcon(this)"
placeholder="Każde zadanie w nowej linii (np. AW przygotować prezentację termin: 15.02)...">${escapeHtml(tasksText)}</textarea>
<div class="field-hint">Każde zadanie w osobnej linii (osoba opis termin)</div>
</div>
</div>
`;
list.appendChild(div);
});
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function toggleProceeding(header) {
header.closest('.proceeding-item').classList.toggle('collapsed');
}
function updateStatusIcon(textarea) {
const item = textarea.closest('.proceeding-item');
const textareas = item.querySelectorAll('textarea');
const hasContent = Array.from(textareas).some(ta => ta.value.trim().length > 0);
const icon = item.querySelector('.proceeding-status-icon');
if (hasContent) {
icon.classList.remove('empty');
icon.classList.add('filled');
icon.innerHTML = '<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>';
} else {
icon.classList.remove('filled');
icon.classList.add('empty');
icon.innerHTML = '<circle cx="12" cy="12" r="9"/>';
}
}
function updateProceeding(agendaIndex, field, value) {
let proc = proceedings.find(p => p.agenda_item === agendaIndex);
if (!proc) {
proc = { agenda_item: agendaIndex, title: agendaItems[agendaIndex]?.title || '', discussion: '', decisions: [], tasks: [] };
proceedings.push(proc);
}
if (field === 'decisions' || field === 'tasks') {
proc[field] = textToArray(value);
} else {
proc[field] = value;
// Normalize old field name
if (field === 'discussion') {
delete proc.discussed;
}
}
proc.title = agendaItems[agendaIndex]?.title || '';
updateProceedingsJson();
}
function updateProceedingsJson() {
document.getElementById('proceedingsJson').value = JSON.stringify(proceedings);
}
// Initialize
renderAgendaItems();
// Attendance status management
function setAttendanceStatus(memberId, status, button) {
// Update hidden input
const row = button.closest('.attendance-row');
row.querySelector('.attendance-status-input').value = status;
// Update button states
row.querySelectorAll('.status-btn').forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
// Recalculate quorum
updateQuorumCount();
}
function updateQuorumCount() {
const presentCount = document.querySelectorAll('.status-btn.present.active').length;
const totalMembers = 16;
const quorumRequired = 9;
const countEl = document.getElementById('quorumCount');
const infoEl = document.getElementById('quorumInfo');
countEl.textContent = presentCount + '/' + totalMembers;
if (presentCount >= quorumRequired) {
infoEl.classList.add('quorum-achieved');
countEl.innerHTML = presentCount + '/' + totalMembers + ' <small></small>';
} else {
infoEl.classList.remove('quorum-achieved');
}
}
// Initialize quorum count on page load
updateQuorumCount();
// Update JSON before submit
document.getElementById('meetingForm').addEventListener('submit', function() {
updateAgendaJson();
updateProceedingsJson();
});
{% endblock %}