nordabiz/templates/calendar/admin_payments.html
Maciej Pienczyn d06507a7c3
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
feat: company colleague picker + admin add person to paid events
- 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>
2026-04-08 11:48:46 +02:00

350 lines
17 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 %}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 %}