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
- EventGuest.guest_type: 'member' (member rate) or 'external' (guest rate) - Dropdown of company colleagues when adding member-type guest - Manual entry option for members not on portal - Admin payment panel: "Dodaj osobę" with "Dodaj + opłacone" shortcut - Migration 064: guest_type column Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
350 lines
17 KiB
HTML
350 lines
17 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}Płatności — {{ event.title }} — Norda Biznes{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<style>
|
||
.payments-header { margin-bottom: var(--spacing-xl); }
|
||
.payments-header h1 { font-size: var(--font-size-2xl); color: var(--text-primary); margin-bottom: var(--spacing-xs); }
|
||
|
||
.stats-bar {
|
||
display: flex; gap: var(--spacing-md); flex-wrap: wrap;
|
||
margin-bottom: var(--spacing-xl);
|
||
}
|
||
.stat-card {
|
||
background: var(--surface); border-radius: var(--radius-lg); padding: var(--spacing-md) var(--spacing-lg);
|
||
box-shadow: var(--shadow); min-width: 120px; text-align: center;
|
||
}
|
||
.stat-value { font-size: var(--font-size-2xl); font-weight: 700; color: var(--text-primary); }
|
||
.stat-label { font-size: var(--font-size-sm); color: var(--text-secondary); }
|
||
.stat-card.paid { border-left: 4px solid var(--success); }
|
||
.stat-card.unpaid { border-left: 4px solid var(--error); }
|
||
.stat-card.exempt { border-left: 4px solid var(--text-secondary); }
|
||
.stat-card.collected { border-left: 4px solid var(--primary); }
|
||
|
||
.payments-table {
|
||
width: 100%; background: var(--surface); border-radius: var(--radius-lg);
|
||
box-shadow: var(--shadow); border-collapse: collapse; overflow: hidden;
|
||
}
|
||
.payments-table th, .payments-table td {
|
||
padding: var(--spacing-sm) var(--spacing-md); text-align: left;
|
||
border-bottom: 1px solid var(--border); font-size: var(--font-size-sm);
|
||
}
|
||
.payments-table th {
|
||
background: var(--background); font-weight: 600; color: var(--text-secondary);
|
||
text-transform: uppercase; font-size: var(--font-size-xs);
|
||
}
|
||
.payments-table tr:hover { background: var(--background); }
|
||
|
||
.badge-paid { background: #dcfce7; color: #166534; padding: 2px 8px; border-radius: var(--radius-sm); font-size: var(--font-size-xs); font-weight: 600; }
|
||
.badge-unpaid { background: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: var(--radius-sm); font-size: var(--font-size-xs); font-weight: 600; }
|
||
.badge-exempt { background: #f3f4f6; color: #6b7280; padding: 2px 8px; border-radius: var(--radius-sm); font-size: var(--font-size-xs); font-weight: 600; }
|
||
.badge-type-member { background: #dbeafe; color: #1e40af; padding: 2px 8px; border-radius: var(--radius-sm); font-size: var(--font-size-xs); }
|
||
.badge-type-guest { background: #fef3c7; color: #92400e; padding: 2px 8px; border-radius: var(--radius-sm); font-size: var(--font-size-xs); }
|
||
|
||
.action-btn {
|
||
padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border);
|
||
background: var(--surface); cursor: pointer; font-size: var(--font-size-xs);
|
||
transition: var(--transition);
|
||
}
|
||
.action-btn:hover { background: var(--background); }
|
||
.action-btn.confirm { border-color: var(--success); color: #166534; }
|
||
.action-btn.confirm:hover { background: #dcfce7; }
|
||
.action-btn.exempt-btn { border-color: #9ca3af; color: #6b7280; }
|
||
.action-btn.exempt-btn:hover { background: #f3f4f6; }
|
||
.action-btn.revert { border-color: var(--error); color: #991b1b; }
|
||
.action-btn.revert:hover { background: #fee2e2; }
|
||
|
||
.confirmed-info { font-size: var(--font-size-xs); color: var(--text-secondary); margin-top: 2px; }
|
||
|
||
.amount-cell { cursor: pointer; }
|
||
.amount-cell:hover { text-decoration: underline; }
|
||
|
||
.back-link {
|
||
display: inline-flex; align-items: center; gap: var(--spacing-xs);
|
||
color: var(--text-secondary); text-decoration: none; margin-bottom: var(--spacing-lg);
|
||
}
|
||
.back-link:hover { color: var(--primary); }
|
||
|
||
@media (max-width: 768px) {
|
||
.payments-table { font-size: var(--font-size-xs); }
|
||
.payments-table th, .payments-table td { padding: var(--spacing-xs); }
|
||
.stats-bar { gap: var(--spacing-sm); }
|
||
.stat-card { min-width: 80px; padding: var(--spacing-sm); }
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<a href="{{ url_for('admin.admin_calendar') }}" class="back-link">
|
||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||
</svg>
|
||
Powrót do listy wydarzeń
|
||
</a>
|
||
|
||
<div class="payments-header" style="display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: var(--spacing-md);">
|
||
<div>
|
||
<h1>Płatności — {{ event.title }}</h1>
|
||
<p class="text-muted">{{ event.event_date.strftime('%d.%m.%Y') }}{% if event.time_start %} o {{ event.time_start.strftime('%H:%M') }}{% endif %} · Członek: {{ "%.0f"|format(event.price_member) }} zł · Gość: {{ "%.0f"|format(event.price_guest) }} zł</p>
|
||
</div>
|
||
<button class="btn btn-primary" onclick="toggleAddPerson()" style="white-space: nowrap;">+ Dodaj osobę</button>
|
||
</div>
|
||
|
||
<div id="add-person-form" style="display: none; background: var(--surface); border-radius: var(--radius-lg); padding: var(--spacing-lg); box-shadow: var(--shadow); margin-bottom: var(--spacing-xl);">
|
||
<h3 style="margin: 0 0 var(--spacing-md);">Dodaj osobę na wydarzenie</h3>
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: var(--spacing-sm); align-items: end;">
|
||
<div>
|
||
<label style="font-size: var(--font-size-xs); color: var(--text-secondary);">Imię *</label>
|
||
<input type="text" id="add-first-name" class="form-control" maxlength="100" style="font-size: var(--font-size-sm);">
|
||
</div>
|
||
<div>
|
||
<label style="font-size: var(--font-size-xs); color: var(--text-secondary);">Nazwisko *</label>
|
||
<input type="text" id="add-last-name" class="form-control" maxlength="100" style="font-size: var(--font-size-sm);">
|
||
</div>
|
||
<div>
|
||
<label style="font-size: var(--font-size-xs); color: var(--text-secondary);">Typ</label>
|
||
<select id="add-guest-type" class="form-control" style="font-size: var(--font-size-sm);">
|
||
<option value="member">Członek Izby ({{ "%.0f"|format(event.price_member) }} zł)</option>
|
||
<option value="external">Gość spoza Izby ({{ "%.0f"|format(event.price_guest) }} zł)</option>
|
||
</select>
|
||
</div>
|
||
<div style="display: flex; gap: var(--spacing-xs);">
|
||
<button class="btn btn-primary" onclick="addPerson(false)" style="font-size: var(--font-size-sm);">Dodaj</button>
|
||
<button class="btn btn-outline" onclick="addPerson(true)" style="font-size: var(--font-size-sm);" title="Dodaj i oznacz jako opłacone">Dodaj + opłacone</button>
|
||
</div>
|
||
</div>
|
||
<p id="add-person-error" style="display: none; color: var(--error); font-size: var(--font-size-xs); margin: 8px 0 0;"></p>
|
||
</div>
|
||
|
||
<div class="stats-bar">
|
||
<div class="stat-card">
|
||
<div class="stat-value">{{ stats.total }}</div>
|
||
<div class="stat-label">Łącznie</div>
|
||
</div>
|
||
<div class="stat-card paid">
|
||
<div class="stat-value">{{ stats.paid }}</div>
|
||
<div class="stat-label">Opłacone</div>
|
||
</div>
|
||
<div class="stat-card unpaid">
|
||
<div class="stat-value">{{ stats.unpaid }}</div>
|
||
<div class="stat-label">Nieopłacone</div>
|
||
</div>
|
||
<div class="stat-card exempt">
|
||
<div class="stat-value">{{ stats.exempt }}</div>
|
||
<div class="stat-label">Zwolnieni</div>
|
||
</div>
|
||
<div class="stat-card collected">
|
||
<div class="stat-value">{{ "%.0f"|format(stats.collected) }} zł</div>
|
||
<div class="stat-label">Zebrano</div>
|
||
</div>
|
||
</div>
|
||
|
||
{% if attendees or guests %}
|
||
<table class="payments-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Osoba</th>
|
||
<th>Firma</th>
|
||
<th>Typ</th>
|
||
<th>Kwota</th>
|
||
<th>Status</th>
|
||
<th>Akcje</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for attendee in attendees|sort(attribute='user.name') %}
|
||
<tr data-type="attendee" data-id="{{ attendee.id }}">
|
||
<td>
|
||
<strong>{{ attendee.user.name or attendee.user.email.split('@')[0] }}</strong>
|
||
</td>
|
||
<td>{{ attendee.user.company.name if attendee.user.company else '—' }}</td>
|
||
<td><span class="badge-type-member">Członek</span></td>
|
||
<td class="amount-cell" onclick="editAmount('attendee', {{ attendee.id }}, this)" title="Kliknij, aby zmienić kwotę">
|
||
{{ "%.0f"|format(attendee.payment_amount) if attendee.payment_amount else '—' }} zł
|
||
</td>
|
||
<td>
|
||
<span class="badge-{{ attendee.payment_status }}">
|
||
{% if attendee.payment_status == 'paid' %}Opłacone{% elif attendee.payment_status == 'exempt' %}Zwolniony{% else %}Nieopłacone{% endif %}
|
||
</span>
|
||
{% if attendee.payment_confirmed_at %}
|
||
<div class="confirmed-info">
|
||
{{ attendee.payment_confirmed_at.strftime('%d.%m %H:%M') }}
|
||
{% if attendee.payment_confirmer %} · {{ attendee.payment_confirmer.name or 'admin' }}{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if attendee.payment_status == 'unpaid' %}
|
||
<button class="action-btn confirm" onclick="updatePayment('attendee', {{ attendee.id }}, 'paid')">Potwierdź</button>
|
||
<button class="action-btn exempt-btn" onclick="updatePayment('attendee', {{ attendee.id }}, 'exempt')">Zwolnij</button>
|
||
{% elif attendee.payment_status == 'paid' %}
|
||
<button class="action-btn revert" onclick="updatePayment('attendee', {{ attendee.id }}, 'unpaid')">Cofnij</button>
|
||
{% elif attendee.payment_status == 'exempt' %}
|
||
<button class="action-btn revert" onclick="updatePayment('attendee', {{ attendee.id }}, 'unpaid')">Cofnij</button>
|
||
{% endif %}
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
{% for guest in guests|sort(attribute='created_at') %}
|
||
<tr data-type="guest" data-id="{{ guest.id }}">
|
||
<td>
|
||
<strong>{{ guest.display_name }}</strong>
|
||
<div class="confirmed-info">gość: {{ guest.host.name or guest.host.email.split('@')[0] }}</div>
|
||
</td>
|
||
<td>{{ guest.organization or '—' }}</td>
|
||
<td><span class="badge-type-{{ 'member' if guest.guest_type == 'member' else 'guest' }}">{{ 'Członek (gość)' if guest.guest_type == 'member' else 'Gość spoza Izby' }}</span></td>
|
||
<td class="amount-cell" onclick="editAmount('guest', {{ guest.id }}, this)" title="Kliknij, aby zmienić kwotę">
|
||
{{ "%.0f"|format(guest.payment_amount) if guest.payment_amount else '—' }} zł
|
||
</td>
|
||
<td>
|
||
<span class="badge-{{ guest.payment_status }}">
|
||
{% if guest.payment_status == 'paid' %}Opłacone{% elif guest.payment_status == 'exempt' %}Zwolniony{% else %}Nieopłacone{% endif %}
|
||
</span>
|
||
{% if guest.payment_confirmed_at %}
|
||
<div class="confirmed-info">
|
||
{{ guest.payment_confirmed_at.strftime('%d.%m %H:%M') }}
|
||
{% if guest.payment_confirmer %} · {{ guest.payment_confirmer.name or 'admin' }}{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if guest.payment_status == 'unpaid' %}
|
||
<button class="action-btn confirm" onclick="updatePayment('guest', {{ guest.id }}, 'paid')">Potwierdź</button>
|
||
<button class="action-btn exempt-btn" onclick="updatePayment('guest', {{ guest.id }}, 'exempt')">Zwolnij</button>
|
||
{% elif guest.payment_status == 'paid' %}
|
||
<button class="action-btn revert" onclick="updatePayment('guest', {{ guest.id }}, 'unpaid')">Cofnij</button>
|
||
{% elif guest.payment_status == 'exempt' %}
|
||
<button class="action-btn revert" onclick="updatePayment('guest', {{ guest.id }}, 'unpaid')">Cofnij</button>
|
||
{% endif %}
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
{% else %}
|
||
<div style="text-align: center; padding: var(--spacing-2xl); color: var(--text-secondary);">
|
||
<p>Nikt jeszcze się nie zapisał na to wydarzenie.</p>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<div id="toastContainer" style="position: fixed; top: 80px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 10px;"></div>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
const csrfToken = '{{ csrf_token() }}';
|
||
const eventId = {{ event.id }};
|
||
|
||
function showToast(message, type = 'info', duration = 3000) {
|
||
const container = document.getElementById('toastContainer');
|
||
const icons = { success: '✓', error: '✕', info: 'ℹ' };
|
||
const toast = document.createElement('div');
|
||
toast.style.cssText = 'padding:12px 20px;border-radius:8px;background:var(--surface);border-left:4px solid ' +
|
||
(type === 'success' ? 'var(--success)' : type === 'error' ? 'var(--error)' : 'var(--primary)') +
|
||
';box-shadow:0 4px 12px rgba(0,0,0,0.15);display:flex;align-items:center;gap:10px;animation:toastIn 0.3s ease;';
|
||
toast.innerHTML = '<span style="font-size:1.2em">' + (icons[type]||'ℹ') + '</span><span>' + message + '</span>';
|
||
container.appendChild(toast);
|
||
setTimeout(() => { toast.style.opacity = '0'; toast.style.transition = 'opacity 0.3s'; setTimeout(() => toast.remove(), 300); }, duration);
|
||
}
|
||
|
||
async function updatePayment(type, id, action) {
|
||
try {
|
||
const resp = await fetch('/admin/kalendarz/' + eventId + '/platnosci/update', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
||
body: JSON.stringify({ type: type, id: id, action: action })
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
showToast(data.message, 'success');
|
||
setTimeout(() => location.reload(), 500);
|
||
} else {
|
||
showToast(data.error || 'Błąd', 'error');
|
||
}
|
||
} catch (e) {
|
||
showToast('Błąd połączenia', 'error');
|
||
}
|
||
}
|
||
|
||
function editAmount(type, id, cell) {
|
||
const current = cell.textContent.trim().replace(' zł', '').replace('—', '');
|
||
const input = document.createElement('input');
|
||
input.type = 'number';
|
||
input.value = current || '';
|
||
input.step = '1';
|
||
input.min = '0';
|
||
input.style.cssText = 'width:80px;padding:4px;border:1px solid var(--primary);border-radius:4px;font-size:inherit;';
|
||
|
||
cell.textContent = '';
|
||
cell.appendChild(input);
|
||
input.focus();
|
||
|
||
async function save() {
|
||
const val = input.value;
|
||
try {
|
||
const resp = await fetch('/admin/kalendarz/' + eventId + '/platnosci/kwota', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
||
body: JSON.stringify({ type: type, id: id, amount: val || null })
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
cell.textContent = val ? val + ' zł' : '— zł';
|
||
showToast(data.message, 'success');
|
||
} else {
|
||
cell.textContent = (current || '—') + ' zł';
|
||
showToast(data.error || 'Błąd', 'error');
|
||
}
|
||
} catch (e) {
|
||
cell.textContent = (current || '—') + ' zł';
|
||
showToast('Błąd połączenia', 'error');
|
||
}
|
||
}
|
||
|
||
input.addEventListener('blur', save);
|
||
input.addEventListener('keydown', function(e) {
|
||
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
|
||
if (e.key === 'Escape') { cell.textContent = (current || '—') + ' zł'; }
|
||
});
|
||
}
|
||
|
||
function toggleAddPerson() {
|
||
const form = document.getElementById('add-person-form');
|
||
form.style.display = form.style.display === 'none' ? 'block' : 'none';
|
||
if (form.style.display === 'block') document.getElementById('add-first-name').focus();
|
||
}
|
||
|
||
async function addPerson(markPaid) {
|
||
const firstName = document.getElementById('add-first-name').value.trim();
|
||
const lastName = document.getElementById('add-last-name').value.trim();
|
||
const guestType = document.getElementById('add-guest-type').value;
|
||
const errEl = document.getElementById('add-person-error');
|
||
|
||
if (!firstName && !lastName) {
|
||
errEl.textContent = 'Podaj imię i/lub nazwisko';
|
||
errEl.style.display = 'block';
|
||
return;
|
||
}
|
||
errEl.style.display = 'none';
|
||
|
||
try {
|
||
const resp = await fetch('/admin/kalendarz/' + eventId + '/platnosci/dodaj', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
||
body: JSON.stringify({ first_name: firstName, last_name: lastName, guest_type: guestType, mark_paid: markPaid })
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
showToast(data.message, 'success');
|
||
setTimeout(() => location.reload(), 500);
|
||
} else {
|
||
errEl.textContent = data.error || 'Błąd';
|
||
errEl.style.display = 'block';
|
||
}
|
||
} catch (e) {
|
||
errEl.textContent = 'Błąd połączenia';
|
||
errEl.style.display = 'block';
|
||
}
|
||
}
|
||
{% endblock %}
|