nordabiz/templates/admin/fees.html
Maciej Pienczyn ea8b622903
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: fee underpayment indicator + premium color on month tiles
- paid 200 zł (stawka 200): green (standard)
- paid 300 zł (stawka 300): teal (paid-premium)
- paid 200 zł (stawka 300): red outline + ! badge (underpaid)
Applied to both board/skladki and admin/fees views.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:46:37 +02:00

967 lines
44 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 %}Składki Członkowskie - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.container { max-width: 1600px; }
.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;
table-layout: fixed;
}
.fees-table th,
.fees-table td {
padding: var(--spacing-xs) var(--spacing-sm);
text-align: center;
border-bottom: 1px solid var(--border);
}
.fees-table th:first-child,
.fees-table td:first-child {
text-align: left;
width: 140px;
}
/* Month columns — compact */
.fees-table th.col-month,
.fees-table td.col-month {
width: 36px;
padding: var(--spacing-xs) 2px;
}
.fees-table th {
font-weight: 600;
color: var(--text-secondary);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0;
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: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
font-size: 10px;
font-weight: 600;
cursor: pointer;
}
.month-cell.paid { background: var(--success); color: white; }
.month-cell.paid-premium { background: #0d9488; 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; border-left: 1px solid var(--border); padding-left: var(--spacing-lg);"><span style="color:var(--error);font-weight:700;font-size:12px;"></span> Zaległości z lat poprzednich (kliknij aby wpisać lub edytować)</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 style="width:90px;font-size:10px;">Stawka</th>
<th class="col-month">I</th><th class="col-month">II</th><th class="col-month">III</th><th class="col-month">IV</th><th class="col-month">V</th><th class="col-month">VI</th>
<th class="col-month">VII</th><th class="col-month">VIII</th><th class="col-month">IX</th><th class="col-month">X</th><th class="col-month">XI</th><th class="col-month">XII</th>
<th title="Zaległości z lat poprzednich — kliknij aby wpisać kwotę" style="width:70px;font-size:9px;line-height:1.2;">Zaległ.<br><span style="font-weight:400;text-transform:none;">z lat poprz.</span></th>
<th style="width:50px;"></th>
<th style="width:70px;"></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 and not cf.is_child %}
{% set ns.separator_shown = true %}
<tr><td colspan="17" 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 %}
{% if not month and cf.is_child %}
{# Firma córka — wiersz z przekreślonymi kafelkami #}
<tr style="opacity:0.55;">
<td style="padding-left:24px;">
<span style="color:var(--text-secondary);font-size:12px;">↳ {{ cf.company.name }}</span>
<span style="display:inline-block;background:#e0e7ff;color:#3730a3;font-size:9px;padding:1px 5px;border-radius:3px;font-weight:600;margin-left:4px;">firma córka</span>
</td>
<td style="text-align:center;">
<span style="font-size:11px;color:var(--text-muted);">0 zł</span>
</td>
{% for m in range(1, 13) %}
<td class="col-month">
{% set parent_fee = cf.parent_months.get(m) %}
<span class="month-cell {% if parent_fee %}{{ parent_fee.status }}{% else %}empty{% endif %}" style="position:relative;opacity:0.4;background-image:repeating-linear-gradient(135deg,transparent,transparent 3px,rgba(0,0,0,0.12) 3px,rgba(0,0,0,0.12) 4px);" title="Nie dotyczy — składka w firmie matce">
-
</span>
</td>
{% endfor %}
<td><span style="color:var(--text-secondary);font-size:11px;"></span></td>
<td></td>
<td></td>
</tr>
{% else %}
<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 %}
{# Yearly view — normal/parent company #}
<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.child_count > 0 %}
<details style="margin-top:2px;">
<summary style="font-size:10px;color:var(--primary);cursor:pointer;">{{ cf.child_count }} {{ 'marka' if cf.child_count == 1 else ('marki' if cf.child_count <= 4 else 'marek') }} zależnych</summary>
<div style="font-size:10px;color:var(--text-secondary);padding:2px 0 0 8px;">
{% for ch in cf.child_brands|sort(attribute='name') %}
<div>{{ ch.name }} <span style="color:var(--text-muted);font-size:9px;">(od {{ ch.created_at.strftime('%m/%Y') if ch.created_at else '?' }})</span></div>
{% endfor %}
</div>
</details>
{% endif %}
</td>
<td style="text-align:center;">
{% if cf.child_count > 0 %}
{% set rate_change_month = cf.rate_change_month %}
{% if rate_change_month and rate_change_month > 1 %}
<div style="font-size:10px;line-height:1.3;">
<span style="color:var(--text-secondary);">I-{{ ['','I','II','III','IV','V','VI','VII','VIII','IX','X','XI','XII'][rate_change_month - 1] }}: 200 zł</span><br>
<span style="display:inline-block;background:#fef3c7;color:#92400e;font-size:10px;padding:1px 5px;border-radius:3px;font-weight:700;">od {{ ['','I','II','III','IV','V','VI','VII','VIII','IX','X','XI','XII'][rate_change_month] }}: 300 zł</span>
</div>
{% else %}
<span style="display:inline-block;background:#fef3c7;color:#92400e;font-size:11px;padding:2px 6px;border-radius:4px;font-weight:700;">300 zł</span>
{% endif %}
{% else %}
<span style="font-size:11px;color:var(--text-secondary);">200 zł</span>
{% endif %}
</td>
{% for m in range(1, 13) %}
<td class="col-month">
{% set fee = cf.months.get(m) %}
{% set expected = cf.expected_fees.get(m, 200) %}
{% set underpaid = fee and fee.amount and fee.amount|int < expected %}
{% set is_premium = fee and fee.status == 'paid' and expected >= 300 and fee.amount|int >= 300 %}
{% if fee %}
<span class="month-cell {% if is_premium %}paid-premium{% else %}{{ fee.status }}{% endif %}" title="{{ fee.status }}: wpłacono {{ fee.amount_paid|int }} z {{ fee.amount|int }} zł{% if underpaid %} ⚠ stawka powinna wynosić {{ expected }} zł{% endif %}{% if is_premium %} (stawka pełna 300 zł){% endif %}" style="position:relative;{% if underpaid %}outline:2px solid var(--error);outline-offset:-2px;{% endif %}">
{{ m }}{% if fee.status == 'partial' %}<span class="partial-badge">{{ fee.amount_paid|int }}</span>{% endif %}{% if underpaid %}<span style="position:absolute;top:-6px;right:-6px;background:var(--error);color:white;font-size:8px;font-weight:700;width:14px;height:14px;border-radius:50%;display:flex;align-items:center;justify-content:center;line-height:1;">!</span>{% endif %}
</span>
{% else %}
<span class="month-cell empty" title="Brak rekordu (stawka: {{ expected }} zł)">-</span>
{% endif %}
</td>
{% endfor %}
<td style="white-space:nowrap;">
{% set debt = cf.company.previous_years_debt|default(0)|float %}
{% if debt > 0 %}
<span class="debt-value" style="cursor:pointer;color:var(--error);font-weight:700;font-size:13px;" onclick="editDebt(this, {{ cf.company.id }}, {{ debt }})" title="Zaległości z lat poprzednich — kliknij aby edytować">{{ debt|int }} zł</span>
{% else %}
<span class="debt-value" style="cursor:pointer;color:var(--text-secondary);font-size:11px;border:1px dashed var(--border);border-radius:4px;padding:2px 8px;display:inline-block;" onclick="editDebt(this, {{ cf.company.id }}, 0)" title="Kliknij aby wpisać zaległość z lat poprzednich">+ wpisz</span>
{% endif %}
</td>
<td style="text-align:center;">
{% if cf.reminder %}
{% if cf.reminder.is_read %}
<span style="color:var(--success);font-size:16px;cursor:default;" title="Odczytano {{ cf.reminder.read_at|local_time('%d.%m %H:%M') if cf.reminder.read_at else '' }}. Wysłano {{ cf.reminder.sent_at|local_time('%d.%m %H:%M') }}"></span>
{% else %}
<span style="color:var(--text-secondary);font-size:16px;cursor:default;" title="Wysłano {{ cf.reminder.sent_at|local_time('%d.%m.%Y %H:%M') }}, jeszcze nie odczytano"></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:10px;padding:2px 6px;color:white;border:none;background:{{ '#64748b' if cf.reminder else 'var(--warning)' }};border-radius:4px;" onclick="openReminderModal({{ cf.company.id }}, '{{ cf.company.name|e }}', {{ year }})" title="{{ 'Ponów przypomnienie' if cf.reminder else 'Wyślij przypomnienie o składce' }}">{{ 'Ponów' if cf.reminder else 'Przyp.' }}</button>
{% endif %}
</td>
{% endif %}
</tr>
{% endif %}
{% 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()">&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 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>&middot;
<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');
}
}
// Inline debt editing
function editDebt(el, companyId, currentDebt) {
const td = el.parentElement;
const input = document.createElement('input');
input.type = 'number';
input.value = currentDebt || '';
input.placeholder = '0';
input.style.cssText = 'width:80px;padding:2px 4px;font-size:12px;border:1px solid var(--primary);border-radius:4px;text-align:right;';
input.min = '0';
input.step = '1';
td.innerHTML = '';
td.appendChild(input);
const suffix = document.createElement('span');
suffix.textContent = ' zł';
suffix.style.fontSize = '11px';
td.appendChild(suffix);
input.focus();
input.select();
async function saveDebt() {
const val = parseFloat(input.value) || 0;
const fd = new FormData();
fd.append('company_id', companyId);
fd.append('debt', val);
try {
const resp = await fetch('{{ url_for("admin.admin_fees_update_debt") }}', {
method: 'POST', body: fd,
headers: {'X-CSRFToken': '{{ csrf_token() }}'}
});
const data = await resp.json();
if (data.success) {
if (val > 0) {
td.innerHTML = '<span class="debt-value" style="cursor:pointer;color:var(--error);font-weight:700;font-size:13px;" onclick="editDebt(this,' + companyId + ',' + val + ')" title="Zaległości z lat poprzednich — kliknij aby edytować">' + val + ' zł</span>';
} else {
td.innerHTML = '<span class="debt-value" style="cursor:pointer;color:var(--text-secondary);font-size:11px;border:1px dashed var(--border);border-radius:4px;padding:2px 8px;display:inline-block;" onclick="editDebt(this,' + companyId + ',0)" title="Kliknij aby wpisać zaległość z lat poprzednich">+ wpisz</span>';
}
showToast(data.message, 'success');
} else {
showToast(data.error, 'error');
}
} catch (err) {
showToast('Błąd: ' + err, 'error');
}
}
input.addEventListener('blur', saveDebt);
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
if (e.key === 'Escape') { location.reload(); }
});
}
// 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 %}