feat(board): Redesign proceedings editor with structured fields and visual cues
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

Major UX improvements to meeting protocol editing:
- Numbered, color-coded cards with left accent borders
- Three fields per agenda item: Discussion, Decisions, Tasks
- Decisions and tasks as line-per-item textareas (auto-convert to arrays)
- Fill status indicators (green check vs empty circle)
- Collapsible sections to reduce visual clutter
- Add CSRF token to form (was missing)
- Better visual hierarchy with proceeding headers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-04 12:11:23 +01:00
parent 7c7d0ec12c
commit 58444ddb0d

View File

@ -266,19 +266,153 @@
/* Proceedings */
.proceeding-item {
background: var(--bg-secondary);
background: white;
border: 1px solid var(--border-color);
border-left: 4px solid var(--primary);
border-radius: var(--radius-md);
padding: var(--spacing-md);
margin-bottom: var(--spacing-md);
padding: 0;
margin-bottom: var(--spacing-lg);
overflow: hidden;
}
.proceeding-item h4 {
margin-bottom: var(--spacing-sm);
.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-item textarea {
.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 */
@ -381,6 +515,7 @@
</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>
@ -663,6 +798,25 @@ function updateAgendaJson() {
// 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 = '';
@ -670,32 +824,105 @@ function renderProceedings() {
agendaItems.forEach((item, index) => {
if (!item.title) return;
const proc = proceedings.find(p => p.agenda_item === index) || { discussed: '', decisions: '' };
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 = `
<h4>Ad. ${index + 1}. ${item.title}</h4>
<div class="form-group">
<label>Omówiono:</label>
<textarea onchange="updateProceeding(${index}, 'discussed', this.value)" placeholder="Opis przebiegu dyskusji...">${proc.discussed || ''}</textarea>
<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="form-group">
<label>Ustalono / decyzje:</label>
<textarea onchange="updateProceeding(${index}, 'decisions', this.value)" placeholder="Podjęte ustalenia i decyzje...">${proc.decisions || ''}</textarea>
<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, discussed: '', decisions: '' };
proc = { agenda_item: agendaIndex, title: agendaItems[agendaIndex]?.title || '', discussion: '', decisions: [], tasks: [] };
proceedings.push(proc);
}
proc[field] = value;
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();
}