feat(board): widok read-only składek dla Zarządu i Rady Izby
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

Nowy route /rada/skladki dostępny dla członków Rady Izby (chamber_role).
Pokazuje te same dane co panel admin, ale bez edycji — tylko podgląd
statusów płatności, filtrowanie i zaległości z lat poprzednich.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-20 15:30:16 +01:00
parent a612c45526
commit 0e5f76dea9
2 changed files with 362 additions and 1 deletions

View File

@ -22,6 +22,7 @@ Endpoints - Documents:
"""
import os
from decimal import Decimal
from datetime import datetime
from flask import (
render_template, request, redirect, url_for, flash,
@ -31,7 +32,7 @@ from flask_login import login_required, current_user
from sqlalchemy import desc
from . import bp
from database import SessionLocal, BoardMeeting, BoardDocument, SystemRole, User
from database import SessionLocal, BoardMeeting, BoardDocument, SystemRole, User, Company, MembershipFee, MembershipFeeConfig
from utils.decorators import rada_member_required, office_manager_required
from utils.helpers import sanitize_html
from services.document_upload_service import DocumentUploadService
@ -755,3 +756,89 @@ def _handle_meeting_form(db, meeting=None):
current_app.logger.error(f"Failed to save meeting: {e}")
flash('Błąd podczas zapisywania posiedzenia.', 'error')
return redirect(request.url)
# =============================================================================
# MEMBERSHIP FEES - READ ONLY VIEW FOR BOARD MEMBERS
# =============================================================================
MONTHS_PL_BOARD = [
(1, 'Styczeń'), (2, 'Luty'), (3, 'Marzec'), (4, 'Kwiecień'),
(5, 'Maj'), (6, 'Czerwiec'), (7, 'Lipiec'), (8, 'Sierpień'),
(9, 'Wrzesień'), (10, 'Październik'), (11, 'Listopad'), (12, 'Grudzień')
]
@bp.route('/skladki')
@login_required
@rada_member_required
def board_fees():
"""Read-only view of membership fees for board members."""
db = SessionLocal()
try:
from sqlalchemy import func
year = request.args.get('year', datetime.now().year, type=int)
status_filter = request.args.get('status', '')
companies = db.query(Company).filter(Company.status == 'active').order_by(Company.name).all()
fee_query = db.query(MembershipFee).filter(MembershipFee.fee_year == year)
fees = {(f.company_id, f.fee_month): f for f in fee_query.all()}
companies_fees = []
for company in companies:
company_data = {'company': company, 'months': {}, 'monthly_rate': 0, 'has_data': False}
for m in range(1, 13):
fee = fees.get((company.id, m))
company_data['months'][m] = fee
if fee and fee.amount:
company_data['has_data'] = True
if not company_data['monthly_rate']:
company_data['monthly_rate'] = int(fee.amount)
companies_fees.append(company_data)
# Sort: companies with fee data first
companies_fees.sort(key=lambda cf: (0 if cf.get('has_data') else 1, cf['company'].name))
# Filters
if status_filter:
if status_filter == 'paid':
companies_fees = [cf for cf in companies_fees if all(
cf['months'].get(m) and cf['months'][m].status == 'paid' for m in range(1, 13)
if cf['months'].get(m)
) and any(cf['months'].get(m) for m in range(1, 13))]
elif status_filter == 'partial':
companies_fees = [cf for cf in companies_fees if (
(any(cf['months'].get(m) and cf['months'][m].status == 'paid' for m in range(1, 13)) and
any(cf['months'].get(m) and cf['months'][m].status in ('pending', 'overdue') for m in range(1, 13))) or
any(cf['months'].get(m) and cf['months'][m].status == 'partial' for m in range(1, 13))
)]
elif status_filter == 'none':
companies_fees = [cf for cf in companies_fees if
any(cf['months'].get(m) for m in range(1, 13)) and
not any(cf['months'].get(m) and cf['months'][m].status in ('paid', 'partial') for m in range(1, 13))
]
total_companies = len(companies)
all_fees = list(fees.values())
paid_count = sum(1 for f in all_fees if f.status == 'paid')
pending_count = len(all_fees) - paid_count
total_due = sum(float(f.amount) for f in all_fees) if all_fees else Decimal(0)
total_paid = sum(float(f.amount_paid or 0) for f in all_fees) if all_fees else Decimal(0)
return render_template(
'board/fees_readonly.html',
companies_fees=companies_fees,
year=year,
status_filter=status_filter,
total_companies=total_companies,
paid_count=paid_count,
pending_count=pending_count,
total_due=total_due,
total_paid=total_paid,
years=list(range(2022, datetime.now().year + 2)),
months=MONTHS_PL_BOARD,
)
finally:
db.close()

View File

@ -0,0 +1,274 @@
{% extends "base.html" %}
{% block title %}Składki Członkowskie - Rada Izby{% endblock %}
{% block extra_css %}
<style>
.admin-header {
margin-bottom: var(--spacing-xl);
}
.admin-header h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-2xl);
}
.stat-card {
background: var(--surface);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
box-shadow: var(--shadow);
text-align: center;
}
.stat-card.success { border-left: 3px solid var(--success); }
.stat-card.warning { border-left: 3px solid var(--warning); }
.stat-card.primary { border-left: 3px solid var(--primary); }
.stat-value {
font-size: var(--font-size-xl);
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 {
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
}
.section {
background: var(--surface);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-xl);
}
.fees-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.fees-table th,
.fees-table td {
padding: var(--spacing-xs) var(--spacing-sm);
text-align: center;
border-bottom: 1px solid var(--border);
}
.fees-table th:first-child,
.fees-table td:first-child {
text-align: left;
width: 160px;
}
.fees-table th.col-month,
.fees-table td.col-month {
width: 36px;
padding: var(--spacing-xs) 2px;
}
.fees-table th {
font-weight: 600;
color: var(--text-secondary);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0;
background: var(--background);
}
.fees-table tr:hover {
background: var(--background);
}
.month-cell {
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
font-size: 10px;
font-weight: 600;
}
.month-cell.paid { background: var(--success); color: white; }
.month-cell.partial { background: #60a5fa; 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); }
.partial-badge {
position: absolute;
top: -6px;
right: -6px;
background: #ef4444;
color: white;
font-size: 8px;
font-weight: 700;
padding: 1px 3px;
border-radius: 6px;
line-height: 1;
transform: rotate(12deg);
min-width: 14px;
text-align: center;
}
.readonly-badge {
display: inline-block;
background: var(--info-bg);
color: var(--info);
font-size: var(--font-size-xs);
padding: 2px 8px;
border-radius: var(--radius-full);
font-weight: 600;
margin-left: var(--spacing-sm);
vertical-align: middle;
}
</style>
{% endblock %}
{% block content %}
<div class="container">
<div class="admin-header">
<h1>Składki Członkowskie <span class="readonly-badge">tylko podgląd</span></h1>
</div>
<!-- Stats -->
<div class="stats-grid">
<div class="stat-card primary">
<div class="stat-value">{{ total_companies }}</div>
<div class="stat-label">Firm członkowskich</div>
</div>
<div class="stat-card success">
<div class="stat-value">{{ paid_count }}</div>
<div class="stat-label">Opłaconych składek (łącznie)</div>
</div>
<div class="stat-card warning">
<div class="stat-value">{{ pending_count }}</div>
<div class="stat-label">Oczekujących składek (łącznie)</div>
</div>
<div class="stat-card success">
<div class="stat-value">{{ total_paid|int }} zł</div>
<div class="stat-label">Zebrano</div>
</div>
<div class="stat-card warning">
<div class="stat-value">{{ (total_due - total_paid)|int }} zł</div>
<div class="stat-label">Do zebrania</div>
</div>
</div>
<!-- Legenda -->
<div style="display: flex; gap: var(--spacing-lg); flex-wrap: wrap; margin-bottom: var(--spacing-md); font-size: var(--font-size-sm); color: var(--text-secondary); align-items: center;">
<span style="display: flex; align-items: center; gap: 4px;"><span class="month-cell paid" style="width: 24px; height: 24px; font-size: 11px;">1</span> Opłacone</span>
<span style="display: flex; align-items: center; gap: 4px;"><span class="month-cell partial" style="width: 24px; height: 24px; font-size: 11px;">1</span> Niepełna wpłata</span>
<span style="display: flex; align-items: center; gap: 4px;"><span class="month-cell pending" style="width: 24px; height: 24px; font-size: 11px;">1</span> Oczekujące</span>
<span style="display: flex; align-items: center; gap: 4px;"><span class="month-cell overdue" style="width: 24px; height: 24px; font-size: 11px;">1</span> Zaległe</span>
<span style="display: flex; align-items: center; gap: 4px;"><span class="month-cell empty" style="width: 24px; height: 24px; font-size: 11px;">-</span> Brak danych</span>
<span style="display: flex; align-items: center; gap: 4px; border-left: 1px solid var(--border); padding-left: var(--spacing-lg);"><span style="color:var(--error);font-weight:700;font-size:12px;">500 zł</span> Zaległości z lat poprzednich</span>
</div>
<!-- Filters -->
<div class="filters-bar">
<form method="GET" action="{{ url_for('board.board_fees') }}" style="display: flex; gap: var(--spacing-md); flex-wrap: wrap; align-items: center;">
<select name="year" onchange="this.form.submit()">
{% for y in years %}
<option value="{{ y }}" {% if y == year %}selected{% endif %}>{{ y }}</option>
{% endfor %}
</select>
<select name="status" onchange="this.form.submit()">
<option value="">-- Wszystkie firmy --</option>
<option value="paid" {% if status_filter == 'paid' %}selected{% endif %}>Opłacone za cały rok</option>
<option value="partial" {% if status_filter == 'partial' %}selected{% endif %}>Częściowo opłacone</option>
<option value="none" {% if status_filter == 'none' %}selected{% endif %}>Brak wpłat</option>
</select>
</form>
</div>
<!-- Companies Table -->
<div class="section">
<div class="section-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-lg);">
<h2>Lista firm ({{ year }}) <span style="display:inline-flex;align-items:center;justify-content:center;background:var(--error);color:white;font-size:var(--font-size-sm);font-weight:700;min-width:28px;height:28px;border-radius:var(--radius-full);padding:0 8px;vertical-align:middle;margin-left:8px;">{{ companies_fees|length }}</span></h2>
</div>
<table class="fees-table">
<thead>
<tr>
<th>Firma</th>
<th class="col-month">I</th><th class="col-month">II</th><th class="col-month">III</th><th class="col-month">IV</th><th class="col-month">V</th><th class="col-month">VI</th>
<th class="col-month">VII</th><th class="col-month">VIII</th><th class="col-month">IX</th><th class="col-month">X</th><th class="col-month">XI</th><th class="col-month">XII</th>
<th style="width:70px;font-size:9px;line-height:1.2;">Zaległ.<br><span style="font-weight:400;text-transform:none;">z lat poprz.</span></th>
</tr>
</thead>
<tbody>
{% set ns = namespace(separator_shown=false) %}
{% for cf in companies_fees %}
{% if not cf.has_data and not ns.separator_shown %}
{% set ns.separator_shown = true %}
<tr><td colspan="14" style="background: var(--border); padding: var(--spacing-xs); text-align: center; font-size: var(--font-size-sm); color: var(--text-secondary); font-weight: 600;">Firmy bez danych o składkach</td></tr>
{% endif %}
<tr{% if not cf.has_data %} style="opacity: 0.5;"{% endif %}>
<td>
<span {% if not cf.has_data %}style="color: var(--text-secondary);"{% endif %}>
{{ cf.company.name }}
</span>
{% if cf.monthly_rate and cf.monthly_rate > 200 %}
<span style="display:inline-block;background:#dbeafe;color:#1e40af;font-size:10px;padding:1px 5px;border-radius:3px;font-weight:600;vertical-align:middle;margin-left:4px;">{{ cf.monthly_rate }} zł</span>
{% endif %}
</td>
{% for m in range(1, 13) %}
<td class="col-month">
{% set fee = cf.months.get(m) %}
{% if fee %}
<span class="month-cell {{ fee.status }}" title="{{ fee.status }}: wpłacono {{ fee.amount_paid|int }} z {{ fee.amount|int }} zł" style="position:relative;">
{{ m }}{% if fee.status == 'partial' %}<span class="partial-badge">{{ fee.amount_paid|int }}</span>{% endif %}
</span>
{% else %}
<span class="month-cell empty" title="Brak rekordu">-</span>
{% endif %}
</td>
{% endfor %}
<td>
{% set debt = cf.company.previous_years_debt|default(0)|float %}
{% if debt > 0 %}
<span style="color:var(--error);font-weight:700;font-size:13px;">{{ debt|int }} zł</span>
{% else %}
<span style="color:var(--text-secondary);font-size:11px;"></span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}