From f274d59ae6a5e6fba516d7118ac67e06d340029a Mon Sep 17 00:00:00 2001 From: Maciej Pienczyn Date: Tue, 14 Apr 2026 19:07:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(fees):=20klikalne=20kwadraciki=20miesi?= =?UTF-8?q?=C4=99cy=20w=20panelu=20sk=C5=82adek=20=E2=80=94=20quick=20paym?= =?UTF-8?q?ent=20registration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- blueprints/admin/routes.py | 83 ++++++++++++++++++++++++++++++++++++++ templates/admin/fees.html | 69 +++++++++++++++++++++++++++---- 2 files changed, 145 insertions(+), 7 deletions(-) diff --git a/blueprints/admin/routes.py b/blueprints/admin/routes.py index e8344af..caad9bc 100644 --- a/blueprints/admin/routes.py +++ b/blueprints/admin/routes.py @@ -980,6 +980,89 @@ def admin_fees_generate(): 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 zł dla firmy matki z co najmniej 2 aktywnymi markami, + 200 zł 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//mark-paid', methods=['POST']) @login_required @role_required(SystemRole.OFFICE_MANAGER) diff --git a/templates/admin/fees.html b/templates/admin/fees.html index 60807a7..bd21cbf 100755 --- a/templates/admin/fees.html +++ b/templates/admin/fees.html @@ -250,6 +250,8 @@ .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); } + .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 { position: absolute; @@ -349,6 +351,12 @@ {% endif %} + {% if not month %} +
+ Jak rejestrować wpłaty: 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ł). +
+ {% endif %} +
@@ -486,11 +494,19 @@ {% 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 %} - + {{ m }}{% if fee.status == 'partial' %}{{ fee.amount_paid|int }}{% endif %}{% if underpaid %}!{% endif %} {% else %} - - + + {{ m }} + {% endif %} {% endfor %} @@ -534,15 +550,25 @@
+ + +
- +
- - + + + Wyliczana z konfiguracji (200 zł / 300 zł dla firmy matki z 2+ markami) +
+ +
+ + + Wpisz wpłaconą kwotę — jeśli równa stawce, status będzie „opłacone", jeśli mniejsza — „częściowa wpłata"
@@ -710,8 +736,32 @@ async function generateFees() { function openPaymentModal(feeId, companyName, amount) { document.getElementById('modalFeeId').value = feeId; + document.getElementById('modalCompanyId').value = ''; + document.getElementById('modalFeeYear').value = ''; + document.getElementById('modalFeeMonth').value = ''; document.getElementById('modalCompanyName').value = companyName; 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('paymentModal').classList.add('active'); } @@ -726,8 +776,13 @@ async function generateFees() { const feeId = document.getElementById('modalFeeId').value; 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 { - const response = await fetch('/admin/fees/' + feeId + '/mark-paid', { + const response = await fetch(endpoint, { method: 'POST', body: formData, headers: { @@ -738,7 +793,7 @@ async function generateFees() { if (data.success) { closePaymentModal(); showToast(data.message, 'success'); - setTimeout(() => location.reload(), 1500); + setTimeout(() => location.reload(), 1200); } else { showToast('Błąd: ' + data.error, 'error'); }