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()
|
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'])
|
@bp.route('/fees/<int:fee_id>/mark-paid', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@role_required(SystemRole.OFFICE_MANAGER)
|
@role_required(SystemRole.OFFICE_MANAGER)
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user