feat(fees): klikalne kwadraciki miesięcy w panelu składek — quick payment registration
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

Przed: w widoku rocznym /admin/fees kwadraciki miesięcy były tylko
dekoracyjne (span z tooltipem). Żeby wpisać płatność trzeba było
przełączyć widok na konkretny miesiąc przez dropdown i dopiero wtedy
pojawiał się przycisk „Opłać". Magdalena (kierownik biura) spędziła
8 minut próbując klikać w kwadraciki — nic się nie działo.

Teraz: każdy kwadrat miesiąca jest klikalny, otwiera okienko płatności
dla konkretnej firmy × miesiąca. Jeśli rekord MembershipFee nie istnieje
— backend sam go tworzy z wyliczoną stawką (200/300 zł wg zasad brand).

Zmiany:
- Nowy endpoint /admin/fees/ensure-and-mark-paid — tworzy rekord
  jeśli brak, potem mark-paid. Odrzuca firmy-córki (parent_company_id)
  z komunikatem „Płatność rejestruj przy firmie matce"
- openPaymentModalSmart() w JS — wybór między /mark-paid (istniejący fee)
  a /ensure-and-mark-paid (nowy fee) na podstawie obecności feeId
- Hidden fields company_id, fee_year, fee_month w formularzu modala
- Modal pokazuje teraz osobno „Stawka" (disabled) i „Kwota wpłacona"
  (editable) — jeden pole amount zmyliło Magdalenę
- Żółty info-box nad tabelą roczną: „Kliknij kwadrat miesiąca, aby
  zarejestrować wpłatę"
- Hover: kwadrat się powiększa, pokazuje cień — afordancja kliknięcia

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-04-14 19:07:17 +02:00
parent 3eaa306b9b
commit f274d59ae6
2 changed files with 145 additions and 7 deletions

View File

@ -980,6 +980,89 @@ def admin_fees_generate():
db.close() db.close()
@bp.route('/fees/ensure-and-mark-paid', methods=['POST'])
@login_required
@role_required(SystemRole.OFFICE_MANAGER)
def admin_fees_ensure_and_mark_paid():
"""Ułatwia rejestrację płatności z widoku rocznego.
Jeśli rekord MembershipFee dla (company_id, fee_year, fee_month) istnieje
aktualizuje go jak mark-paid. Jeśli nie tworzy go z kwotą wyliczoną
wg reguł (300 dla firmy matki z co najmniej 2 aktywnymi markami,
200 dla pozostałych) i oznacza jako opłacony.
"""
db = SessionLocal()
try:
company_id = request.form.get('company_id', type=int)
fee_year = request.form.get('fee_year', type=int)
fee_month = request.form.get('fee_month', type=int)
if not company_id or not fee_year or not fee_month:
return jsonify({'success': False, 'error': 'Brakuje company_id / fee_year / fee_month'}), 400
company = db.query(Company).filter(Company.id == company_id).first()
if not company:
return jsonify({'success': False, 'error': 'Firma nie istnieje'}), 404
fee = db.query(MembershipFee).filter(
MembershipFee.company_id == company_id,
MembershipFee.fee_year == fee_year,
MembershipFee.fee_month == fee_month,
).first()
if not fee:
# Calculate expected amount: 300 zł dla firmy matki z 2+ markami, w przeciwnym razie 200
if company.parent_company_id:
# Firma-córka — stawka 0 (lub nie tworzymy — zwracamy błąd z wyjaśnieniem)
return jsonify({'success': False, 'error': 'Firmy-córki nie mają własnej składki — płatność rejestruj przy firmie matce'}), 400
child_count = db.query(Company).filter(
Company.parent_company_id == company_id,
Company.status == 'active'
).count()
amount = 300 if child_count >= 2 else 200
fee = MembershipFee(
company_id=company_id,
fee_year=fee_year,
fee_month=fee_month,
amount=amount,
status='pending',
)
db.add(fee)
db.flush()
amount_paid = request.form.get('amount_paid', type=float)
payment_date = request.form.get('payment_date')
payment_method = request.form.get('payment_method', 'transfer')
payment_reference = request.form.get('payment_reference', '')
notes = request.form.get('notes', '')
fee.amount_paid = amount_paid or float(fee.amount)
fee.payment_date = datetime.strptime(payment_date, '%Y-%m-%d').date() if payment_date else datetime.now().date()
fee.payment_method = payment_method
fee.payment_reference = payment_reference
fee.notes = notes
fee.recorded_by = current_user.id
fee.recorded_at = datetime.now()
if fee.amount_paid >= float(fee.amount):
fee.status = 'paid'
elif fee.amount_paid > 0:
fee.status = 'partial'
db.commit()
return jsonify({
'success': True,
'message': 'Składka zarejestrowana',
'fee_id': fee.id,
'status': fee.status,
})
except Exception as e:
db.rollback()
logger.error(f"ensure-and-mark-paid error: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@bp.route('/fees/<int:fee_id>/mark-paid', methods=['POST']) @bp.route('/fees/<int:fee_id>/mark-paid', methods=['POST'])
@login_required @login_required
@role_required(SystemRole.OFFICE_MANAGER) @role_required(SystemRole.OFFICE_MANAGER)

View File

@ -250,6 +250,8 @@
.month-cell.pending { background: var(--warning); color: white; } .month-cell.pending { background: var(--warning); color: white; }
.month-cell.overdue { background: var(--error); color: white; } .month-cell.overdue { background: var(--error); color: white; }
.month-cell.empty { background: var(--surface-secondary); color: var(--text-secondary); } .month-cell.empty { background: var(--surface-secondary); color: var(--text-secondary); }
.month-cell.clickable { transition: transform 0.15s, box-shadow 0.15s; }
.month-cell.clickable:hover { transform: scale(1.15); box-shadow: 0 2px 6px rgba(0,0,0,0.25); z-index: 2; position: relative; }
.partial-badge { .partial-badge {
position: absolute; position: absolute;
@ -349,6 +351,12 @@
{% endif %} {% endif %}
</div> </div>
{% if not month %}
<div style="background:#fef3c7;border-left:4px solid #f59e0b;padding:12px 18px;border-radius:6px;margin-bottom:var(--spacing-lg);color:#92400e;font-size:14px;line-height:1.5">
<strong>Jak rejestrować wpłaty:</strong> kliknij bezpośrednio kwadrat miesiąca w wierszu firmy — otworzy się okienko do wpisania kwoty i daty wpłaty. Kwadraty szare („-") oznaczają brak rekordu — klik utworzy rekord i od razu zarejestruje wpłatę z właściwą stawką (200 zł lub 300 zł).
</div>
{% endif %}
<!-- Companies Table --> <!-- Companies Table -->
<div class="section"> <div class="section">
<div class="section-header"> <div class="section-header">
@ -486,11 +494,19 @@
{% set underpaid = fee and fee.amount and fee.amount|int < expected %} {% 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 %} {% set is_premium = fee and fee.status == 'paid' and expected >= 300 and fee.amount|int >= 300 %}
{% if fee %} {% 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 %}"> <span class="month-cell clickable {% 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 %} · Kliknij, aby edytować płatność"
style="position:relative;cursor:pointer;{% if underpaid %}outline:2px solid var(--error);outline-offset:-2px;{% endif %}"
onclick="openPaymentModalSmart({{ cf.company.id }}, {{ cf.company.name|tojson }}, {{ year }}, {{ m }}, {{ fee.id }}, {{ fee.amount|int }}, {{ expected }}, {{ 'true' if fee.status == 'paid' else 'false' }}, {{ fee.amount_paid|int if fee.amount_paid else 0 }})">
{{ 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 %} {{ 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> </span>
{% else %} {% else %}
<span class="month-cell empty" title="Brak rekordu (stawka: {{ expected }} zł)">-</span> <span class="month-cell empty clickable"
title="Brak wpłaty za miesiąc {{ m }} (stawka {{ expected }} zł) · Kliknij, aby zarejestrować wpłatę"
style="cursor:pointer;"
onclick="openPaymentModalSmart({{ cf.company.id }}, {{ cf.company.name|tojson }}, {{ year }}, {{ m }}, null, {{ expected }}, {{ expected }}, false, 0)">
{{ m }}
</span>
{% endif %} {% endif %}
</td> </td>
{% endfor %} {% endfor %}
@ -534,15 +550,25 @@
</div> </div>
<form id="paymentForm"> <form id="paymentForm">
<input type="hidden" name="fee_id" id="modalFeeId"> <input type="hidden" name="fee_id" id="modalFeeId">
<input type="hidden" name="company_id" id="modalCompanyId">
<input type="hidden" name="fee_year" id="modalFeeYear">
<input type="hidden" name="fee_month" id="modalFeeMonth">
<div class="form-group"> <div class="form-group">
<label>Firma</label> <label>Firma / miesiąc</label>
<input type="text" id="modalCompanyName" disabled> <input type="text" id="modalCompanyName" disabled>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Kwota do zapłaty</label> <label>Stawka (kwota do zapłaty)</label>
<input type="number" name="amount_paid" id="modalAmount" step="0.01" required> <input type="number" id="modalAmount" step="0.01" disabled style="background:#f3f4f6;color:#6b7280">
<small style="color:#6b7280;font-size:11px">Wyliczana z konfiguracji (200 zł / 300 zł dla firmy matki z 2+ markami)</small>
</div>
<div class="form-group">
<label>Kwota wpłacona</label>
<input type="number" name="amount_paid" id="modalAmountPaid" step="0.01" required>
<small style="color:#6b7280;font-size:11px">Wpisz wpłaconą kwotę — jeśli równa stawce, status będzie „opłacone", jeśli mniejsza — „częściowa wpłata"</small>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -710,8 +736,32 @@ async function generateFees() {
function openPaymentModal(feeId, companyName, amount) { function openPaymentModal(feeId, companyName, amount) {
document.getElementById('modalFeeId').value = feeId; document.getElementById('modalFeeId').value = feeId;
document.getElementById('modalCompanyId').value = '';
document.getElementById('modalFeeYear').value = '';
document.getElementById('modalFeeMonth').value = '';
document.getElementById('modalCompanyName').value = companyName; document.getElementById('modalCompanyName').value = companyName;
document.getElementById('modalAmount').value = amount; document.getElementById('modalAmount').value = amount;
document.getElementById('modalAmountPaid').value = amount;
document.getElementById('modalDate').value = new Date().toISOString().split('T')[0];
document.getElementById('paymentModal').classList.add('active');
}
/**
* openPaymentModalSmart — działa w widoku rocznym: otwiera modal dla konkretnego
* (company, year, month). Jeśli fee już istnieje — tryb edycji istniejącego
* rekordu. Jeśli nie — po submit backend utworzy rekord z wyliczoną kwotą.
*/
function openPaymentModalSmart(companyId, companyName, year, month, feeId, feeAmount, expected, isPaid, amountPaid) {
const monthNames = ['','styczeń','luty','marzec','kwiecień','maj','czerwiec',
'lipiec','sierpień','wrzesień','październik','listopad','grudzień'];
document.getElementById('modalFeeId').value = feeId || '';
document.getElementById('modalCompanyId').value = companyId;
document.getElementById('modalFeeYear').value = year;
document.getElementById('modalFeeMonth').value = month;
document.getElementById('modalCompanyName').value = companyName + ' — ' + monthNames[month] + ' ' + year;
const amountToSet = feeId ? feeAmount : expected;
document.getElementById('modalAmount').value = amountToSet;
document.getElementById('modalAmountPaid').value = isPaid ? amountPaid : amountToSet;
document.getElementById('modalDate').value = new Date().toISOString().split('T')[0]; document.getElementById('modalDate').value = new Date().toISOString().split('T')[0];
document.getElementById('paymentModal').classList.add('active'); document.getElementById('paymentModal').classList.add('active');
} }
@ -726,8 +776,13 @@ async function generateFees() {
const feeId = document.getElementById('modalFeeId').value; const feeId = document.getElementById('modalFeeId').value;
const formData = new FormData(this); const formData = new FormData(this);
// Wybierz endpoint: konkretny fee (edycja) lub ensure-and-mark-paid (tworzy jeśli brak)
const endpoint = feeId
? '/admin/fees/' + feeId + '/mark-paid'
: '/admin/fees/ensure-and-mark-paid';
try { try {
const response = await fetch('/admin/fees/' + feeId + '/mark-paid', { const response = await fetch(endpoint, {
method: 'POST', method: 'POST',
body: formData, body: formData,
headers: { headers: {
@ -738,7 +793,7 @@ async function generateFees() {
if (data.success) { if (data.success) {
closePaymentModal(); closePaymentModal();
showToast(data.message, 'success'); showToast(data.message, 'success');
setTimeout(() => location.reload(), 1500); setTimeout(() => location.reload(), 1200);
} else { } else {
showToast('Błąd: ' + data.error, 'error'); showToast('Błąd: ' + data.error, 'error');
} }