nordabiz/templates/admin/fees.html
Maciej Pienczyn 6e00291a88 feat: AI usage user details + styled modals across app
- Add /admin/ai-usage/user/<id> route for detailed AI usage per user
- Add ai_usage_user.html template with stats, usage breakdown, logs
- Make user names clickable in AI usage dashboard ranking
- Replace all native browser dialogs (alert, confirm) with styled modals/toasts:
  - admin/fees.html, forum.html, recommendations.html, announcements.html, debug.html
  - calendar/admin.html, event.html
  - company_detail.html, company/recommend.html
  - forum/new_topic.html, topic.html
  - classifieds/view.html
  - auth/reset_password.html

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 10:30:35 +01:00

633 lines
23 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 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>
<!-- 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_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_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 %}