nordabiz/templates/admin/fees.html
Maciej Pienczyn 6d589407be Sync local repo with production state
- Add MembershipFee and MembershipFeeConfig models
- Add /health endpoint for monitoring
- Add Microsoft Fluent Design CSS
- Update templates with new CSS structure
- Add Announcement model
- Update .gitignore to exclude analysis files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:23:28 +01:00

560 lines
18 KiB
HTML

{% extends "base.html" %}
{% block title %}Skladki Czlonkowskie - Norda Biznes Hub{% 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-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
text-align: center;
}
.stat-card.success { border-left: 4px solid var(--success); }
.stat-card.warning { border-left: 4px solid var(--warning); }
.stat-card.primary { border-left: 4px solid var(--primary); }
.stat-value {
font-size: var(--font-size-3xl);
font-weight: 700;
color: var(--primary);
}
.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_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">{{ "%.2f"|format(total_paid) }} zl</div>
<div class="stat-label">Zebrano</div>
</div>
<div class="stat-card warning">
<div class="stat-value">{{ "%.2f"|format(total_due - total_paid) }} zl</div>
<div class="stat-label">Do zebrania</div>
</div>
</div>
<!-- Filters -->
<div class="filters-bar">
<form method="GET" action="{{ url_for('admin_fees') }}" style="display: flex; gap: var(--spacing-md); flex-wrap: wrap; align-items: center;">
<select name="year">
{% for y in years %}
<option value="{{ y }}" {% if y == year %}selected{% endif %}>{{ y }}</option>
{% endfor %}
</select>
<select name="month">
<option value="">-- Caly rok --</option>
{% for m, name in months %}
<option value="{{ m }}" {% if m == month %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
{% if month %}
<select name="status">
<option value="">-- Wszystkie --</option>
<option value="paid" {% if status_filter == 'paid' %}selected{% endif %}>Oplacone</option>
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>Oczekujace</option>
<option value="overdue" {% if status_filter == 'overdue' %}selected{% endif %}>Zaległe</option>
</select>
{% endif %}
<button type="submit" class="btn btn-primary">Filtruj</button>
</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>
</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>
{% endblock %}
{% block extra_js %}
function generateFees() {
if (!confirm('Czy na pewno chcesz wygenerowac rekordy skladek dla wszystkich firm na wybrany miesiac?')) {
return;
}
const formData = new FormData();
formData.append('year', {{ year }});
formData.append('month', {{ month or 'null' }});
fetch('{{ url_for("admin_fees_generate") }}', {
method: 'POST',
body: formData,
headers: {
'X-CSRFToken': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message);
location.reload();
} else {
alert('Blad: ' + data.error);
}
})
.catch(err => alert('Blad: ' + err));
}
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', function(e) {
e.preventDefault();
const feeId = document.getElementById('modalFeeId').value;
const formData = new FormData(this);
fetch('/admin/fees/' + feeId + '/mark-paid', {
method: 'POST',
body: formData,
headers: {
'X-CSRFToken': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message);
location.reload();
} else {
alert('Blad: ' + data.error);
}
})
.catch(err => alert('Blad: ' + err));
});
function toggleSelectAll() {
const selectAll = document.getElementById('selectAll');
const checkboxes = document.querySelectorAll('.fee-checkbox:not(:disabled)');
checkboxes.forEach(cb => cb.checked = selectAll.checked);
}
function bulkMarkPaid() {
const checkboxes = document.querySelectorAll('.fee-checkbox:checked');
if (checkboxes.length === 0) {
alert('Zaznacz przynajmniej jedna skladke');
return;
}
if (!confirm('Czy na pewno chcesz oznaczyc ' + checkboxes.length + ' skladek jako oplacone?')) {
return;
}
const formData = new FormData();
checkboxes.forEach(cb => formData.append('fee_ids[]', cb.value));
fetch('{{ url_for("admin_fees_bulk_mark_paid") }}', {
method: 'POST',
body: formData,
headers: {
'X-CSRFToken': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message);
location.reload();
} else {
alert('Blad: ' + data.error);
}
})
.catch(err => alert('Blad: ' + err));
}
// Close modal on outside click
document.getElementById('paymentModal').addEventListener('click', function(e) {
if (e.target === this) {
closePaymentModal();
}
});
{% endblock %}