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
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>
995 lines
33 KiB
HTML
995 lines
33 KiB
HTML
{% 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 %}
|