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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
836 lines
36 KiB
HTML
Executable File
836 lines
36 KiB
HTML
Executable File
{% extends "base.html" %}
|
||
|
||
{% block title %}Składki Członkowskie - 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.partial { background: #60a5fa; 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); }
|
||
|
||
.partial-badge {
|
||
position: absolute;
|
||
top: -6px;
|
||
right: -6px;
|
||
background: #ef4444;
|
||
color: white;
|
||
font-size: 8px;
|
||
font-weight: 700;
|
||
padding: 1px 3px;
|
||
border-radius: 6px;
|
||
line-height: 1;
|
||
transform: rotate(12deg);
|
||
min-width: 14px;
|
||
text-align: center;
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container">
|
||
<div class="admin-header">
|
||
<h1>Składki Członkowskie</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 członkowskich</div>
|
||
</div>
|
||
<div class="stat-card success">
|
||
<div class="stat-value">{{ paid_count }}</div>
|
||
<div class="stat-label">Opłaconych{% if month %} w tym miesiącu{% else %} składek (łącznie){% endif %}</div>
|
||
</div>
|
||
<div class="stat-card warning">
|
||
<div class="stat-value">{{ pending_count }}</div>
|
||
<div class="stat-label">Oczekujących{% if month %} w tym miesiącu{% else %} składek (łącznie){% endif %}</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 partial" style="width: 24px; height: 24px; display: inline-flex; align-items: center; justify-content: center; font-size: 11px;">1</span> Niepełna wpłata</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>
|
||
{% if month %}
|
||
<option value="paid" {% if status_filter == 'paid' %}selected{% endif %}>Opłacone w tym miesiącu</option>
|
||
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>Nieopłacone w tym miesiącu</option>
|
||
<option value="overdue" {% if status_filter == 'overdue' %}selected{% endif %}>Zaległe (po terminie)</option>
|
||
{% else %}
|
||
<option value="paid" {% if status_filter == 'paid' %}selected{% endif %}>Opłacone za cały rok</option>
|
||
<option value="partial" {% if status_filter == 'partial' %}selected{% endif %}>Częściowo opłacone</option>
|
||
<option value="none" {% if status_filter == 'none' %}selected{% endif %}>Brak wpłat</option>
|
||
{% endif %}
|
||
</select>
|
||
</form>
|
||
|
||
{% if month %}
|
||
<button class="btn btn-success" onclick="generateFees()">
|
||
Generuj składki 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 %} <span style="display:inline-flex;align-items:center;justify-content:center;background:var(--error);color:white;font-size:var(--font-size-sm);font-weight:700;min-width:28px;height:28px;border-radius:var(--radius-full);padding:0 8px;vertical-align:middle;margin-left:8px;">{{ companies_fees|length }}</span></h2>
|
||
{% if month %}
|
||
<button class="btn btn-success btn-small" onclick="bulkMarkPaid()">
|
||
Oznacz zaznaczone jako opłacone
|
||
</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>Zapłacono</th>
|
||
<th>Data płatności</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>Paź</th><th>Lis</th><th>Gru</th>
|
||
<th>Przypomnienie</th>
|
||
<th></th>
|
||
{% endif %}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% set ns = namespace(separator_shown=false) %}
|
||
{% for cf in companies_fees %}
|
||
{% if not month and not cf.has_data and not ns.separator_shown %}
|
||
{% set ns.separator_shown = true %}
|
||
<tr><td colspan="15" style="background: var(--border); padding: var(--spacing-xs); text-align: center; font-size: var(--font-size-sm); color: var(--text-secondary); font-weight: 600;">Firmy bez danych o składkach</td></tr>
|
||
{% endif %}
|
||
<tr{% if not month and not cf.has_data %} style="opacity: 0.5;"{% endif %}>
|
||
{% 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' %}Opłacone
|
||
{% elif cf.status == 'pending' %}Oczekuje
|
||
{% elif cf.status == 'overdue' %}Zaległe
|
||
{% elif cf.status == 'partial' %}Częściowe
|
||
{% else %}Brak
|
||
{% endif %}
|
||
</span>
|
||
</td>
|
||
<td>{% if cf.fee %}{{ cf.fee.amount }} zł{% else %}-{% endif %}</td>
|
||
<td>{% if cf.fee and cf.fee.amount_paid %}{{ cf.fee.amount_paid }} zł{% 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 }})">
|
||
Opłać
|
||
</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" {% if not cf.has_data %}style="color: var(--text-secondary);"{% endif %}>
|
||
{{ 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 }}: wpłacono {{ fee.amount_paid|int }} z {{ fee.amount|int }} zł" style="position:relative;">
|
||
{{ m }}{% if fee.status == 'partial' %}<span class="partial-badge">{{ fee.amount_paid|int }}</span>{% endif %}
|
||
</span>
|
||
{% else %}
|
||
<span class="month-cell empty" title="Brak rekordu">-</span>
|
||
{% endif %}
|
||
</td>
|
||
{% endfor %}
|
||
<td style="font-size:11px;white-space:nowrap;">
|
||
{% if cf.reminder %}
|
||
{% if cf.reminder.is_read %}
|
||
<span style="color:var(--success);" title="Odczytano {{ cf.reminder.read_at.strftime('%d.%m %H:%M') if cf.reminder.read_at else '' }}">✓ Odczytano {{ cf.reminder.sent_at.strftime('%d.%m %H:%M') }}</span>
|
||
{% else %}
|
||
<span style="color:var(--text-secondary);" title="Wysłano {{ cf.reminder.sent_at.strftime('%d.%m.%Y %H:%M') }}">✉ Wysłano {{ cf.reminder.sent_at.strftime('%d.%m %H:%M') }}</span>
|
||
{% endif %}
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if cf.has_data and cf.months.values()|selectattr('status', 'in', ['pending', 'partial', 'overdue'])|list %}
|
||
<button class="btn btn-small" style="font-size:11px;padding:2px 8px;color:white;border:none;background:{{ '#64748b' if cf.reminder else 'var(--warning)' }};" onclick="openReminderModal({{ cf.company.id }}, '{{ cf.company.name|e }}', {{ year }})">{{ 'Ponów' if cf.reminder else 'Przypomnij' }}</button>
|
||
{% endif %}
|
||
</td>
|
||
{% endif %}
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Payment Modal -->
|
||
<div class="modal" id="paymentModal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h3>Rejestracja płatności</h3>
|
||
<button class="modal-close" onclick="closePaymentModal()">×</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 zapłaty</label>
|
||
<input type="number" name="amount_paid" id="modalAmount" step="0.01" required>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Data płatności</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 płatności</label>
|
||
<select name="payment_method">
|
||
<option value="transfer">Przelew bankowy</option>
|
||
<option value="cash">Gotówka</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 płatność</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>
|
||
|
||
<!-- Reminder Modal -->
|
||
<div class="modal" id="reminderModal" style="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;">
|
||
<div style="background:var(--surface);border-radius:var(--radius-lg);padding:var(--spacing-xl);max-width:700px;width:90%;max-height:80vh;overflow-y:auto;">
|
||
<h3 style="margin-bottom:var(--spacing-md);">Przypomnienie o składce — <span id="reminderCompanyName"></span></h3>
|
||
<div style="margin-bottom:var(--spacing-md);padding:var(--spacing-md);background:var(--background);border-radius:var(--radius);font-size:var(--font-size-sm);">
|
||
<strong>Kwota:</strong> <span id="reminderAmount"></span> zł ·
|
||
<strong>Okres:</strong> <span id="reminderPeriod"></span>
|
||
</div>
|
||
<div style="margin-bottom:var(--spacing-sm);font-weight:600;">Podgląd wiadomości:</div>
|
||
<div id="reminderMessagePreview" style="border:1px solid var(--border);border-radius:var(--radius);padding:var(--spacing-md);margin-bottom:var(--spacing-md);background:white;font-size:var(--font-size-sm);line-height:1.6;max-height:300px;overflow-y:auto;"></div>
|
||
<div style="margin-bottom:var(--spacing-md);display:flex;flex-direction:column;gap:var(--spacing-sm);">
|
||
<div>
|
||
<label style="font-size:var(--font-size-sm);font-weight:600;display:block;margin-bottom:4px;">Wiadomość na portalu do:</label>
|
||
<select id="reminderPortalRecipient" style="width:100%;padding:6px;border:1px solid var(--border);border-radius:var(--radius);font-size:var(--font-size-sm);">
|
||
<option value="">— nie wysyłaj na portal —</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label style="font-size:var(--font-size-sm);font-weight:600;display:block;margin-bottom:4px;">Wyślij email do:</label>
|
||
<div id="reminderEmailCheckboxes" style="display:flex;flex-direction:column;gap:4px;margin-bottom:6px;"></div>
|
||
<input type="text" id="reminderEmailCustom" placeholder="+ dodatkowy adres email (wpisz ręcznie)" style="width:100%;padding:6px;border:1px solid var(--border);border-radius:var(--radius);font-size:var(--font-size-sm);">
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:var(--spacing-sm);justify-content:flex-end;">
|
||
<button class="btn btn-secondary" onclick="closeReminderModal()">Anuluj</button>
|
||
<button class="btn btn-primary" id="reminderSendBtn" onclick="sendReminder()">Wyślij przypomnienie</button>
|
||
</div>
|
||
<input type="hidden" id="reminderCompanyId">
|
||
<input type="hidden" id="reminderSubject">
|
||
<input type="hidden" id="reminderMessage">
|
||
</div>
|
||
</div>
|
||
|
||
{% 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();
|
||
}
|
||
});
|
||
|
||
// Reminder functions
|
||
async function openReminderModal(companyId, companyName, year) {
|
||
var fd = new FormData();
|
||
fd.append('company_id', companyId);
|
||
fd.append('year', year);
|
||
try {
|
||
var resp = await fetch('/admin/fees/reminder-preview', {
|
||
method: 'POST',
|
||
body: fd,
|
||
headers: {'X-CSRFToken': '{{ csrf_token() }}'}
|
||
});
|
||
var data = await resp.json();
|
||
if (!data.success) {
|
||
showToast(data.error, 'error');
|
||
return;
|
||
}
|
||
document.getElementById('reminderCompanyName').textContent = data.company_name;
|
||
document.getElementById('reminderAmount').textContent = data.total_due;
|
||
document.getElementById('reminderPeriod').textContent = data.period;
|
||
document.getElementById('reminderMessagePreview').innerHTML = data.message;
|
||
|
||
// Populate portal recipient dropdown
|
||
var portalSel = document.getElementById('reminderPortalRecipient');
|
||
portalSel.innerHTML = '<option value="">— nie wysyłaj na portal —</option>';
|
||
(data.linked_users || []).forEach(function(u) {
|
||
var opt = document.createElement('option');
|
||
opt.value = u.id;
|
||
opt.textContent = u.name + (u.role ? ' (' + u.role + ')' : '');
|
||
portalSel.appendChild(opt);
|
||
});
|
||
if (data.manager_user_id) portalSel.value = data.manager_user_id;
|
||
|
||
// Populate email checkboxes
|
||
var emailBox = document.getElementById('reminderEmailCheckboxes');
|
||
emailBox.innerHTML = '';
|
||
var emails = data.available_emails || [];
|
||
emails.forEach(function(e, i) {
|
||
var label = document.createElement('label');
|
||
label.style.cssText = 'display:flex;align-items:center;gap:6px;font-size:13px;cursor:pointer;';
|
||
var cb = document.createElement('input');
|
||
cb.type = 'checkbox';
|
||
cb.className = 'reminder-email-cb';
|
||
cb.value = e.email;
|
||
cb.checked = (i === 0);
|
||
label.appendChild(cb);
|
||
label.appendChild(document.createTextNode(e.label));
|
||
emailBox.appendChild(label);
|
||
});
|
||
if (emails.length === 0) {
|
||
emailBox.innerHTML = '<span style="font-size:12px;color:var(--text-secondary);">Brak adresów email w systemie</span>';
|
||
}
|
||
document.getElementById('reminderEmailCustom').value = '';
|
||
|
||
document.getElementById('reminderCompanyId').value = companyId;
|
||
document.getElementById('reminderSubject').value = data.subject;
|
||
document.getElementById('reminderMessage').value = data.message;
|
||
document.getElementById('reminderModal').style.display = 'flex';
|
||
} catch(e) {
|
||
showToast('Błąd: ' + e, 'error');
|
||
}
|
||
}
|
||
|
||
function closeReminderModal() {
|
||
document.getElementById('reminderModal').style.display = 'none';
|
||
}
|
||
|
||
async function sendReminder() {
|
||
var btn = document.getElementById('reminderSendBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Wysyłanie...';
|
||
var fd = new FormData();
|
||
fd.append('company_id', document.getElementById('reminderCompanyId').value);
|
||
fd.append('manager_user_id', document.getElementById('reminderPortalRecipient').value);
|
||
fd.append('subject', document.getElementById('reminderSubject').value);
|
||
fd.append('message', document.getElementById('reminderMessage').value);
|
||
// Collect all checked emails + custom
|
||
var selectedEmails = [];
|
||
document.querySelectorAll('.reminder-email-cb:checked').forEach(function(cb) {
|
||
selectedEmails.push(cb.value);
|
||
});
|
||
var custom = document.getElementById('reminderEmailCustom').value.trim();
|
||
if (custom && selectedEmails.indexOf(custom) === -1) {
|
||
selectedEmails.push(custom);
|
||
}
|
||
if (selectedEmails.length > 0) {
|
||
fd.append('send_email', 'on');
|
||
fd.append('company_email', selectedEmails.join(','));
|
||
}
|
||
try {
|
||
var resp = await fetch('/admin/fees/send-reminder', {
|
||
method: 'POST',
|
||
body: fd,
|
||
headers: {'X-CSRFToken': '{{ csrf_token() }}'}
|
||
});
|
||
var data = await resp.json();
|
||
if (data.success) {
|
||
closeReminderModal();
|
||
showToast(data.message, 'success');
|
||
setTimeout(function() { location.reload(); }, 1500);
|
||
} else {
|
||
showToast(data.error, 'error');
|
||
}
|
||
} catch(e) {
|
||
showToast('Błąd: ' + e, 'error');
|
||
}
|
||
btn.disabled = false;
|
||
btn.textContent = 'Wyślij przypomnienie';
|
||
}
|
||
|
||
document.getElementById('reminderModal').addEventListener('click', function(e) {
|
||
if (e.target === this) closeReminderModal();
|
||
});
|
||
{% endblock %}
|