nordabiz/templates/admin/fees.html
Maciej Pienczyn 345fc175eb
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
improve(fees): show rate badge for higher membership fees (300 zł)
Companies with monthly rate above standard 200 PLN get a small blue
badge next to their name showing the actual rate (e.g. "300 zł").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 21:15:30 +01:00

648 lines
24 KiB
HTML
Executable File
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 %}Skladki Czlonkowskie - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.admin-header {
margin-bottom: var(--spacing-xl);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--spacing-md);
}
.admin-header h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-2xl);
}
.stat-card {
background: var(--surface);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
box-shadow: var(--shadow);
text-align: center;
}
.stat-card.success { border-left: 3px solid var(--success); }
.stat-card.warning { border-left: 3px solid var(--warning); }
.stat-card.primary { border-left: 3px solid var(--primary); }
.stat-value {
font-size: var(--font-size-xl);
font-weight: 700;
color: var(--primary);
}
.stat-label {
font-size: var(--font-size-xs);
}
.stat-label {
color: var(--text-secondary);
font-size: var(--font-size-sm);
margin-top: var(--spacing-xs);
}
.filters-bar {
background: var(--surface);
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-xl);
display: flex;
gap: var(--spacing-md);
flex-wrap: wrap;
align-items: center;
}
.filters-bar select, .filters-bar input {
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
}
.filters-bar .btn {
padding: var(--spacing-sm) var(--spacing-lg);
}
.section {
background: var(--surface);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-xl);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
flex-wrap: wrap;
gap: var(--spacing-md);
}
.section h2 {
font-size: var(--font-size-xl);
color: var(--text-primary);
}
.fees-table {
width: 100%;
border-collapse: collapse;
}
.fees-table th,
.fees-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.fees-table th {
font-weight: 600;
color: var(--text-secondary);
font-size: var(--font-size-sm);
text-transform: uppercase;
letter-spacing: 0.5px;
background: var(--background);
}
.fees-table tr:hover {
background: var(--background);
}
.status-badge {
display: inline-block;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: 600;
text-transform: uppercase;
}
.status-paid { background: var(--success-bg); color: var(--success); }
.status-pending { background: var(--warning-bg); color: var(--warning); }
.status-overdue { background: var(--error-bg); color: var(--error); }
.status-partial { background: var(--info-bg); color: var(--info); }
.status-brak { background: var(--surface-secondary); color: var(--text-secondary); }
.btn-small {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: var(--font-size-xs);
}
.actions-cell {
white-space: nowrap;
}
/* Modal */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: var(--surface);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
max-width: 500px;
width: 90%;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
}
.modal-header h3 {
font-size: var(--font-size-xl);
}
.modal-close {
background: none;
border: none;
font-size: var(--font-size-xl);
cursor: pointer;
color: var(--text-secondary);
}
.form-group {
margin-bottom: var(--spacing-md);
}
.form-group label {
display: block;
margin-bottom: var(--spacing-xs);
font-weight: 500;
}
.form-group input, .form-group select, .form-group textarea {
width: 100%;
padding: var(--spacing-sm);
border: 1px solid var(--border);
border-radius: var(--radius-md);
}
.btn-group {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-lg);
}
/* Month grid for year view */
.month-cell {
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 600;
cursor: pointer;
}
.month-cell.paid { background: var(--success); color: white; }
.month-cell.pending { background: var(--warning); color: white; }
.month-cell.overdue { background: var(--error); color: white; }
.month-cell.empty { background: var(--surface-secondary); color: var(--text-secondary); }
</style>
{% endblock %}
{% block content %}
<div class="container">
<div class="admin-header">
<h1>Skladki Czlonkowskie</h1>
<div class="header-actions">
<a href="{{ url_for('admin.admin_fees_export', year=year, month=month) }}" class="btn btn-secondary">
Eksportuj CSV
</a>
</div>
</div>
<!-- Stats -->
<div class="stats-grid">
<div class="stat-card primary">
<div class="stat-value">{{ total_companies }}</div>
<div class="stat-label">Firm czlonkowskich</div>
</div>
<div class="stat-card success">
<div class="stat-value">{{ paid_count }}</div>
<div class="stat-label">Oplaconych</div>
</div>
<div class="stat-card warning">
<div class="stat-value">{{ pending_count }}</div>
<div class="stat-label">Oczekujacych</div>
</div>
<div class="stat-card primary">
<div class="stat-value">{{ total_paid|int }} zł</div>
<div class="stat-label">Zebrano</div>
</div>
<div class="stat-card warning">
<div class="stat-value">{{ (total_due - total_paid)|int }} zł</div>
<div class="stat-label">Do zebrania</div>
</div>
</div>
<!-- Legenda -->
<div style="display: flex; gap: var(--spacing-lg); flex-wrap: wrap; margin-bottom: var(--spacing-md); font-size: var(--font-size-sm); color: var(--text-secondary); align-items: center;">
<span style="display: flex; align-items: center; gap: 4px;"><span class="month-cell paid" style="width: 24px; height: 24px; display: inline-flex; align-items: center; justify-content: center; font-size: 11px;">1</span> Opłacone</span>
<span style="display: flex; align-items: center; gap: 4px;"><span class="month-cell pending" style="width: 24px; height: 24px; display: inline-flex; align-items: center; justify-content: center; font-size: 11px;">1</span> Oczekujące</span>
<span style="display: flex; align-items: center; gap: 4px;"><span class="month-cell overdue" style="width: 24px; height: 24px; display: inline-flex; align-items: center; justify-content: center; font-size: 11px;">1</span> Zaległe</span>
<span style="display: flex; align-items: center; gap: 4px;"><span class="month-cell empty" style="width: 24px; height: 24px; display: inline-flex; align-items: center; justify-content: center; font-size: 11px;">-</span> Brak danych</span>
</div>
<!-- Filters -->
<div class="filters-bar">
<form id="feesFilterForm" method="GET" action="{{ url_for('admin.admin_fees') }}" style="display: flex; gap: var(--spacing-md); flex-wrap: wrap; align-items: center;">
<select name="year" onchange="this.form.submit()">
{% for y in years %}
<option value="{{ y }}" {% if y == year %}selected{% endif %}>{{ y }}</option>
{% endfor %}
</select>
<select name="month" onchange="this.form.submit()">
<option value="">-- Cały rok --</option>
{% for m, name in months %}
<option value="{{ m }}" {% if m == month %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
<select name="status" onchange="this.form.submit()">
<option value="">-- Wszystkie firmy --</option>
<option value="paid" {% if status_filter == 'paid' %}selected{% endif %}>{% if month %}Opłacone{% else %}Uregulowane cały rok{% endif %}</option>
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>Oczekujące</option>
{% if month %}
<option value="overdue" {% if status_filter == 'overdue' %}selected{% endif %}>Zaległe</option>
{% else %}
<option value="partial" {% if status_filter == 'partial' %}selected{% endif %}>Częściowo opłacone</option>
{% endif %}
</select>
</form>
{% if month %}
<button class="btn btn-success" onclick="generateFees()">
Generuj skladki na {{ dict(months).get(month, month) }}
</button>
{% endif %}
</div>
<!-- Companies Table -->
<div class="section">
<div class="section-header">
<h2>Lista firm {% if month %}({{ dict(months).get(month, month) }} {{ year }}){% else %}({{ year }}){% endif %}</h2>
{% if month %}
<button class="btn btn-success btn-small" onclick="bulkMarkPaid()">
Oznacz zaznaczone jako oplacone
</button>
{% endif %}
</div>
<table class="fees-table">
<thead>
<tr>
{% if month %}<th><input type="checkbox" id="selectAll" onclick="toggleSelectAll()"></th>{% endif %}
<th>Firma</th>
{% if month %}
<th>Status</th>
<th>Kwota</th>
<th>Zaplacono</th>
<th>Data platnosci</th>
<th>Akcje</th>
{% else %}
<th>Sty</th><th>Lut</th><th>Mar</th><th>Kwi</th><th>Maj</th><th>Cze</th>
<th>Lip</th><th>Sie</th><th>Wrz</th><th>Paz</th><th>Lis</th><th>Gru</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for cf in companies_fees %}
<tr>
{% if month %}
<td>
{% if cf.fee %}
<input type="checkbox" class="fee-checkbox" value="{{ cf.fee.id }}" {% if cf.status == 'paid' %}disabled{% endif %}>
{% endif %}
</td>
<td>
<a href="{{ url_for('company_detail_by_slug', slug=cf.company.slug) }}" target="_blank">
{{ cf.company.name }}
</a>
</td>
<td>
<span class="status-badge status-{{ cf.status }}">
{% if cf.status == 'paid' %}Oplacone
{% elif cf.status == 'pending' %}Oczekuje
{% elif cf.status == 'overdue' %}Zalegle
{% elif cf.status == 'partial' %}Czesciowe
{% else %}Brak
{% endif %}
</span>
</td>
<td>{% if cf.fee %}{{ cf.fee.amount }} zl{% else %}-{% endif %}</td>
<td>{% if cf.fee and cf.fee.amount_paid %}{{ cf.fee.amount_paid }} zl{% else %}-{% endif %}</td>
<td>{% if cf.fee and cf.fee.payment_date %}{{ cf.fee.payment_date }}{% else %}-{% endif %}</td>
<td class="actions-cell">
{% if cf.fee and cf.status != 'paid' %}
<button class="btn btn-success btn-small" onclick="openPaymentModal({{ cf.fee.id }}, '{{ cf.company.name }}', {{ cf.fee.amount }})">
Oplac
</button>
{% elif not cf.fee %}
<span class="text-secondary">Brak rekordu</span>
{% endif %}
</td>
{% else %}
<td>
<a href="{{ url_for('company_detail_by_slug', slug=cf.company.slug) }}" target="_blank">
{{ cf.company.name }}
</a>
{% if cf.monthly_rate and cf.monthly_rate > 200 %}
<span style="display:inline-block;background:#dbeafe;color:#1e40af;font-size:10px;padding:1px 5px;border-radius:3px;font-weight:600;vertical-align:middle;margin-left:4px;">{{ cf.monthly_rate }} zł</span>
{% endif %}
</td>
{% for m in range(1, 13) %}
<td>
{% set fee = cf.months.get(m) %}
{% if fee %}
<span class="month-cell {{ fee.status }}" title="{{ fee.status }}: {{ fee.amount }} zl">
{{ m }}
</span>
{% else %}
<span class="month-cell empty" title="Brak rekordu">-</span>
{% endif %}
</td>
{% endfor %}
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Payment Modal -->
<div class="modal" id="paymentModal">
<div class="modal-content">
<div class="modal-header">
<h3>Rejestracja platnosci</h3>
<button class="modal-close" onclick="closePaymentModal()">&times;</button>
</div>
<form id="paymentForm">
<input type="hidden" name="fee_id" id="modalFeeId">
<div class="form-group">
<label>Firma</label>
<input type="text" id="modalCompanyName" disabled>
</div>
<div class="form-group">
<label>Kwota do zaplaty</label>
<input type="number" name="amount_paid" id="modalAmount" step="0.01" required>
</div>
<div class="form-group">
<label>Data platnosci</label>
<input type="date" name="payment_date" id="modalDate" value="{{ now.strftime('%Y-%m-%d') if now else '' }}">
</div>
<div class="form-group">
<label>Metoda platnosci</label>
<select name="payment_method">
<option value="transfer">Przelew bankowy</option>
<option value="cash">Gotowka</option>
<option value="card">Karta</option>
<option value="other">Inna</option>
</select>
</div>
<div class="form-group">
<label>Numer referencyjny</label>
<input type="text" name="payment_reference" placeholder="np. numer przelewu">
</div>
<div class="form-group">
<label>Notatki</label>
<textarea name="notes" rows="2"></textarea>
</div>
<div class="btn-group">
<button type="button" class="btn btn-secondary" onclick="closePaymentModal()">Anuluj</button>
<button type="submit" class="btn btn-success">Zarejestruj platnosc</button>
</div>
</form>
</div>
</div>
<!-- Universal Confirm Modal -->
<div class="modal-overlay" id="confirmModal">
<div class="modal" style="max-width: 420px;">
<div style="text-align: center; margin-bottom: var(--spacing-lg);">
<div class="modal-icon" id="confirmModalIcon"></div>
<h3 id="confirmModalTitle" style="margin-bottom: var(--spacing-sm);">Potwierdzenie</h3>
<p class="modal-description" id="confirmModalMessage"></p>
</div>
<div class="modal-actions" style="justify-content: center;">
<button type="button" class="btn btn-secondary" id="confirmModalCancel">Anuluj</button>
<button type="button" class="btn btn-primary" id="confirmModalOk">OK</button>
</div>
</div>
</div>
<div id="toastContainer" style="position: fixed; top: 80px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 10px;"></div>
<style>
.modal-overlay#confirmModal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1050; align-items: center; justify-content: center; }
.modal-overlay#confirmModal.active { display: flex; }
#confirmModal .modal { background: var(--surface); border-radius: var(--radius-lg); padding: var(--spacing-xl); }
#confirmModal .modal-icon { font-size: 3em; margin-bottom: var(--spacing-md); }
#confirmModal .modal-actions { display: flex; gap: var(--spacing-sm); margin-top: var(--spacing-lg); }
.toast { padding: 12px 20px; border-radius: var(--radius); background: var(--surface); border-left: 4px solid 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.success { border-left-color: var(--success); }
.toast.error { border-left-color: var(--error); }
@keyframes toastIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes toastOut { from { opacity: 1; } to { opacity: 0; } }
</style>
{% endblock %}
{% block extra_js %}
// Modal system
let confirmResolve = null;
function showConfirm(message, options = {}) {
return new Promise(resolve => {
confirmResolve = resolve;
document.getElementById('confirmModalIcon').textContent = options.icon || '❓';
document.getElementById('confirmModalTitle').textContent = options.title || 'Potwierdzenie';
document.getElementById('confirmModalMessage').innerHTML = message;
document.getElementById('confirmModalOk').textContent = options.okText || 'OK';
document.getElementById('confirmModalOk').className = 'btn ' + (options.okClass || 'btn-primary');
document.getElementById('confirmModal').classList.add('active');
});
}
function closeConfirm(result) {
document.getElementById('confirmModal').classList.remove('active');
if (confirmResolve) { confirmResolve(result); confirmResolve = null; }
}
document.getElementById('confirmModalOk').addEventListener('click', () => closeConfirm(true));
document.getElementById('confirmModalCancel').addEventListener('click', () => closeConfirm(false));
document.getElementById('confirmModal').addEventListener('click', e => { if (e.target.id === 'confirmModal') closeConfirm(false); });
function showToast(message, type = 'info', duration = 4000) {
const container = document.getElementById('toastContainer');
const icons = { success: '✓', error: '✕', warning: '⚠', info: '' };
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `<span style="font-size:1.2em">${icons[type]||''}</span><span>${message}</span>`;
container.appendChild(toast);
setTimeout(() => { toast.style.animation = 'toastOut 0.3s ease forwards'; setTimeout(() => toast.remove(), 300); }, duration);
}
async function generateFees() {
const confirmed = await showConfirm('Czy na pewno chcesz wygenerować rekordy składek dla wszystkich firm na wybrany miesiąc?', {
icon: '📋',
title: 'Generowanie składek',
okText: 'Generuj',
okClass: 'btn-success'
});
if (!confirmed) return;
const formData = new FormData();
formData.append('year', {{ year }});
formData.append('month', {{ month or 'null' }});
try {
const response = await fetch('{{ url_for("admin.admin_fees_generate") }}', {
method: 'POST',
body: formData,
headers: {
'X-CSRFToken': '{{ csrf_token() }}'
}
});
const data = await response.json();
if (data.success) {
showToast(data.message, 'success');
setTimeout(() => location.reload(), 1500);
} else {
showToast('Błąd: ' + data.error, 'error');
}
} catch (err) {
showToast('Błąd: ' + err, 'error');
}
}
function openPaymentModal(feeId, companyName, amount) {
document.getElementById('modalFeeId').value = feeId;
document.getElementById('modalCompanyName').value = companyName;
document.getElementById('modalAmount').value = amount;
document.getElementById('modalDate').value = new Date().toISOString().split('T')[0];
document.getElementById('paymentModal').classList.add('active');
}
function closePaymentModal() {
document.getElementById('paymentModal').classList.remove('active');
}
document.getElementById('paymentForm').addEventListener('submit', async function(e) {
e.preventDefault();
const feeId = document.getElementById('modalFeeId').value;
const formData = new FormData(this);
try {
const response = await fetch('/admin/fees/' + feeId + '/mark-paid', {
method: 'POST',
body: formData,
headers: {
'X-CSRFToken': '{{ csrf_token() }}'
}
});
const data = await response.json();
if (data.success) {
closePaymentModal();
showToast(data.message, 'success');
setTimeout(() => location.reload(), 1500);
} else {
showToast('Błąd: ' + data.error, 'error');
}
} catch (err) {
showToast('Błąd: ' + err, 'error');
}
});
function toggleSelectAll() {
const selectAll = document.getElementById('selectAll');
const checkboxes = document.querySelectorAll('.fee-checkbox:not(:disabled)');
checkboxes.forEach(cb => cb.checked = selectAll.checked);
}
async function bulkMarkPaid() {
const checkboxes = document.querySelectorAll('.fee-checkbox:checked');
if (checkboxes.length === 0) {
showToast('Zaznacz przynajmniej jedną składkę', 'warning');
return;
}
const confirmed = await showConfirm(`Czy na pewno chcesz oznaczyć <strong>${checkboxes.length}</strong> składek jako opłacone?`, {
icon: '💰',
title: 'Oznaczanie płatności',
okText: 'Oznacz',
okClass: 'btn-success'
});
if (!confirmed) return;
const formData = new FormData();
checkboxes.forEach(cb => formData.append('fee_ids[]', cb.value));
try {
const response = await fetch('{{ url_for("admin.admin_fees_bulk_mark_paid") }}', {
method: 'POST',
body: formData,
headers: {
'X-CSRFToken': '{{ csrf_token() }}'
}
});
const data = await response.json();
if (data.success) {
showToast(data.message, 'success');
setTimeout(() => location.reload(), 1500);
} else {
showToast('Błąd: ' + data.error, 'error');
}
} catch (err) {
showToast('Błąd: ' + err, 'error');
}
}
// Close modal on outside click
document.getElementById('paymentModal').addEventListener('click', function(e) {
if (e.target === this) {
closePaymentModal();
}
});
{% endblock %}