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
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:
parent
3eaa306b9b
commit
f274d59ae6
@ -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/<int:fee_id>/mark-paid', methods=['POST'])
|
||||
@login_required
|
||||
@role_required(SystemRole.OFFICE_MANAGER)
|
||||
|
||||
@ -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 %}
|
||||
</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 -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
@ -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 %}
|
||||
<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 %}
|
||||
</span>
|
||||
{% 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 %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
@ -534,15 +550,25 @@
|
||||
</div>
|
||||
<form id="paymentForm">
|
||||
<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">
|
||||
<label>Firma</label>
|
||||
<label>Firma / miesiąc</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>
|
||||
<label>Stawka (kwota do zapłaty)</label>
|
||||
<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 class="form-group">
|
||||
@ -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');
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user