feat: Add membership application system

Implement full online membership application workflow:
- 3-step wizard form with KRS/CEIDG auto-fill
- Admin panel for application review (approve/reject/request changes)
- Company data update requests for existing members
- Dashboard CTA for users without company
- API endpoints for NIP lookup and draft management

New files:
- database/migrations/042_membership_applications.sql
- blueprints/membership/ (routes, templates)
- blueprints/admin/routes_membership.py
- blueprints/api/routes_membership.py
- templates/membership/ and templates/admin/membership*.html

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-01 12:38:31 +01:00
parent cc83186486
commit 0f8aca1435
18 changed files with 4967 additions and 1 deletions

View File

@ -399,6 +399,25 @@ def register_blueprints(app):
except Exception as e:
logger.error(f"Error registering admin blueprint: {e}")
# Membership Application blueprint
try:
from blueprints.membership import bp as membership_bp
app.register_blueprint(membership_bp)
logger.info("Registered blueprint: membership")
# Create aliases for backward compatibility
_create_endpoint_aliases(app, membership_bp, {
'membership_apply': 'membership.apply',
'membership_apply_step': 'membership.apply_step',
'membership_status': 'membership.status',
'membership_data_request': 'membership.data_request',
})
logger.info("Created membership endpoint aliases")
except ImportError as e:
logger.debug(f"Blueprint membership not yet available: {e}")
except Exception as e:
logger.error(f"Error registering membership blueprint: {e}")
# Phase 6 (continued) + Phase 7-10: Future blueprints will be added here

View File

@ -26,3 +26,4 @@ from . import routes_users_api # noqa: E402, F401
from . import routes_krs_api # noqa: E402, F401
from . import routes_companies # noqa: E402, F401
from . import routes_people # noqa: E402, F401
from . import routes_membership # noqa: E402, F401

View File

@ -0,0 +1,549 @@
"""
Admin Routes - Membership Applications
=======================================
Admin panel for managing membership applications and company data requests.
"""
import re
import logging
from datetime import datetime
from flask import render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from . import bp
from database import (
SessionLocal, MembershipApplication, CompanyDataRequest,
Company, Category, User
)
logger = logging.getLogger(__name__)
def _generate_slug(name):
"""Generate URL-safe slug from company name."""
slug = re.sub(r'[^\w\s-]', '', name.lower())
slug = re.sub(r'[\s_]+', '-', slug)
slug = re.sub(r'-+', '-', slug).strip('-')
return slug
def _generate_member_number(db):
"""Generate next member number."""
last = db.query(MembershipApplication).filter(
MembershipApplication.member_number.isnot(None)
).order_by(MembershipApplication.id.desc()).first()
if last and last.member_number:
try:
num = int(last.member_number.replace('NB-', ''))
return f"NB-{num + 1:04d}"
except ValueError:
pass
# Count existing companies as starting point
count = db.query(Company).filter(Company.status == 'active').count()
return f"NB-{count + 1:04d}"
# ============================================================
# MEMBERSHIP APPLICATIONS
# ============================================================
@bp.route('/membership')
@login_required
def admin_membership():
"""Admin panel for membership applications."""
if not current_user.is_admin:
flash('Brak uprawnień do tej strony.', 'error')
return redirect(url_for('index'))
db = SessionLocal()
try:
# Get filter parameters
status_filter = request.args.get('status', 'all')
search_query = request.args.get('q', '').strip()
# Base query
query = db.query(MembershipApplication)
# Apply filters
if status_filter and status_filter != 'all':
query = query.filter(MembershipApplication.status == status_filter)
if search_query:
search_pattern = f'%{search_query}%'
query = query.filter(
(MembershipApplication.company_name.ilike(search_pattern)) |
(MembershipApplication.nip.ilike(search_pattern)) |
(MembershipApplication.email.ilike(search_pattern))
)
# Order by submitted date (newest first)
applications = query.order_by(
MembershipApplication.submitted_at.desc().nullslast(),
MembershipApplication.created_at.desc()
).all()
# Statistics
total = db.query(MembershipApplication).count()
submitted = db.query(MembershipApplication).filter(
MembershipApplication.status == 'submitted'
).count()
under_review = db.query(MembershipApplication).filter(
MembershipApplication.status == 'under_review'
).count()
approved = db.query(MembershipApplication).filter(
MembershipApplication.status == 'approved'
).count()
rejected = db.query(MembershipApplication).filter(
MembershipApplication.status == 'rejected'
).count()
logger.info(f"Admin {current_user.email} accessed membership applications panel")
return render_template(
'admin/membership.html',
applications=applications,
total=total,
submitted=submitted,
under_review=under_review,
approved=approved,
rejected=rejected,
current_status=status_filter,
search_query=search_query,
status_choices=MembershipApplication.STATUS_CHOICES
)
finally:
db.close()
@bp.route('/membership/<int:app_id>')
@login_required
def admin_membership_detail(app_id):
"""View membership application details."""
if not current_user.is_admin:
flash('Brak uprawnień do tej strony.', 'error')
return redirect(url_for('index'))
db = SessionLocal()
try:
application = db.query(MembershipApplication).get(app_id)
if not application:
flash('Nie znaleziono deklaracji.', 'error')
return redirect(url_for('admin.admin_membership'))
# Get categories for company creation
categories = db.query(Category).order_by(Category.name).all()
return render_template(
'admin/membership_detail.html',
application=application,
categories=categories,
section_choices=MembershipApplication.SECTION_CHOICES,
business_type_choices=MembershipApplication.BUSINESS_TYPE_CHOICES
)
finally:
db.close()
@bp.route('/membership/<int:app_id>/approve', methods=['POST'])
@login_required
def admin_membership_approve(app_id):
"""Approve membership application and create company."""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
db = SessionLocal()
try:
application = db.query(MembershipApplication).get(app_id)
if not application:
return jsonify({'success': False, 'error': 'Nie znaleziono deklaracji'}), 404
if application.status not in ['submitted', 'under_review']:
return jsonify({
'success': False,
'error': f'Nie można zatwierdzić deklaracji w statusie: {application.status}'
}), 400
data = request.get_json() or {}
# Check if company with this NIP already exists
existing = db.query(Company).filter(Company.nip == application.nip).first()
if existing:
return jsonify({
'success': False,
'error': f'Firma z NIP {application.nip} już istnieje w katalogu'
}), 400
# Generate unique slug
slug = _generate_slug(application.company_name)
base_slug = slug
counter = 1
while db.query(Company).filter(Company.slug == slug).first():
slug = f"{base_slug}-{counter}"
counter += 1
# Create company
company = Company(
name=application.company_name,
slug=slug,
nip=application.nip,
regon=application.regon,
krs=application.krs_number,
website=application.website,
email=application.email,
phone=application.phone,
address_postal_code=application.address_postal_code,
address_city=application.address_city,
address_street=application.address_street,
address_number=application.address_number,
description_short=application.description[:500] if application.description else None,
description_full=application.description,
founded_date=application.founded_date,
employee_count=application.employee_count,
show_employee_count=application.show_employee_count,
category_id=data.get('category_id'),
status='active',
is_norda_member=True,
data_quality='enhanced'
)
db.add(company)
db.flush() # Get company ID
# Update application
application.status = 'approved'
application.reviewed_at = datetime.now()
application.reviewed_by_id = current_user.id
application.review_comment = data.get('comment', '')
application.company_id = company.id
application.member_number = _generate_member_number(db)
# Assign user to company
user = db.query(User).get(application.user_id)
if user:
user.company_id = company.id
user.is_norda_member = True
db.commit()
logger.info(
f"Membership application {app_id} approved by {current_user.email}. "
f"Company {company.id} created. Member number: {application.member_number}"
)
flash(f'Deklaracja zatwierdzona. Utworzono firmę: {company.name}', 'success')
return jsonify({
'success': True,
'company_id': company.id,
'member_number': application.member_number
})
except Exception as e:
db.rollback()
logger.error(f"Error approving application {app_id}: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@bp.route('/membership/<int:app_id>/reject', methods=['POST'])
@login_required
def admin_membership_reject(app_id):
"""Reject membership application."""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
db = SessionLocal()
try:
application = db.query(MembershipApplication).get(app_id)
if not application:
return jsonify({'success': False, 'error': 'Nie znaleziono deklaracji'}), 404
if application.status not in ['submitted', 'under_review']:
return jsonify({
'success': False,
'error': f'Nie można odrzucić deklaracji w statusie: {application.status}'
}), 400
data = request.get_json() or {}
comment = data.get('comment', '').strip()
if not comment:
return jsonify({
'success': False,
'error': 'Podaj powód odrzucenia'
}), 400
application.status = 'rejected'
application.reviewed_at = datetime.now()
application.reviewed_by_id = current_user.id
application.review_comment = comment
db.commit()
logger.info(f"Membership application {app_id} rejected by {current_user.email}: {comment}")
return jsonify({'success': True})
except Exception as e:
db.rollback()
logger.error(f"Error rejecting application {app_id}: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@bp.route('/membership/<int:app_id>/request-changes', methods=['POST'])
@login_required
def admin_membership_request_changes(app_id):
"""Request changes to membership application."""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
db = SessionLocal()
try:
application = db.query(MembershipApplication).get(app_id)
if not application:
return jsonify({'success': False, 'error': 'Nie znaleziono deklaracji'}), 404
if application.status not in ['submitted', 'under_review']:
return jsonify({
'success': False,
'error': f'Nie można poprosić o poprawki dla deklaracji w statusie: {application.status}'
}), 400
data = request.get_json() or {}
comment = data.get('comment', '').strip()
if not comment:
return jsonify({
'success': False,
'error': 'Podaj co wymaga poprawienia'
}), 400
application.status = 'changes_requested'
application.reviewed_at = datetime.now()
application.reviewed_by_id = current_user.id
application.review_comment = comment
db.commit()
logger.info(f"Changes requested for application {app_id} by {current_user.email}: {comment}")
return jsonify({'success': True})
except Exception as e:
db.rollback()
logger.error(f"Error requesting changes for application {app_id}: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@bp.route('/membership/<int:app_id>/start-review', methods=['POST'])
@login_required
def admin_membership_start_review(app_id):
"""Mark application as under review."""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
db = SessionLocal()
try:
application = db.query(MembershipApplication).get(app_id)
if not application:
return jsonify({'success': False, 'error': 'Nie znaleziono deklaracji'}), 404
if application.status != 'submitted':
return jsonify({
'success': False,
'error': 'Można rozpocząć rozpatrywanie tylko dla wysłanych deklaracji'
}), 400
application.status = 'under_review'
application.reviewed_by_id = current_user.id
db.commit()
return jsonify({'success': True})
except Exception as e:
db.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
# ============================================================
# COMPANY DATA REQUESTS
# ============================================================
@bp.route('/company-requests')
@login_required
def admin_company_requests():
"""Admin panel for company data requests."""
if not current_user.is_admin:
flash('Brak uprawnień do tej strony.', 'error')
return redirect(url_for('index'))
db = SessionLocal()
try:
status_filter = request.args.get('status', 'pending')
query = db.query(CompanyDataRequest)
if status_filter and status_filter != 'all':
query = query.filter(CompanyDataRequest.status == status_filter)
requests_list = query.order_by(CompanyDataRequest.created_at.desc()).all()
# Statistics
pending = db.query(CompanyDataRequest).filter(
CompanyDataRequest.status == 'pending'
).count()
approved = db.query(CompanyDataRequest).filter(
CompanyDataRequest.status == 'approved'
).count()
rejected = db.query(CompanyDataRequest).filter(
CompanyDataRequest.status == 'rejected'
).count()
return render_template(
'admin/company_requests.html',
requests=requests_list,
pending=pending,
approved=approved,
rejected=rejected,
current_status=status_filter
)
finally:
db.close()
@bp.route('/company-requests/<int:req_id>/approve', methods=['POST'])
@login_required
def admin_company_request_approve(req_id):
"""Approve company data request and update company."""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
db = SessionLocal()
try:
data_request = db.query(CompanyDataRequest).get(req_id)
if not data_request:
return jsonify({'success': False, 'error': 'Nie znaleziono zgłoszenia'}), 404
if data_request.status != 'pending':
return jsonify({
'success': False,
'error': f'Zgłoszenie już rozpatrzone: {data_request.status}'
}), 400
company = db.query(Company).get(data_request.company_id)
if not company:
return jsonify({'success': False, 'error': 'Firma nie istnieje'}), 404
fetched = data_request.fetched_data or {}
applied_fields = []
# Update company with fetched data
if fetched.get('name') and not company.name:
company.name = fetched['name']
applied_fields.append('name')
if data_request.nip and not company.nip:
company.nip = data_request.nip
applied_fields.append('nip')
if fetched.get('regon') and not company.regon:
company.regon = fetched['regon']
applied_fields.append('regon')
if fetched.get('krs') and not company.krs:
company.krs = fetched['krs']
applied_fields.append('krs')
if fetched.get('address_postal_code') and not company.address_postal_code:
company.address_postal_code = fetched['address_postal_code']
applied_fields.append('address_postal_code')
if fetched.get('address_city') and not company.address_city:
company.address_city = fetched['address_city']
applied_fields.append('address_city')
if fetched.get('address_street') and not company.address_street:
company.address_street = fetched['address_street']
applied_fields.append('address_street')
if fetched.get('address_number') and not company.address_number:
company.address_number = fetched['address_number']
applied_fields.append('address_number')
if fetched.get('website') and not company.website:
company.website = fetched['website']
applied_fields.append('website')
if fetched.get('email') and not company.email:
company.email = fetched['email']
applied_fields.append('email')
# Update request
data_request.status = 'approved'
data_request.reviewed_at = datetime.now()
data_request.reviewed_by_id = current_user.id
data_request.applied_fields = applied_fields
db.commit()
logger.info(
f"Company data request {req_id} approved by {current_user.email}. "
f"Updated fields: {applied_fields}"
)
return jsonify({
'success': True,
'applied_fields': applied_fields
})
except Exception as e:
db.rollback()
logger.error(f"Error approving data request {req_id}: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@bp.route('/company-requests/<int:req_id>/reject', methods=['POST'])
@login_required
def admin_company_request_reject(req_id):
"""Reject company data request."""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
db = SessionLocal()
try:
data_request = db.query(CompanyDataRequest).get(req_id)
if not data_request:
return jsonify({'success': False, 'error': 'Nie znaleziono zgłoszenia'}), 404
if data_request.status != 'pending':
return jsonify({
'success': False,
'error': f'Zgłoszenie już rozpatrzone: {data_request.status}'
}), 400
data = request.get_json() or {}
comment = data.get('comment', '').strip()
data_request.status = 'rejected'
data_request.reviewed_at = datetime.now()
data_request.reviewed_by_id = current_user.id
data_request.review_comment = comment
db.commit()
logger.info(f"Company data request {req_id} rejected by {current_user.email}")
return jsonify({'success': True})
except Exception as e:
db.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()

View File

@ -16,3 +16,4 @@ from . import routes_seo_audit # noqa: E402, F401
from . import routes_gbp_audit # noqa: E402, F401
from . import routes_social_audit # noqa: E402, F401
from . import routes_company # noqa: E402, F401
from . import routes_membership # noqa: E402, F401

View File

@ -0,0 +1,558 @@
"""
Membership API Routes
======================
API endpoints for membership application system:
- NIP lookup in KRS/CEIDG registries
- Draft save/load
- Application submission
- Company data requests
"""
import logging
from datetime import datetime
from flask import jsonify, request
from flask_login import current_user, login_required
from database import (
SessionLocal, MembershipApplication, CompanyDataRequest, Company
)
from . import bp
logger = logging.getLogger(__name__)
# ============================================================
# NIP LOOKUP (KRS/CEIDG)
# ============================================================
@bp.route('/membership/lookup-nip', methods=['POST'])
@login_required
def lookup_nip():
"""
Lookup company data by NIP in KRS and CEIDG registries.
Returns company info for auto-fill in application form.
"""
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'Brak danych'}), 400
nip = data.get('nip', '').strip().replace('-', '').replace(' ', '')
if not nip or len(nip) != 10:
return jsonify({'success': False, 'error': 'NIP musi mieć 10 cyfr'}), 400
# Check if NIP is numeric
if not nip.isdigit():
return jsonify({'success': False, 'error': 'NIP może zawierać tylko cyfry'}), 400
# Try KRS first
krs_result = _lookup_krs(nip)
if krs_result:
return jsonify({
'success': True,
'source': 'KRS',
'data': krs_result
})
# Try CEIDG
ceidg_result = _lookup_ceidg(nip)
if ceidg_result:
return jsonify({
'success': True,
'source': 'CEIDG',
'data': ceidg_result
})
# Not found in any registry
return jsonify({
'success': True,
'source': 'manual',
'data': None,
'message': 'Firma nie została znaleziona w KRS ani CEIDG. Wypełnij dane ręcznie.'
})
def _lookup_krs(nip):
"""Lookup in KRS registry."""
try:
from krs_api_service import krs_api_service
result = krs_api_service.search_by_nip(nip)
if result:
# Parse address components
address = result.get('adres', {})
if isinstance(address, str):
address = {'full': address}
return {
'name': result.get('nazwa'),
'krs': result.get('krs'),
'regon': result.get('regon'),
'address_postal_code': address.get('kodPocztowy', ''),
'address_city': address.get('miejscowosc', ''),
'address_street': address.get('ulica', ''),
'address_number': address.get('nrDomu', ''),
'founded_date': result.get('data_rejestracji'),
'business_type': _detect_business_type_from_krs(result),
'email': result.get('email'),
'website': result.get('strona_www'),
'raw': result
}
except ImportError:
logger.warning("KRS API service not available")
except Exception as e:
logger.error(f"KRS lookup error for NIP {nip}: {e}")
return None
def _lookup_ceidg(nip):
"""Lookup in CEIDG registry."""
try:
from ceidg_api_service import fetch_ceidg_by_nip
result = fetch_ceidg_by_nip(nip)
if result:
address = result.get('adresDzialalnosci', {})
return {
'name': result.get('firma'),
'regon': result.get('regon'),
'address_postal_code': address.get('kodPocztowy', ''),
'address_city': address.get('miejscowosc', ''),
'address_street': address.get('ulica', ''),
'address_number': address.get('budynek', ''),
'founded_date': result.get('dataRozpoczeciaDzialalnosci'),
'business_type': 'jdg',
'email': result.get('email'),
'website': result.get('stronaWWW'),
'raw': result
}
except ImportError:
logger.warning("CEIDG API service not available")
except Exception as e:
logger.error(f"CEIDG lookup error for NIP {nip}: {e}")
return None
def _detect_business_type_from_krs(data):
"""Detect business type from KRS data."""
legal_form = data.get('forma_prawna', '').lower()
if 'akcyjna' in legal_form and 'komandytowo' in legal_form:
return 'spolka_komandytowo_akcyjna'
elif 'akcyjna' in legal_form:
return 'spolka_akcyjna'
elif 'z ograniczoną odpowiedzialnością' in legal_form or 'z o.o.' in legal_form:
if 'komandytowa' in legal_form:
return 'sp_z_oo_komandytowa'
return 'sp_z_oo'
elif 'komandytowa' in legal_form:
return 'spolka_komandytowa'
elif 'partnerska' in legal_form:
return 'spolka_partnerska'
elif 'jawna' in legal_form:
return 'spolka_jawna'
elif 'cywilna' in legal_form:
return 'spolka_cywilna'
return 'inna'
# ============================================================
# MEMBERSHIP APPLICATION DRAFT
# ============================================================
@bp.route('/membership/draft', methods=['GET'])
@login_required
def get_draft():
"""Get current draft application."""
db = SessionLocal()
try:
application = db.query(MembershipApplication).filter(
MembershipApplication.user_id == current_user.id,
MembershipApplication.status.in_(['draft', 'changes_requested'])
).first()
if not application:
return jsonify({'success': True, 'draft': None})
return jsonify({
'success': True,
'draft': _serialize_application(application)
})
finally:
db.close()
@bp.route('/membership/draft', methods=['POST'])
@login_required
def save_draft():
"""Save draft application data."""
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'Brak danych'}), 400
db = SessionLocal()
try:
# Get or create draft
application = db.query(MembershipApplication).filter(
MembershipApplication.user_id == current_user.id,
MembershipApplication.status.in_(['draft', 'changes_requested'])
).first()
if not application:
application = MembershipApplication(
user_id=current_user.id,
company_name=data.get('company_name', ''),
nip=data.get('nip', ''),
email=data.get('email', current_user.email or ''),
status='draft'
)
db.add(application)
# Update fields
_update_application_from_data(application, data)
application.updated_at = datetime.now()
db.commit()
return jsonify({
'success': True,
'message': 'Zapisano',
'application_id': application.id
})
except Exception as e:
db.rollback()
logger.error(f"Error saving draft: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
def _update_application_from_data(app, data):
"""Update application fields from request data."""
# Step 1 fields
if 'company_name' in data:
app.company_name = data['company_name'].strip()
if 'nip' in data:
app.nip = data['nip'].strip().replace('-', '').replace(' ', '')
if 'address_postal_code' in data:
app.address_postal_code = data['address_postal_code'].strip()
if 'address_city' in data:
app.address_city = data['address_city'].strip()
if 'address_street' in data:
app.address_street = data['address_street'].strip()
if 'address_number' in data:
app.address_number = data['address_number'].strip()
if 'delegate_1' in data:
app.delegate_1 = data['delegate_1'].strip()
if 'delegate_2' in data:
app.delegate_2 = data['delegate_2'].strip()
if 'delegate_3' in data:
app.delegate_3 = data['delegate_3'].strip()
if 'krs_number' in data:
app.krs_number = data['krs_number'].strip()
if 'regon' in data:
app.regon = data['regon'].strip()
if 'registry_source' in data:
app.registry_source = data['registry_source']
if 'registry_data' in data:
app.registry_data = data['registry_data']
# Step 2 fields
if 'website' in data:
app.website = data['website'].strip()
if 'email' in data:
app.email = data['email'].strip()
if 'phone' in data:
app.phone = data['phone'].strip()
if 'short_name' in data:
app.short_name = data['short_name'].strip()
if 'description' in data:
app.description = data['description'].strip()
if 'founded_date' in data and data['founded_date']:
try:
app.founded_date = datetime.strptime(data['founded_date'], '%Y-%m-%d').date()
except ValueError:
pass
if 'employee_count' in data and data['employee_count']:
try:
app.employee_count = int(data['employee_count'])
except ValueError:
pass
if 'show_employee_count' in data:
app.show_employee_count = bool(data['show_employee_count'])
if 'annual_revenue' in data:
app.annual_revenue = data['annual_revenue'].strip() if data['annual_revenue'] else None
if 'related_companies' in data:
app.related_companies = data['related_companies'] if data['related_companies'] else None
# Step 3 fields
if 'sections' in data:
app.sections = data['sections'] if data['sections'] else []
if 'sections_other' in data:
app.sections_other = data['sections_other'].strip() if data['sections_other'] else None
if 'business_type' in data:
app.business_type = data['business_type']
if 'business_type_other' in data:
app.business_type_other = data['business_type_other'].strip() if data['business_type_other'] else None
if 'consent_email' in data:
app.consent_email = bool(data['consent_email'])
if 'consent_email_address' in data:
app.consent_email_address = data['consent_email_address'].strip() if data['consent_email_address'] else None
if 'consent_sms' in data:
app.consent_sms = bool(data['consent_sms'])
if 'consent_sms_phone' in data:
app.consent_sms_phone = data['consent_sms_phone'].strip() if data['consent_sms_phone'] else None
def _serialize_application(app):
"""Serialize application to dict."""
return {
'id': app.id,
'status': app.status,
'company_name': app.company_name,
'nip': app.nip,
'address_postal_code': app.address_postal_code,
'address_city': app.address_city,
'address_street': app.address_street,
'address_number': app.address_number,
'delegate_1': app.delegate_1,
'delegate_2': app.delegate_2,
'delegate_3': app.delegate_3,
'krs_number': app.krs_number,
'regon': app.regon,
'registry_source': app.registry_source,
'website': app.website,
'email': app.email,
'phone': app.phone,
'short_name': app.short_name,
'description': app.description,
'founded_date': app.founded_date.isoformat() if app.founded_date else None,
'employee_count': app.employee_count,
'show_employee_count': app.show_employee_count,
'annual_revenue': app.annual_revenue,
'related_companies': app.related_companies,
'sections': app.sections,
'sections_other': app.sections_other,
'business_type': app.business_type,
'business_type_other': app.business_type_other,
'consent_email': app.consent_email,
'consent_email_address': app.consent_email_address,
'consent_sms': app.consent_sms,
'consent_sms_phone': app.consent_sms_phone,
'declaration_accepted': app.declaration_accepted,
'created_at': app.created_at.isoformat() if app.created_at else None,
'updated_at': app.updated_at.isoformat() if app.updated_at else None,
'submitted_at': app.submitted_at.isoformat() if app.submitted_at else None
}
# ============================================================
# MEMBERSHIP APPLICATION SUBMIT
# ============================================================
@bp.route('/membership/submit', methods=['POST'])
@login_required
def submit_application():
"""Submit draft application for review."""
db = SessionLocal()
try:
application = db.query(MembershipApplication).filter(
MembershipApplication.user_id == current_user.id,
MembershipApplication.status.in_(['draft', 'changes_requested'])
).first()
if not application:
return jsonify({'success': False, 'error': 'Nie znaleziono deklaracji'}), 404
# Validate
errors = _validate_application(application)
if errors:
return jsonify({'success': False, 'errors': errors}), 400
# Submit
application.status = 'submitted'
application.submitted_at = datetime.now()
application.declaration_accepted_at = datetime.now()
application.declaration_ip_address = request.remote_addr
db.commit()
logger.info(f"Membership application submitted: user={current_user.id}, app={application.id}")
return jsonify({
'success': True,
'message': 'Deklaracja została wysłana do rozpatrzenia',
'application_id': application.id
})
except Exception as e:
db.rollback()
logger.error(f"Error submitting application: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
def _validate_application(app):
"""Validate application before submission."""
errors = []
if not app.company_name:
errors.append('Nazwa firmy jest wymagana')
if not app.nip or len(app.nip) != 10:
errors.append('NIP jest wymagany (10 cyfr)')
if not app.email:
errors.append('Email jest wymagany')
if not app.delegate_1:
errors.append('Przynajmniej jeden delegat jest wymagany')
if not app.sections:
errors.append('Wybierz przynajmniej jedną sekcję tematyczną')
if not app.consent_email:
errors.append('Zgoda na kontakt email jest wymagana')
if not app.declaration_accepted:
errors.append('Musisz zaakceptować oświadczenie')
return errors
# ============================================================
# MEMBERSHIP STATUS
# ============================================================
@bp.route('/membership/status', methods=['GET'])
@login_required
def get_status():
"""Get application status for current user."""
db = SessionLocal()
try:
applications = db.query(MembershipApplication).filter(
MembershipApplication.user_id == current_user.id
).order_by(MembershipApplication.created_at.desc()).all()
return jsonify({
'success': True,
'applications': [
{
'id': app.id,
'status': app.status,
'status_label': app.status_label,
'company_name': app.company_name,
'created_at': app.created_at.isoformat() if app.created_at else None,
'submitted_at': app.submitted_at.isoformat() if app.submitted_at else None,
'reviewed_at': app.reviewed_at.isoformat() if app.reviewed_at else None,
'review_comment': app.review_comment
}
for app in applications
]
})
finally:
db.close()
# ============================================================
# COMPANY DATA REQUEST
# ============================================================
@bp.route('/company/data-request', methods=['POST'])
@login_required
def create_data_request():
"""Create a company data update request."""
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'Brak danych'}), 400
if not current_user.company_id:
return jsonify({'success': False, 'error': 'Nie masz przypisanej firmy'}), 400
nip = data.get('nip', '').strip().replace('-', '').replace(' ', '')
if not nip or len(nip) != 10:
return jsonify({'success': False, 'error': 'NIP musi mieć 10 cyfr'}), 400
db = SessionLocal()
try:
# Check for existing pending request
existing = db.query(CompanyDataRequest).filter(
CompanyDataRequest.user_id == current_user.id,
CompanyDataRequest.company_id == current_user.company_id,
CompanyDataRequest.status == 'pending'
).first()
if existing:
return jsonify({'success': False, 'error': 'Masz już oczekujące zgłoszenie'}), 400
# Fetch registry data
registry_data = None
registry_source = None
krs_result = _lookup_krs(nip)
if krs_result:
registry_data = krs_result
registry_source = 'KRS'
else:
ceidg_result = _lookup_ceidg(nip)
if ceidg_result:
registry_data = ceidg_result
registry_source = 'CEIDG'
# Create request
data_request = CompanyDataRequest(
request_type=data.get('request_type', 'update_data'),
user_id=current_user.id,
company_id=current_user.company_id,
nip=nip,
registry_source=registry_source,
fetched_data=registry_data,
user_note=data.get('user_note', '').strip() if data.get('user_note') else None
)
db.add(data_request)
db.commit()
logger.info(f"Company data request created: user={current_user.id}, company={current_user.company_id}")
return jsonify({
'success': True,
'message': 'Zgłoszenie zostało wysłane do rozpatrzenia',
'request_id': data_request.id,
'registry_data': registry_data
})
except Exception as e:
db.rollback()
logger.error(f"Error creating data request: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@bp.route('/company/data-request/status', methods=['GET'])
@login_required
def get_data_request_status():
"""Get company data request status."""
if not current_user.company_id:
return jsonify({'success': True, 'requests': []})
db = SessionLocal()
try:
requests = db.query(CompanyDataRequest).filter(
CompanyDataRequest.user_id == current_user.id
).order_by(CompanyDataRequest.created_at.desc()).all()
return jsonify({
'success': True,
'requests': [
{
'id': req.id,
'request_type': req.request_type,
'request_type_label': req.request_type_label,
'status': req.status,
'status_label': req.status_label,
'nip': req.nip,
'registry_source': req.registry_source,
'created_at': req.created_at.isoformat() if req.created_at else None,
'reviewed_at': req.reviewed_at.isoformat() if req.reviewed_at else None,
'review_comment': req.review_comment
}
for req in requests
]
})
finally:
db.close()

View File

@ -0,0 +1,16 @@
"""
Membership Blueprint
=====================
Membership application system for joining NORDA Business Association.
Includes:
- Online application form (wizard)
- Company data update requests
- Status tracking
"""
from flask import Blueprint
bp = Blueprint('membership', __name__, url_prefix='/membership')
from . import routes # noqa: E402, F401

View File

@ -0,0 +1,349 @@
"""
Membership Routes
==================
Routes for membership application wizard and status tracking.
"""
import logging
from datetime import datetime
from flask import render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from . import bp
from database import SessionLocal, MembershipApplication, CompanyDataRequest, Company
logger = logging.getLogger(__name__)
# ============================================================
# APPLICATION WIZARD
# ============================================================
@bp.route('/apply')
@login_required
def apply():
"""
Membership application wizard - multi-step form.
"""
db = SessionLocal()
try:
# Check if user already has a company
if current_user.company_id:
flash('Masz już przypisaną firmę. Nie możesz składać nowej deklaracji.', 'warning')
return redirect(url_for('index'))
# Check for existing draft or pending application
existing = db.query(MembershipApplication).filter(
MembershipApplication.user_id == current_user.id,
MembershipApplication.status.in_(['draft', 'submitted', 'under_review', 'changes_requested'])
).first()
if existing:
# Redirect to continue existing application
return redirect(url_for('membership.apply_step', step=1))
# Create new draft
application = MembershipApplication(
user_id=current_user.id,
company_name='',
nip='',
email=current_user.email or '',
status='draft'
)
db.add(application)
db.commit()
return redirect(url_for('membership.apply_step', step=1))
finally:
db.close()
@bp.route('/apply/step/<int:step>', methods=['GET', 'POST'])
@login_required
def apply_step(step):
"""
Handle specific step of the application wizard.
Step 1: Company data (from registry)
Step 2: Additional info
Step 3: Sections and declaration
"""
if step not in [1, 2, 3]:
return redirect(url_for('membership.apply_step', step=1))
db = SessionLocal()
try:
# Get current application
application = db.query(MembershipApplication).filter(
MembershipApplication.user_id == current_user.id,
MembershipApplication.status.in_(['draft', 'changes_requested'])
).first()
if not application:
flash('Nie znaleziono aktywnej deklaracji.', 'error')
return redirect(url_for('membership.apply'))
if request.method == 'POST':
action = request.form.get('action', 'save')
if step == 1:
# Save step 1 data
application.company_name = request.form.get('company_name', '').strip()
application.nip = request.form.get('nip', '').strip().replace('-', '').replace(' ', '')
application.address_postal_code = request.form.get('address_postal_code', '').strip()
application.address_city = request.form.get('address_city', '').strip()
application.address_street = request.form.get('address_street', '').strip()
application.address_number = request.form.get('address_number', '').strip()
application.delegate_1 = request.form.get('delegate_1', '').strip()
application.delegate_2 = request.form.get('delegate_2', '').strip()
application.delegate_3 = request.form.get('delegate_3', '').strip()
application.krs_number = request.form.get('krs_number', '').strip()
application.regon = request.form.get('regon', '').strip()
application.registry_source = request.form.get('registry_source', 'manual')
elif step == 2:
# Save step 2 data
application.website = request.form.get('website', '').strip()
application.email = request.form.get('email', '').strip()
application.phone = request.form.get('phone', '').strip()
application.short_name = request.form.get('short_name', '').strip()
application.description = request.form.get('description', '').strip()
founded_date = request.form.get('founded_date', '').strip()
if founded_date:
try:
application.founded_date = datetime.strptime(founded_date, '%Y-%m-%d').date()
except ValueError:
pass
employee_count = request.form.get('employee_count', '').strip()
if employee_count:
try:
application.employee_count = int(employee_count)
except ValueError:
pass
application.show_employee_count = request.form.get('show_employee_count') == 'on'
application.annual_revenue = request.form.get('annual_revenue', '').strip()
# Related companies (JSON array)
related = []
for i in range(1, 6):
company = request.form.get(f'related_company_{i}', '').strip()
if company:
related.append(company)
application.related_companies = related if related else None
elif step == 3:
# Save step 3 data
sections = request.form.getlist('sections')
application.sections = sections if sections else []
application.sections_other = request.form.get('sections_other', '').strip()
application.business_type = request.form.get('business_type', 'sp_z_oo')
application.business_type_other = request.form.get('business_type_other', '').strip()
# GDPR consents
application.consent_email = request.form.get('consent_email') == 'on'
application.consent_email_address = request.form.get('consent_email_address', '').strip()
application.consent_sms = request.form.get('consent_sms') == 'on'
application.consent_sms_phone = request.form.get('consent_sms_phone', '').strip()
# Declaration
application.declaration_accepted = request.form.get('declaration_accepted') == 'on'
if application.declaration_accepted:
application.declaration_accepted_at = datetime.now()
application.declaration_ip_address = request.remote_addr
application.updated_at = datetime.now()
db.commit()
if action == 'next':
if step < 3:
return redirect(url_for('membership.apply_step', step=step + 1))
else:
# Final validation before submit
errors = validate_application(application)
if errors:
for error in errors:
flash(error, 'error')
return redirect(url_for('membership.apply_step', step=3))
# Submit application
application.status = 'submitted'
application.submitted_at = datetime.now()
db.commit()
flash('Deklaracja została wysłana do rozpatrzenia!', 'success')
logger.info(f"Membership application submitted: user={current_user.id}, app={application.id}")
return redirect(url_for('membership.status'))
elif action == 'prev' and step > 1:
return redirect(url_for('membership.apply_step', step=step - 1))
else:
flash('Zapisano zmiany.', 'success')
return redirect(url_for('membership.apply_step', step=step))
# GET - render step
return render_template(
'membership/apply.html',
application=application,
step=step,
section_choices=MembershipApplication.SECTION_CHOICES,
business_type_choices=MembershipApplication.BUSINESS_TYPE_CHOICES
)
finally:
db.close()
def validate_application(app):
"""Validate application before submission."""
errors = []
if not app.company_name:
errors.append('Nazwa firmy jest wymagana.')
if not app.nip or len(app.nip) != 10:
errors.append('NIP jest wymagany (10 cyfr).')
if not app.email:
errors.append('Email jest wymagany.')
if not app.delegate_1:
errors.append('Przynajmniej jeden delegat jest wymagany.')
if not app.sections:
errors.append('Wybierz przynajmniej jedną sekcję tematyczną.')
if not app.consent_email:
errors.append('Zgoda na kontakt email jest wymagana.')
if not app.declaration_accepted:
errors.append('Musisz zaakceptować oświadczenie.')
return errors
# ============================================================
# APPLICATION STATUS
# ============================================================
@bp.route('/status')
@login_required
def status():
"""
Show current application status.
"""
db = SessionLocal()
try:
applications = db.query(MembershipApplication).filter(
MembershipApplication.user_id == current_user.id
).order_by(MembershipApplication.created_at.desc()).all()
return render_template('membership/status.html', applications=applications)
finally:
db.close()
# ============================================================
# COMPANY DATA REQUEST (Modal-based)
# ============================================================
@bp.route('/data-request', methods=['GET', 'POST'])
@login_required
def data_request():
"""
Handle company data update request.
For users with a company but missing NIP/data.
"""
if not current_user.company_id:
flash('Nie masz przypisanej firmy.', 'warning')
return redirect(url_for('membership.apply'))
db = SessionLocal()
try:
company = db.query(Company).get(current_user.company_id)
if not company:
flash('Firma nie została znaleziona.', 'error')
return redirect(url_for('index'))
# Check for pending request
pending = db.query(CompanyDataRequest).filter(
CompanyDataRequest.user_id == current_user.id,
CompanyDataRequest.company_id == company.id,
CompanyDataRequest.status == 'pending'
).first()
if request.method == 'POST':
if pending:
flash('Masz już oczekujące zgłoszenie.', 'warning')
return redirect(url_for('membership.data_request'))
nip = request.form.get('nip', '').strip().replace('-', '').replace(' ', '')
if not nip or len(nip) != 10:
flash('NIP musi mieć 10 cyfr.', 'error')
return redirect(url_for('membership.data_request'))
# Create request
data_request = CompanyDataRequest(
request_type='update_data',
user_id=current_user.id,
company_id=company.id,
nip=nip,
user_note=request.form.get('user_note', '').strip()
)
# Try to fetch registry data
fetched = fetch_registry_data(nip)
if fetched:
data_request.registry_source = fetched.get('source')
data_request.fetched_data = fetched
db.add(data_request)
db.commit()
flash('Zgłoszenie zostało wysłane do rozpatrzenia.', 'success')
logger.info(f"Company data request created: user={current_user.id}, company={company.id}")
return redirect(url_for('index'))
return render_template(
'membership/data_request.html',
company=company,
pending_request=pending
)
finally:
db.close()
def fetch_registry_data(nip):
"""
Fetch company data from KRS/CEIDG registries.
Returns dict with source and data, or None if not found.
"""
try:
# Try KRS first
from krs_api_service import krs_api_service
krs_data = krs_api_service.search_by_nip(nip)
if krs_data:
return {
'source': 'KRS',
'name': krs_data.get('nazwa'),
'krs': krs_data.get('krs'),
'regon': krs_data.get('regon'),
'address': krs_data.get('adres'),
'founding_date': krs_data.get('data_rejestracji'),
'raw': krs_data
}
except Exception as e:
logger.warning(f"KRS lookup failed for NIP {nip}: {e}")
try:
# Try CEIDG
from ceidg_api_service import fetch_ceidg_by_nip
ceidg_data = fetch_ceidg_by_nip(nip)
if ceidg_data:
return {
'source': 'CEIDG',
'name': ceidg_data.get('firma'),
'regon': ceidg_data.get('regon'),
'address': ceidg_data.get('adres'),
'raw': ceidg_data
}
except Exception as e:
logger.warning(f"CEIDG lookup failed for NIP {nip}: {e}")
return None

View File

@ -522,11 +522,36 @@ def dashboard():
AIChatConversation.user_id == current_user.id
).count()
# Check for membership application status
has_pending_application = False
has_draft_application = False
pending_application = None
try:
from database import MembershipApplication
pending_application = db.query(MembershipApplication).filter(
MembershipApplication.user_id == current_user.id,
MembershipApplication.status.in_(['submitted', 'under_review', 'changes_requested'])
).first()
has_pending_application = pending_application is not None
if not has_pending_application:
draft = db.query(MembershipApplication).filter(
MembershipApplication.user_id == current_user.id,
MembershipApplication.status == 'draft'
).first()
has_draft_application = draft is not None
except Exception:
pass # MembershipApplication table may not exist yet
return render_template(
'dashboard.html',
conversations=conversations,
total_conversations=total_conversations,
total_messages=total_messages
total_messages=total_messages,
has_pending_application=has_pending_application,
has_draft_application=has_draft_application,
pending_application=pending_application
)
finally:
db.close()

View File

@ -4194,6 +4194,232 @@ class ZOPKMilestone(Base):
source_news = relationship('ZOPKNews', backref='milestones')
# ============================================================
# MEMBERSHIP APPLICATION SYSTEM
# ============================================================
class MembershipApplication(Base):
"""
Deklaracja przystąpienia do Izby NORDA.
Pełny formularz online z workflow zatwierdzania.
"""
__tablename__ = 'membership_applications'
id = Column(Integer, primary_key=True)
# Kto złożył
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
# Status workflow
# draft, submitted, under_review, changes_requested, approved, rejected
status = Column(String(20), nullable=False, default='draft')
# Dane firmy (strona 1 - Deklaracja)
company_name = Column(String(255), nullable=False)
nip = Column(String(10), nullable=False)
address_postal_code = Column(String(6))
address_city = Column(String(100))
address_street = Column(String(200))
address_number = Column(String(50))
# Delegaci do Walnego Zgromadzenia
delegate_1 = Column(String(150))
delegate_2 = Column(String(150))
delegate_3 = Column(String(150))
# Kontakt (strona 2 - Karta Informacyjna)
website = Column(String(255))
email = Column(String(255), nullable=False)
phone = Column(String(30))
short_name = Column(String(100))
# Informacje dodatkowe
description = Column(Text)
founded_date = Column(Date)
employee_count = Column(Integer)
show_employee_count = Column(Boolean, default=False)
annual_revenue = Column(String(50))
related_companies = Column(JSONBType) # ["Firma A", "Firma B"]
# Sekcje tematyczne
sections = Column(JSONBType, nullable=False, default=[])
sections_other = Column(String(200))
# Typ działalności
business_type = Column(String(50), nullable=False, default='sp_z_oo')
business_type_other = Column(String(100))
# Zgody RODO
consent_email = Column(Boolean, nullable=False, default=False)
consent_email_address = Column(String(255))
consent_sms = Column(Boolean, default=False)
consent_sms_phone = Column(String(30))
# Oświadczenie końcowe
declaration_accepted = Column(Boolean, nullable=False, default=False)
declaration_accepted_at = Column(DateTime)
declaration_ip_address = Column(String(45))
# Dane z rejestru KRS/CEIDG
registry_source = Column(String(20)) # 'KRS', 'CEIDG', 'manual'
registry_data = Column(JSONBType)
krs_number = Column(String(10))
regon = Column(String(14))
# Workflow timestamps
created_at = Column(DateTime, nullable=False, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
submitted_at = Column(DateTime)
reviewed_at = Column(DateTime)
reviewed_by_id = Column(Integer, ForeignKey('users.id'))
review_comment = Column(Text)
# Po zatwierdzeniu
company_id = Column(Integer, ForeignKey('companies.id'))
member_number = Column(String(20))
# Relationships
user = relationship('User', foreign_keys=[user_id], backref='membership_applications')
reviewed_by = relationship('User', foreign_keys=[reviewed_by_id])
company = relationship('Company')
# Constants
STATUS_CHOICES = [
('draft', 'Szkic'),
('submitted', 'Wysłane'),
('under_review', 'W trakcie rozpatrywania'),
('changes_requested', 'Prośba o poprawki'),
('approved', 'Zatwierdzone'),
('rejected', 'Odrzucone'),
]
SECTION_CHOICES = [
('turystyka', 'Turystyka i hotelarstwo'),
('szkolnictwo', 'Szkolnictwo, praktyki, zatrudnienie'),
('budownictwo', 'Budownictwo'),
('produkcja', 'Produkcja'),
('handel', 'Handel'),
('uslugi', 'Usługi'),
('inna', 'Inna'),
]
BUSINESS_TYPE_CHOICES = [
('jdg', 'Jednoosobowa działalność gospodarcza'),
('spolka_cywilna', 'Spółka cywilna'),
('spolka_jawna', 'Spółka jawna'),
('spolka_partnerska', 'Spółka partnerska'),
('spolka_komandytowa', 'Spółka komandytowa'),
('spolka_komandytowo_akcyjna', 'Spółka komandytowo-akcyjna'),
('sp_z_oo_komandytowa', 'Sp. z o.o. komandytowa'),
('spolka_akcyjna', 'Spółka akcyjna'),
('sp_z_oo', 'Spółka z o.o.'),
('inna', 'Inna'),
]
@property
def status_label(self):
return dict(self.STATUS_CHOICES).get(self.status, self.status)
@property
def business_type_label(self):
return dict(self.BUSINESS_TYPE_CHOICES).get(self.business_type, self.business_type)
@property
def sections_labels(self):
section_map = dict(self.SECTION_CHOICES)
if not self.sections:
return []
return [section_map.get(s, s) for s in self.sections]
@property
def full_address(self):
parts = []
if self.address_postal_code:
parts.append(self.address_postal_code)
if self.address_city:
parts.append(self.address_city)
if parts:
address = ' '.join(parts)
if self.address_street:
address += f', {self.address_street}'
if self.address_number:
address += f' {self.address_number}'
return address
return None
def __repr__(self):
return f"<MembershipApplication {self.id} [{self.status}] {self.company_name}>"
class CompanyDataRequest(Base):
"""
Zgłoszenie uzupełnienia danych firmy.
Prostsza funkcjonalność dla istniejących członków bez pełnych danych.
"""
__tablename__ = 'company_data_requests'
id = Column(Integer, primary_key=True)
# Typ zgłoszenia
request_type = Column(String(30), nullable=False, default='update_data')
# Kto zgłasza
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
# Firma (jeśli istnieje)
company_id = Column(Integer, ForeignKey('companies.id', ondelete='SET NULL'))
# NIP wprowadzony przez użytkownika
nip = Column(String(10), nullable=False)
# Dane pobrane z KRS/CEIDG
registry_source = Column(String(20)) # 'KRS', 'CEIDG'
fetched_data = Column(JSONBType)
# Status
status = Column(String(20), nullable=False, default='pending')
# Workflow
created_at = Column(DateTime, nullable=False, default=datetime.now)
reviewed_at = Column(DateTime)
reviewed_by_id = Column(Integer, ForeignKey('users.id'))
review_comment = Column(Text)
# Notatka użytkownika
user_note = Column(Text)
# Które pola zostały zaktualizowane
applied_fields = Column(JSONBType)
# Relationships
user = relationship('User', foreign_keys=[user_id], backref='company_data_requests')
company = relationship('Company')
reviewed_by = relationship('User', foreign_keys=[reviewed_by_id])
# Constants
REQUEST_TYPE_CHOICES = [
('update_data', 'Uzupełnienie danych'),
('claim_company', 'Przejęcie firmy'),
]
STATUS_CHOICES = [
('pending', 'Oczekuje'),
('approved', 'Zatwierdzone'),
('rejected', 'Odrzucone'),
]
@property
def request_type_label(self):
return dict(self.REQUEST_TYPE_CHOICES).get(self.request_type, self.request_type)
@property
def status_label(self):
return dict(self.STATUS_CHOICES).get(self.status, self.status)
def __repr__(self):
return f"<CompanyDataRequest {self.id} [{self.status}] NIP:{self.nip}>"
# ============================================================
# DATABASE INITIALIZATION
# ============================================================

View File

@ -0,0 +1,183 @@
-- ============================================================
-- 042_membership_applications.sql
-- System Zgłoszeń Członkowskich (Membership Application)
-- ============================================================
-- Created: 2026-02-01
-- Description:
-- - Creates membership_applications table for new member applications
-- - Creates company_data_requests table for data update requests
-- - Full workflow support: draft -> submitted -> approved/rejected
-- ============================================================
-- ============================================================
-- 1. MEMBERSHIP APPLICATIONS TABLE
-- ============================================================
CREATE TABLE IF NOT EXISTS membership_applications (
id SERIAL PRIMARY KEY,
-- Kto złożył
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Status workflow
-- draft: zapisany szkic
-- submitted: wysłany do rozpatrzenia
-- under_review: w trakcie rozpatrywania
-- changes_requested: prośba o poprawki
-- approved: zatwierdzony
-- rejected: odrzucony
status VARCHAR(20) NOT NULL DEFAULT 'draft',
-- Dane firmy (strona 1 - Deklaracja)
company_name VARCHAR(255) NOT NULL,
nip VARCHAR(10) NOT NULL,
address_postal_code VARCHAR(6),
address_city VARCHAR(100),
address_street VARCHAR(200),
address_number VARCHAR(50),
-- Delegaci do Walnego Zgromadzenia
delegate_1 VARCHAR(150),
delegate_2 VARCHAR(150),
delegate_3 VARCHAR(150),
-- Kontakt (strona 2 - Karta Informacyjna)
website VARCHAR(255),
email VARCHAR(255) NOT NULL,
phone VARCHAR(30),
short_name VARCHAR(100),
-- Informacje dodatkowe
description TEXT,
founded_date DATE,
employee_count INTEGER,
show_employee_count BOOLEAN DEFAULT FALSE,
annual_revenue VARCHAR(50),
related_companies JSONB, -- ["Firma A", "Firma B"]
-- Sekcje i typ działalności (strona 3)
-- Sekcje: turystyka, szkolnictwo, budownictwo, produkcja, handel, uslugi, inna
sections JSONB NOT NULL DEFAULT '[]',
sections_other VARCHAR(200), -- jeśli wybrano "inna"
-- Typ działalności
-- jdg, spolka_cywilna, spolka_jawna, spolka_partnerska, spolka_komandytowa,
-- spolka_komandytowo_akcyjna, sp_z_oo_komandytowa, spolka_akcyjna, sp_z_oo, inna
business_type VARCHAR(50) NOT NULL DEFAULT 'sp_z_oo',
business_type_other VARCHAR(100),
-- Zgody RODO
consent_email BOOLEAN NOT NULL DEFAULT FALSE,
consent_email_address VARCHAR(255),
consent_sms BOOLEAN DEFAULT FALSE,
consent_sms_phone VARCHAR(30),
-- Oświadczenie końcowe
declaration_accepted BOOLEAN NOT NULL DEFAULT FALSE,
declaration_accepted_at TIMESTAMP,
declaration_ip_address VARCHAR(45), -- IPv4/IPv6
-- Dane z rejestru KRS/CEIDG
registry_source VARCHAR(20), -- 'KRS', 'CEIDG', 'manual'
registry_data JSONB, -- surowe dane z API
krs_number VARCHAR(10), -- numer KRS jeśli dotyczy
regon VARCHAR(14),
-- Workflow timestamps
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
submitted_at TIMESTAMP,
reviewed_at TIMESTAMP,
reviewed_by_id INTEGER REFERENCES users(id),
review_comment TEXT,
-- Po zatwierdzeniu
company_id INTEGER REFERENCES companies(id),
member_number VARCHAR(20) -- Nr ewidencyjny członka
);
-- Indeksy
CREATE INDEX IF NOT EXISTS idx_membership_app_status ON membership_applications(status);
CREATE INDEX IF NOT EXISTS idx_membership_app_user ON membership_applications(user_id);
CREATE INDEX IF NOT EXISTS idx_membership_app_nip ON membership_applications(nip);
CREATE INDEX IF NOT EXISTS idx_membership_app_created ON membership_applications(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_membership_app_submitted ON membership_applications(submitted_at DESC);
-- Komentarze
COMMENT ON TABLE membership_applications IS 'Deklaracje przystąpienia do Izby NORDA';
COMMENT ON COLUMN membership_applications.status IS 'Status: draft, submitted, under_review, changes_requested, approved, rejected';
COMMENT ON COLUMN membership_applications.sections IS 'JSON array sekcji tematycznych: turystyka, szkolnictwo, budownictwo, produkcja, handel, uslugi, inna';
COMMENT ON COLUMN membership_applications.registry_source IS 'Źródło danych: KRS, CEIDG lub manual (ręcznie)';
COMMENT ON COLUMN membership_applications.member_number IS 'Numer ewidencyjny członka nadawany przy zatwierdzeniu';
-- ============================================================
-- 2. COMPANY DATA REQUESTS TABLE
-- ============================================================
-- Prostsza tabela dla uzupełnienia danych istniejącej firmy
CREATE TABLE IF NOT EXISTS company_data_requests (
id SERIAL PRIMARY KEY,
-- Typ zgłoszenia
-- update_data: uzupełnienie danych istniejącej firmy (np. brak NIP)
-- claim_company: przejęcie firmy bez właściciela
request_type VARCHAR(30) NOT NULL DEFAULT 'update_data',
-- Kto zgłasza
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Firma (jeśli istnieje)
company_id INTEGER REFERENCES companies(id) ON DELETE SET NULL,
-- NIP wprowadzony przez użytkownika
nip VARCHAR(10) NOT NULL,
-- Dane pobrane z KRS/CEIDG
registry_source VARCHAR(20), -- 'KRS', 'CEIDG'
fetched_data JSONB,
-- Status
-- pending: oczekuje na rozpatrzenie
-- approved: zatwierdzono, dane zaktualizowane
-- rejected: odrzucono
status VARCHAR(20) NOT NULL DEFAULT 'pending',
-- Workflow
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
reviewed_at TIMESTAMP,
reviewed_by_id INTEGER REFERENCES users(id),
review_comment TEXT,
-- Notatka użytkownika
user_note TEXT,
-- Które pola zostały zaktualizowane (przy approve)
applied_fields JSONB
);
-- Indeksy
CREATE INDEX IF NOT EXISTS idx_data_request_status ON company_data_requests(status);
CREATE INDEX IF NOT EXISTS idx_data_request_user ON company_data_requests(user_id);
CREATE INDEX IF NOT EXISTS idx_data_request_company ON company_data_requests(company_id);
CREATE INDEX IF NOT EXISTS idx_data_request_nip ON company_data_requests(nip);
CREATE INDEX IF NOT EXISTS idx_data_request_created ON company_data_requests(created_at DESC);
-- Komentarze
COMMENT ON TABLE company_data_requests IS 'Zgłoszenia uzupełnienia danych firmy przez użytkowników';
COMMENT ON COLUMN company_data_requests.request_type IS 'Typ: update_data (uzupełnienie), claim_company (przejęcie)';
COMMENT ON COLUMN company_data_requests.fetched_data IS 'Dane pobrane z KRS/CEIDG jako JSON';
COMMENT ON COLUMN company_data_requests.applied_fields IS 'Lista pól zaktualizowanych przy zatwierdzeniu';
-- ============================================================
-- 3. GRANT PERMISSIONS
-- ============================================================
GRANT ALL ON TABLE membership_applications TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE membership_applications_id_seq TO nordabiz_app;
GRANT ALL ON TABLE company_data_requests TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE company_data_requests_id_seq TO nordabiz_app;
-- ============================================================
-- MIGRATION COMPLETE
-- ============================================================

View File

@ -0,0 +1,380 @@
{% extends "base.html" %}
{% block title %}Zgłoszenia Uzupełnienia Danych - Admin - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.admin-header {
margin-bottom: var(--spacing-xl);
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.admin-header-content h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
margin: 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-2xl);
}
.stat-card {
background: var(--surface);
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
text-align: center;
}
.stat-value {
font-size: var(--font-size-3xl);
font-weight: 700;
}
.stat-card.pending .stat-value { color: var(--warning); }
.stat-card.approved .stat-value { color: var(--success); }
.stat-card.rejected .stat-value { color: var(--error); }
.stat-label {
color: var(--text-secondary);
font-size: var(--font-size-sm);
margin-top: var(--spacing-xs);
}
.filters-row {
display: flex;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.filter-select {
padding: var(--spacing-xs) var(--spacing-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-sm);
background: var(--surface);
}
.section {
background: var(--surface);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
.section h2 {
font-size: var(--font-size-xl);
margin-bottom: var(--spacing-lg);
color: var(--text-primary);
border-bottom: 2px solid var(--border);
padding-bottom: var(--spacing-sm);
}
.request-card {
background: var(--background);
padding: var(--spacing-lg);
border-radius: var(--radius);
margin-bottom: var(--spacing-md);
border-left: 4px solid var(--warning);
}
.request-card.approved {
border-color: var(--success);
}
.request-card.rejected {
border-color: var(--error);
}
.request-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-md);
}
.request-company {
font-weight: 600;
font-size: var(--font-size-lg);
}
.request-nip {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.status-badge {
display: inline-flex;
padding: var(--spacing-xs) var(--spacing-md);
border-radius: var(--radius-full);
font-size: var(--font-size-sm);
font-weight: 500;
}
.status-pending { background: var(--warning-light); color: var(--warning); }
.status-approved { background: var(--success-light); color: var(--success); }
.status-rejected { background: var(--error-light); color: var(--error); }
.request-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.info-item {
font-size: var(--font-size-sm);
}
.info-label {
color: var(--text-secondary);
}
.registry-data {
background: var(--surface);
padding: var(--spacing-md);
border-radius: var(--radius);
margin-bottom: var(--spacing-md);
}
.registry-data h4 {
margin: 0 0 var(--spacing-sm) 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.registry-data-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--spacing-xs);
font-size: var(--font-size-sm);
}
.request-actions {
display: flex;
gap: var(--spacing-sm);
}
.btn-approve, .btn-reject {
padding: var(--spacing-xs) var(--spacing-md);
border-radius: var(--radius);
font-size: var(--font-size-sm);
cursor: pointer;
border: none;
font-weight: 500;
}
.btn-approve {
background: var(--success);
color: white;
}
.btn-reject {
background: var(--error);
color: white;
}
.btn-approve:hover, .btn-reject:hover {
opacity: 0.9;
}
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
color: var(--text-secondary);
}
.back-link {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--text-secondary);
text-decoration: none;
margin-bottom: var(--spacing-lg);
}
.back-link:hover {
color: var(--primary);
}
</style>
{% endblock %}
{% block content %}
<a href="{{ url_for('admin.admin_membership') }}" class="back-link">
← Powrót do deklaracji członkowskich
</a>
<div class="admin-header">
<div class="admin-header-content">
<h1>Zgłoszenia Uzupełnienia Danych</h1>
<p class="text-muted">Weryfikacja danych z KRS/CEIDG dla istniejących firm</p>
</div>
</div>
<div class="stats-grid">
<div class="stat-card pending">
<div class="stat-value">{{ pending }}</div>
<div class="stat-label">Oczekujące</div>
</div>
<div class="stat-card approved">
<div class="stat-value">{{ approved }}</div>
<div class="stat-label">Zatwierdzone</div>
</div>
<div class="stat-card rejected">
<div class="stat-value">{{ rejected }}</div>
<div class="stat-label">Odrzucone</div>
</div>
</div>
<form method="GET" class="filters-row">
<select name="status" class="filter-select" onchange="this.form.submit()">
<option value="pending" {% if current_status == 'pending' %}selected{% endif %}>Oczekujące</option>
<option value="approved" {% if current_status == 'approved' %}selected{% endif %}>Zatwierdzone</option>
<option value="rejected" {% if current_status == 'rejected' %}selected{% endif %}>Odrzucone</option>
<option value="all" {% if current_status == 'all' %}selected{% endif %}>Wszystkie</option>
</select>
</form>
<div class="section">
<h2>Zgłoszenia ({{ requests|length }})</h2>
{% if requests %}
{% for req in requests %}
<div class="request-card {{ req.status }}">
<div class="request-header">
<div>
<div class="request-company">
{{ req.company.name if req.company else 'Nieznana firma' }}
</div>
<div class="request-nip">NIP: {{ req.nip }}</div>
</div>
<span class="status-badge status-{{ req.status }}">
{{ req.status_label }}
</span>
</div>
<div class="request-info">
<div class="info-item">
<span class="info-label">Zgłaszający:</span>
{{ req.user.name if req.user else '-' }} ({{ req.user.email if req.user else '-' }})
</div>
<div class="info-item">
<span class="info-label">Typ:</span>
{{ req.request_type_label }}
</div>
<div class="info-item">
<span class="info-label">Data:</span>
{{ req.created_at.strftime('%Y-%m-%d %H:%M') if req.created_at else '-' }}
</div>
<div class="info-item">
<span class="info-label">Źródło:</span>
{{ req.registry_source or 'Brak' }}
</div>
</div>
{% if req.fetched_data %}
<div class="registry-data">
<h4>Dane z rejestru {{ req.registry_source }}:</h4>
<div class="registry-data-grid">
{% if req.fetched_data.name %}
<div><strong>Nazwa:</strong> {{ req.fetched_data.name }}</div>
{% endif %}
{% if req.fetched_data.regon %}
<div><strong>REGON:</strong> {{ req.fetched_data.regon }}</div>
{% endif %}
{% if req.fetched_data.krs %}
<div><strong>KRS:</strong> {{ req.fetched_data.krs }}</div>
{% endif %}
{% if req.fetched_data.address_city %}
<div><strong>Miasto:</strong> {{ req.fetched_data.address_city }}</div>
{% endif %}
</div>
</div>
{% endif %}
{% if req.user_note %}
<div style="margin-bottom: var(--spacing-md);">
<strong>Notatka:</strong> {{ req.user_note }}
</div>
{% endif %}
{% if req.status == 'pending' %}
<div class="request-actions">
<button class="btn-approve" onclick="approveRequest({{ req.id }})">
✓ Zatwierdź i zaktualizuj
</button>
<button class="btn-reject" onclick="rejectRequest({{ req.id }})">
✗ Odrzuć
</button>
</div>
{% elif req.review_comment %}
<div style="font-size: var(--font-size-sm); color: var(--text-secondary);">
<strong>Komentarz:</strong> {{ req.review_comment }}
</div>
{% endif %}
{% if req.applied_fields %}
<div style="font-size: var(--font-size-sm); color: var(--success); margin-top: var(--spacing-sm);">
<strong>Zaktualizowane pola:</strong> {{ req.applied_fields|join(', ') }}
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<p>Brak zgłoszeń w wybranej kategorii.</p>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
async function approveRequest(id) {
if (!confirm('Czy na pewno chcesz zatwierdzić to zgłoszenie i zaktualizować dane firmy?')) {
return;
}
try {
const response = await fetch(`/admin/company-requests/${id}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const result = await response.json();
if (result.success) {
alert('Zgłoszenie zatwierdzone. Zaktualizowane pola: ' + (result.applied_fields || []).join(', '));
location.reload();
} else {
alert(result.error || 'Błąd');
}
} catch (e) {
alert('Błąd połączenia');
}
}
async function rejectRequest(id) {
const comment = prompt('Podaj powód odrzucenia (opcjonalnie):');
if (comment === null) return;
try {
const response = await fetch(`/admin/company-requests/${id}/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ comment: comment })
});
const result = await response.json();
if (result.success) {
location.reload();
} else {
alert(result.error || 'Błąd');
}
} catch (e) {
alert('Błąd połączenia');
}
}
{% endblock %}

View File

@ -0,0 +1,282 @@
{% extends "base.html" %}
{% block title %}Deklaracje Członkowskie - Admin - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.admin-header {
margin-bottom: var(--spacing-xl);
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.admin-header-content h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
margin: 0;
}
.admin-header-content p {
margin: var(--spacing-xs) 0 0 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-2xl);
}
.stat-card {
background: var(--surface);
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
text-align: center;
}
.stat-value {
font-size: var(--font-size-3xl);
font-weight: 700;
color: var(--primary);
}
.stat-card.submitted .stat-value { color: #3b82f6; }
.stat-card.under-review .stat-value { color: #a855f7; }
.stat-card.approved .stat-value { color: var(--success); }
.stat-card.rejected .stat-value { color: var(--error); }
.stat-label {
color: var(--text-secondary);
font-size: var(--font-size-sm);
margin-top: var(--spacing-xs);
}
.filters-row {
display: flex;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
flex-wrap: wrap;
align-items: center;
}
.filter-group {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.filter-group label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
font-weight: 500;
}
.filter-select, .filter-input {
padding: var(--spacing-xs) var(--spacing-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-sm);
background: var(--surface);
}
.section {
background: var(--surface);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-xl);
}
.section h2 {
font-size: var(--font-size-xl);
margin-bottom: var(--spacing-lg);
color: var(--text-primary);
border-bottom: 2px solid var(--border);
padding-bottom: var(--spacing-sm);
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th, .data-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.data-table th {
font-weight: 600;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.data-table tbody tr:hover {
background: var(--background);
}
.status-badge {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-md);
border-radius: var(--radius-full);
font-size: var(--font-size-sm);
font-weight: 500;
}
.status-draft { background: var(--warning-light); color: var(--warning); }
.status-submitted { background: rgba(59, 130, 246, 0.1); color: #3b82f6; }
.status-under_review { background: rgba(168, 85, 247, 0.1); color: #a855f7; }
.status-changes_requested { background: rgba(251, 146, 60, 0.1); color: #fb923c; }
.status-approved { background: var(--success-light); color: var(--success); }
.status-rejected { background: var(--error-light); color: var(--error); }
.btn-view {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--primary);
color: white;
text-decoration: none;
border-radius: var(--radius);
font-size: var(--font-size-sm);
transition: var(--transition);
}
.btn-view:hover {
opacity: 0.9;
}
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
color: var(--text-secondary);
}
@media (max-width: 768px) {
.data-table {
display: block;
overflow-x: auto;
}
}
</style>
{% endblock %}
{% block content %}
<div class="admin-header">
<div class="admin-header-content">
<h1>Deklaracje Członkowskie</h1>
<p class="text-muted">Zarządzanie zgłoszeniami przystąpienia do Izby</p>
</div>
<a href="{{ url_for('admin.admin_company_requests') }}" class="btn-view" style="padding: var(--spacing-sm) var(--spacing-lg);">
Zgłoszenia uzupełnienia danych →
</a>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ total }}</div>
<div class="stat-label">Wszystkie</div>
</div>
<div class="stat-card submitted">
<div class="stat-value">{{ submitted }}</div>
<div class="stat-label">Wysłane</div>
</div>
<div class="stat-card under-review">
<div class="stat-value">{{ under_review }}</div>
<div class="stat-label">W rozpatrywaniu</div>
</div>
<div class="stat-card approved">
<div class="stat-value">{{ approved }}</div>
<div class="stat-label">Zatwierdzone</div>
</div>
<div class="stat-card rejected">
<div class="stat-value">{{ rejected }}</div>
<div class="stat-label">Odrzucone</div>
</div>
</div>
<form method="GET" class="filters-row">
<div class="filter-group">
<label>Status:</label>
<select name="status" class="filter-select" onchange="this.form.submit()">
<option value="all" {% if current_status == 'all' %}selected{% endif %}>Wszystkie</option>
{% for value, label in status_choices %}
<option value="{{ value }}" {% if current_status == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="filter-group">
<label>Szukaj:</label>
<input type="text" name="q" class="filter-input" value="{{ search_query }}"
placeholder="Nazwa, NIP, email..." style="width: 200px;">
<button type="submit" class="btn-view">Szukaj</button>
</div>
</form>
<div class="section">
<h2>Lista deklaracji ({{ applications|length }})</h2>
{% if applications %}
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Firma</th>
<th>NIP</th>
<th>Zgłaszający</th>
<th>Status</th>
<th>Data wysłania</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for app in applications %}
<tr>
<td>{{ app.id }}</td>
<td>
<strong>{{ app.company_name or '-' }}</strong>
{% if app.registry_source %}
<br><small class="text-muted">z {{ app.registry_source }}</small>
{% endif %}
</td>
<td>{{ app.nip or '-' }}</td>
<td>
{{ app.user.name if app.user else '-' }}
<br><small class="text-muted">{{ app.email }}</small>
</td>
<td>
<span class="status-badge status-{{ app.status }}">
{{ app.status_label }}
</span>
</td>
<td>
{% if app.submitted_at %}
{{ app.submitted_at.strftime('%Y-%m-%d') }}
<br><small class="text-muted">{{ app.submitted_at.strftime('%H:%M') }}</small>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('admin.admin_membership_detail', app_id=app.id) }}" class="btn-view">
Szczegóły
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>Brak deklaracji spełniających kryteria wyszukiwania.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,737 @@
{% extends "base.html" %}
{% block title %}Deklaracja #{{ application.id }} - Admin - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-xl);
}
.detail-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
margin: 0;
}
.back-link {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--text-secondary);
text-decoration: none;
margin-bottom: var(--spacing-sm);
}
.back-link:hover {
color: var(--primary);
}
.status-badge {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--radius-full);
font-size: var(--font-size-base);
font-weight: 600;
}
.status-draft { background: var(--warning-light); color: var(--warning); }
.status-submitted { background: rgba(59, 130, 246, 0.1); color: #3b82f6; }
.status-under_review { background: rgba(168, 85, 247, 0.1); color: #a855f7; }
.status-changes_requested { background: rgba(251, 146, 60, 0.1); color: #fb923c; }
.status-approved { background: var(--success-light); color: var(--success); }
.status-rejected { background: var(--error-light); color: var(--error); }
.content-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--spacing-xl);
}
@media (max-width: 1024px) {
.content-grid {
grid-template-columns: 1fr;
}
}
.section {
background: var(--surface);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-xl);
}
.section h2 {
font-size: var(--font-size-lg);
margin: 0 0 var(--spacing-lg) 0;
color: var(--text-primary);
border-bottom: 2px solid var(--border);
padding-bottom: var(--spacing-sm);
}
.data-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-md);
}
.data-grid.single-col {
grid-template-columns: 1fr;
}
.data-item {
padding: var(--spacing-sm);
background: var(--background);
border-radius: var(--radius);
}
.data-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-xs);
}
.data-value {
font-weight: 500;
color: var(--text-primary);
}
.data-value.empty {
color: var(--text-secondary);
font-style: italic;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.tag {
display: inline-block;
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--primary-light);
color: var(--primary);
border-radius: var(--radius);
font-size: var(--font-size-sm);
}
.actions-section {
position: sticky;
top: var(--spacing-xl);
}
.action-buttons {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.btn-action {
padding: var(--spacing-md);
border-radius: var(--radius);
font-size: var(--font-size-base);
font-weight: 500;
cursor: pointer;
transition: var(--transition);
text-align: center;
border: none;
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
}
.btn-approve {
background: var(--success);
color: white;
}
.btn-approve:hover {
opacity: 0.9;
}
.btn-reject {
background: var(--error);
color: white;
}
.btn-reject:hover {
opacity: 0.9;
}
.btn-changes {
background: var(--warning);
color: white;
}
.btn-changes:hover {
opacity: 0.9;
}
.btn-review {
background: var(--primary);
color: white;
}
.btn-review:hover {
opacity: 0.9;
}
.form-group {
margin-bottom: var(--spacing-md);
}
.form-group label {
display: block;
font-weight: 500;
margin-bottom: var(--spacing-xs);
}
.form-control {
width: 100%;
padding: var(--spacing-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-base);
}
.form-control:focus {
outline: none;
border-color: var(--primary);
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: var(--surface);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
max-width: 500px;
width: 90%;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
}
.modal-header h3 {
margin: 0;
}
.modal-close {
background: none;
border: none;
font-size: var(--font-size-xl);
cursor: pointer;
color: var(--text-secondary);
}
.modal-buttons {
display: flex;
gap: var(--spacing-md);
justify-content: flex-end;
margin-top: var(--spacing-lg);
}
.btn-cancel {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text-primary);
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--radius);
cursor: pointer;
}
.alert {
padding: var(--spacing-md);
border-radius: var(--radius);
margin-bottom: var(--spacing-lg);
}
.alert-info {
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.3);
color: #3b82f6;
}
.alert-warning {
background: var(--warning-light);
border: 1px solid var(--warning);
color: var(--warning);
}
</style>
{% endblock %}
{% block content %}
<a href="{{ url_for('admin.admin_membership') }}" class="back-link">
← Powrót do listy deklaracji
</a>
<div class="detail-header">
<div>
<h1>Deklaracja #{{ application.id }}</h1>
<p class="text-muted">{{ application.company_name }}</p>
</div>
<span class="status-badge status-{{ application.status }}">
{{ application.status_label }}
</span>
</div>
<div class="content-grid">
<div class="main-content">
<!-- Dane firmy -->
<div class="section">
<h2>Dane firmy</h2>
<div class="data-grid">
<div class="data-item">
<div class="data-label">Nazwa</div>
<div class="data-value">{{ application.company_name or '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">NIP</div>
<div class="data-value">{{ application.nip or '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">KRS</div>
<div class="data-value">{{ application.krs_number or '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">REGON</div>
<div class="data-value">{{ application.regon or '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">Źródło danych</div>
<div class="data-value">{{ application.registry_source or 'Ręcznie' }}</div>
</div>
<div class="data-item">
<div class="data-label">Forma prawna</div>
<div class="data-value">{{ application.business_type_label }}</div>
</div>
</div>
</div>
<!-- Adres -->
<div class="section">
<h2>Adres</h2>
<div class="data-grid">
<div class="data-item">
<div class="data-label">Kod pocztowy</div>
<div class="data-value">{{ application.address_postal_code or '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">Miejscowość</div>
<div class="data-value">{{ application.address_city or '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">Ulica</div>
<div class="data-value">{{ application.address_street or '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">Nr budynku</div>
<div class="data-value">{{ application.address_number or '-' }}</div>
</div>
</div>
</div>
<!-- Kontakt -->
<div class="section">
<h2>Dane kontaktowe</h2>
<div class="data-grid">
<div class="data-item">
<div class="data-label">Email</div>
<div class="data-value">{{ application.email or '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">Telefon</div>
<div class="data-value">{{ application.phone or '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">Strona WWW</div>
<div class="data-value">
{% if application.website %}
<a href="{{ application.website }}" target="_blank">{{ application.website }}</a>
{% else %}-{% endif %}
</div>
</div>
<div class="data-item">
<div class="data-label">Nazwa skrócona</div>
<div class="data-value">{{ application.short_name or '-' }}</div>
</div>
</div>
</div>
<!-- Delegaci -->
<div class="section">
<h2>Delegaci do Walnego Zgromadzenia</h2>
<div class="data-grid single-col">
<div class="data-item">
<div class="data-label">Delegat 1 (główny)</div>
<div class="data-value">{{ application.delegate_1 or '-' }}</div>
</div>
{% if application.delegate_2 %}
<div class="data-item">
<div class="data-label">Delegat 2</div>
<div class="data-value">{{ application.delegate_2 }}</div>
</div>
{% endif %}
{% if application.delegate_3 %}
<div class="data-item">
<div class="data-label">Delegat 3</div>
<div class="data-value">{{ application.delegate_3 }}</div>
</div>
{% endif %}
</div>
</div>
<!-- Sekcje -->
<div class="section">
<h2>Sekcje tematyczne</h2>
<div class="tags">
{% for label in application.sections_labels %}
<span class="tag">{{ label }}</span>
{% else %}
<span class="text-muted">Brak wybranych sekcji</span>
{% endfor %}
</div>
{% if application.sections_other %}
<div style="margin-top: var(--spacing-md);">
<div class="data-label">Inna sekcja</div>
<div class="data-value">{{ application.sections_other }}</div>
</div>
{% endif %}
</div>
<!-- Opis -->
{% if application.description %}
<div class="section">
<h2>Opis działalności</h2>
<p>{{ application.description }}</p>
</div>
{% endif %}
<!-- Zgody RODO -->
<div class="section">
<h2>Zgody i oświadczenie</h2>
<div class="data-grid">
<div class="data-item">
<div class="data-label">Zgoda email</div>
<div class="data-value">
{% if application.consent_email %}✅ Tak{% else %}❌ Nie{% endif %}
{% if application.consent_email_address %}
<br><small>{{ application.consent_email_address }}</small>
{% endif %}
</div>
</div>
<div class="data-item">
<div class="data-label">Zgoda SMS</div>
<div class="data-value">
{% if application.consent_sms %}✅ Tak{% else %}❌ Nie{% endif %}
{% if application.consent_sms_phone %}
<br><small>{{ application.consent_sms_phone }}</small>
{% endif %}
</div>
</div>
<div class="data-item">
<div class="data-label">Oświadczenie</div>
<div class="data-value">
{% if application.declaration_accepted %}
✅ Zaakceptowane
<br><small>{{ application.declaration_accepted_at.strftime('%Y-%m-%d %H:%M') if application.declaration_accepted_at else '' }}</small>
<br><small>IP: {{ application.declaration_ip_address or '-' }}</small>
{% else %}
❌ Nie zaakceptowane
{% endif %}
</div>
</div>
</div>
</div>
</div>
<div class="sidebar">
<!-- Akcje -->
<div class="section actions-section">
<h2>Akcje</h2>
{% if application.status == 'submitted' %}
<div class="alert alert-info">
Deklaracja oczekuje na rozpatrzenie.
</div>
<div class="action-buttons">
<button class="btn-action btn-review" onclick="startReview()">
Rozpocznij rozpatrywanie
</button>
</div>
{% elif application.status == 'under_review' %}
<div class="action-buttons">
<button class="btn-action btn-approve" onclick="openApproveModal()">
✓ Zatwierdź
</button>
<button class="btn-action btn-changes" onclick="openChangesModal()">
Poproś o poprawki
</button>
<button class="btn-action btn-reject" onclick="openRejectModal()">
✗ Odrzuć
</button>
</div>
<div class="form-group" style="margin-top: var(--spacing-lg);">
<label>Kategoria firmy</label>
<select class="form-control" id="categorySelect">
<option value="">Wybierz kategorię...</option>
{% for cat in categories %}
<option value="{{ cat.id }}">{{ cat.name }}</option>
{% endfor %}
</select>
</div>
{% elif application.status == 'approved' %}
<div class="alert alert-info">
✓ Deklaracja zatwierdzona
{% if application.member_number %}
<br><strong>Nr członkowski: {{ application.member_number }}</strong>
{% endif %}
{% if application.company_id %}
<br><a href="{{ url_for('public.company', slug=application.company.slug) }}">Zobacz firmę →</a>
{% endif %}
</div>
{% elif application.status == 'rejected' %}
<div class="alert alert-warning">
✗ Deklaracja odrzucona
{% if application.review_comment %}
<br><small>{{ application.review_comment }}</small>
{% endif %}
</div>
{% elif application.status == 'changes_requested' %}
<div class="alert alert-warning">
Oczekuje na poprawki od zgłaszającego.
{% if application.review_comment %}
<br><small>{{ application.review_comment }}</small>
{% endif %}
</div>
{% endif %}
</div>
<!-- Info o zgłaszającym -->
<div class="section">
<h2>Zgłaszający</h2>
<div class="data-grid single-col">
<div class="data-item">
<div class="data-label">Użytkownik</div>
<div class="data-value">{{ application.user.name if application.user else '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">Email</div>
<div class="data-value">{{ application.user.email if application.user else '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">Data zgłoszenia</div>
<div class="data-value">{{ application.created_at.strftime('%Y-%m-%d %H:%M') if application.created_at else '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">Data wysłania</div>
<div class="data-value">{{ application.submitted_at.strftime('%Y-%m-%d %H:%M') if application.submitted_at else '-' }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal: Zatwierdź -->
<div class="modal" id="approveModal">
<div class="modal-content">
<div class="modal-header">
<h3>Zatwierdź deklarację</h3>
<button class="modal-close" onclick="closeModal('approveModal')">&times;</button>
</div>
<p>Zostanie utworzona nowa firma w katalogu i przypisana do zgłaszającego użytkownika.</p>
<div class="form-group">
<label>Komentarz (opcjonalnie)</label>
<textarea class="form-control" id="approveComment" rows="3" placeholder="Komentarz dla zgłaszającego..."></textarea>
</div>
<div class="modal-buttons">
<button class="btn-cancel" onclick="closeModal('approveModal')">Anuluj</button>
<button class="btn-action btn-approve" onclick="approve()">Zatwierdź</button>
</div>
</div>
</div>
<!-- Modal: Odrzuć -->
<div class="modal" id="rejectModal">
<div class="modal-content">
<div class="modal-header">
<h3>Odrzuć deklarację</h3>
<button class="modal-close" onclick="closeModal('rejectModal')">&times;</button>
</div>
<div class="form-group">
<label>Powód odrzucenia <span style="color: var(--error);">*</span></label>
<textarea class="form-control" id="rejectComment" rows="3" placeholder="Podaj powód odrzucenia..." required></textarea>
</div>
<div class="modal-buttons">
<button class="btn-cancel" onclick="closeModal('rejectModal')">Anuluj</button>
<button class="btn-action btn-reject" onclick="reject()">Odrzuć</button>
</div>
</div>
</div>
<!-- Modal: Poprawki -->
<div class="modal" id="changesModal">
<div class="modal-content">
<div class="modal-header">
<h3>Poproś o poprawki</h3>
<button class="modal-close" onclick="closeModal('changesModal')">&times;</button>
</div>
<div class="form-group">
<label>Co wymaga poprawienia? <span style="color: var(--error);">*</span></label>
<textarea class="form-control" id="changesComment" rows="3" placeholder="Opisz wymagane poprawki..." required></textarea>
</div>
<div class="modal-buttons">
<button class="btn-cancel" onclick="closeModal('changesModal')">Anuluj</button>
<button class="btn-action btn-changes" onclick="requestChanges()">Wyślij</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
const appId = {{ application.id }};
function openApproveModal() {
document.getElementById('approveModal').classList.add('active');
}
function openRejectModal() {
document.getElementById('rejectModal').classList.add('active');
}
function openChangesModal() {
document.getElementById('changesModal').classList.add('active');
}
function closeModal(id) {
document.getElementById(id).classList.remove('active');
}
async function startReview() {
try {
const response = await fetch(`/admin/membership/${appId}/start-review`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const result = await response.json();
if (result.success) {
location.reload();
} else {
alert(result.error || 'Błąd');
}
} catch (e) {
alert('Błąd połączenia');
}
}
async function approve() {
const categoryId = document.getElementById('categorySelect')?.value;
const comment = document.getElementById('approveComment').value;
try {
const response = await fetch(`/admin/membership/${appId}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category_id: categoryId || null, comment: comment })
});
const result = await response.json();
if (result.success) {
alert(`Zatwierdzono! Nr członkowski: ${result.member_number}`);
location.reload();
} else {
alert(result.error || 'Błąd');
}
} catch (e) {
alert('Błąd połączenia');
}
}
async function reject() {
const comment = document.getElementById('rejectComment').value.trim();
if (!comment) {
alert('Podaj powód odrzucenia');
return;
}
try {
const response = await fetch(`/admin/membership/${appId}/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ comment: comment })
});
const result = await response.json();
if (result.success) {
location.reload();
} else {
alert(result.error || 'Błąd');
}
} catch (e) {
alert('Błąd połączenia');
}
}
async function requestChanges() {
const comment = document.getElementById('changesComment').value.trim();
if (!comment) {
alert('Opisz wymagane poprawki');
return;
}
try {
const response = await fetch(`/admin/membership/${appId}/request-changes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ comment: comment })
});
const result = await response.json();
if (result.success) {
location.reload();
} else {
alert(result.error || 'Błąd');
}
} catch (e) {
alert('Błąd połączenia');
}
}
// Close modals on outside click
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('click', function(e) {
if (e.target === this) {
this.classList.remove('active');
}
});
});
{% endblock %}

View File

@ -1232,6 +1232,12 @@
</svg>
Użytkownicy
</a>
<a href="{{ url_for('admin.admin_membership') }}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/>
</svg>
Deklaracje
</a>
<a href="{{ url_for('admin.admin_fees') }}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>

View File

@ -269,6 +269,97 @@
margin-bottom: var(--spacing-md, 16px);
}
/* Membership CTA */
.membership-cta {
background: linear-gradient(135deg, var(--primary, #0066cc) 0%, #0052a3 100%);
color: white;
padding: var(--spacing-xl, 24px);
border-radius: var(--radius-lg, 12px);
margin-bottom: var(--spacing-xl, 24px);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-lg, 20px);
box-shadow: 0 4px 16px rgba(0, 102, 204, 0.3);
}
.membership-cta-content h3 {
margin: 0 0 var(--spacing-xs, 4px) 0;
font-size: var(--font-size-xl, 1.25rem);
}
.membership-cta-content p {
margin: 0;
opacity: 0.9;
}
.membership-cta .btn-cta {
background: white;
color: var(--primary, #0066cc);
padding: var(--spacing-sm, 8px) var(--spacing-xl, 24px);
border-radius: var(--radius, 6px);
text-decoration: none;
font-weight: 600;
white-space: nowrap;
transition: all 0.2s ease;
}
.membership-cta .btn-cta:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* Pending application status */
.membership-status {
background: var(--warning-light, #fef3c7);
border: 1px solid var(--warning, #f59e0b);
padding: var(--spacing-lg, 20px);
border-radius: var(--radius-lg, 12px);
margin-bottom: var(--spacing-xl, 24px);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-lg, 20px);
}
.membership-status-content h4 {
margin: 0 0 var(--spacing-xs, 4px) 0;
color: var(--warning, #f59e0b);
}
.membership-status-content p {
margin: 0;
color: var(--text-secondary, #666);
}
.membership-status .btn-outline {
white-space: nowrap;
}
/* Company data update CTA */
.data-update-cta {
background: var(--surface, #f9fafb);
border: 2px dashed var(--warning, #f59e0b);
padding: var(--spacing-lg, 20px);
border-radius: var(--radius-lg, 12px);
margin-bottom: var(--spacing-xl, 24px);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-lg, 20px);
}
.data-update-cta-content h4 {
margin: 0 0 var(--spacing-xs, 4px) 0;
color: var(--warning, #f59e0b);
}
.data-update-cta-content p {
margin: 0;
color: var(--text-secondary, #666);
font-size: var(--font-size-sm, 0.875rem);
}
/* Responsive */
@media (max-width: 768px) {
.stats-row {
@ -371,6 +462,52 @@
</div>
</div>
{# Membership CTA for users without company #}
{% if not current_user.company_id %}
{% if has_pending_application %}
<div class="membership-status">
<div class="membership-status-content">
<h4>Twoja deklaracja członkowska jest w trakcie rozpatrywania</h4>
<p>Status: {{ pending_application.status_label if pending_application else 'Wysłane' }}</p>
</div>
<a href="{{ url_for('membership.status') }}" class="btn btn-outline">
Zobacz szczegóły →
</a>
</div>
{% elif has_draft_application %}
<div class="membership-status">
<div class="membership-status-content">
<h4>Masz niedokończoną deklarację członkowską</h4>
<p>Kontynuuj wypełnianie formularza</p>
</div>
<a href="{{ url_for('membership.apply') }}" class="btn btn-primary">
Kontynuuj →
</a>
</div>
{% else %}
<div class="membership-cta">
<div class="membership-cta-content">
<h3>Dołącz do Izby Przedsiębiorców NORDA</h3>
<p>Wypełnij deklarację członkowską online i zyskaj dostęp do pełnych funkcji portalu</p>
</div>
<a href="{{ url_for('membership.apply') }}" class="btn-cta">
Złóż deklarację →
</a>
</div>
{% endif %}
{% elif current_user.company and not current_user.company.nip %}
{# User has company but missing NIP - suggest data update #}
<div class="data-update-cta">
<div class="data-update-cta-content">
<h4>Uzupełnij dane firmy</h4>
<p>Twoja firma nie ma jeszcze NIP w systemie. Uzupełnij dane, aby pobrać informacje z rejestru.</p>
</div>
<a href="{{ url_for('membership.data_request') }}" class="btn btn-warning">
Uzupełnij NIP →
</a>
</div>
{% endif %}
{% if current_user.is_admin %}
<!-- Admin Section -->
<div class="admin-section-highlight">

View File

@ -0,0 +1,762 @@
{% extends "base.html" %}
{% block title %}Deklaracja Członkowska - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.wizard-container {
max-width: 800px;
margin: 0 auto;
}
.wizard-header {
margin-bottom: var(--spacing-2xl);
text-align: center;
}
.wizard-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
margin: 0 0 var(--spacing-md) 0;
}
.wizard-steps {
display: flex;
justify-content: center;
gap: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
}
.wizard-step {
display: flex;
align-items: center;
gap: var(--spacing-sm);
color: var(--text-secondary);
}
.wizard-step.active {
color: var(--primary);
font-weight: 600;
}
.wizard-step.completed {
color: var(--success);
}
.step-number {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--border);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
.wizard-step.active .step-number {
background: var(--primary);
color: white;
}
.wizard-step.completed .step-number {
background: var(--success);
color: white;
}
.wizard-content {
background: var(--surface);
padding: var(--spacing-2xl);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
.form-section {
margin-bottom: var(--spacing-xl);
}
.form-section h2 {
font-size: var(--font-size-lg);
color: var(--text-primary);
margin: 0 0 var(--spacing-lg) 0;
padding-bottom: var(--spacing-sm);
border-bottom: 2px solid var(--border);
}
.form-group {
margin-bottom: var(--spacing-lg);
}
.form-group label {
display: block;
font-weight: 500;
margin-bottom: var(--spacing-xs);
color: var(--text-primary);
}
.form-group label .required {
color: var(--error);
}
.form-control {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-base);
transition: var(--transition);
}
.form-control:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.1);
}
.form-control.error {
border-color: var(--error);
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-md);
}
.form-hint {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-top: var(--spacing-xs);
}
.nip-lookup {
display: flex;
gap: var(--spacing-sm);
}
.nip-lookup .form-control {
flex: 1;
}
.btn-lookup {
padding: var(--spacing-sm) var(--spacing-lg);
background: var(--surface);
border: 1px solid var(--primary);
color: var(--primary);
border-radius: var(--radius);
cursor: pointer;
font-weight: 500;
transition: var(--transition);
white-space: nowrap;
}
.btn-lookup:hover {
background: var(--primary);
color: white;
}
.btn-lookup:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.registry-preview {
background: var(--background);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--spacing-lg);
margin-top: var(--spacing-md);
}
.registry-preview.success {
border-color: var(--success);
background: rgba(var(--success-rgb), 0.05);
}
.registry-preview h4 {
margin: 0 0 var(--spacing-sm) 0;
color: var(--success);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.registry-data {
display: grid;
gap: var(--spacing-xs);
}
.registry-data-row {
display: flex;
gap: var(--spacing-sm);
}
.registry-data-label {
font-weight: 500;
color: var(--text-secondary);
min-width: 100px;
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.checkbox-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.checkbox-item input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--primary);
}
.radio-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-sm);
}
.radio-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm);
background: var(--background);
border-radius: var(--radius);
}
.radio-item input[type="radio"] {
accent-color: var(--primary);
}
.declaration-box {
background: var(--background);
border: 2px solid var(--border);
border-radius: var(--radius);
padding: var(--spacing-lg);
margin-top: var(--spacing-lg);
}
.declaration-box.accepted {
border-color: var(--success);
background: rgba(var(--success-rgb), 0.05);
}
.declaration-text {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-md);
line-height: 1.6;
}
.wizard-buttons {
display: flex;
justify-content: space-between;
margin-top: var(--spacing-xl);
padding-top: var(--spacing-xl);
border-top: 1px solid var(--border);
}
.btn-wizard {
padding: var(--spacing-md) var(--spacing-xl);
border-radius: var(--radius);
font-size: var(--font-size-base);
font-weight: 500;
cursor: pointer;
transition: var(--transition);
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
}
.btn-prev {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text-primary);
}
.btn-prev:hover {
background: var(--background);
}
.btn-next, .btn-submit {
background: var(--primary);
border: none;
color: white;
}
.btn-next:hover, .btn-submit:hover {
opacity: 0.9;
}
.btn-save {
background: var(--surface);
border: 1px solid var(--success);
color: var(--success);
}
.btn-save:hover {
background: rgba(var(--success-rgb), 0.1);
}
.loading-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 768px) {
.wizard-steps {
flex-direction: column;
align-items: flex-start;
}
.wizard-buttons {
flex-direction: column-reverse;
gap: var(--spacing-md);
}
.wizard-buttons button {
width: 100%;
}
}
</style>
{% endblock %}
{% block content %}
<div class="wizard-container">
<div class="wizard-header">
<h1>Deklaracja Członkowska</h1>
<p class="text-muted">Przystąpienie do Izby Przedsiębiorców NORDA</p>
<div class="wizard-steps">
<div class="wizard-step {% if step == 1 %}active{% elif step > 1 %}completed{% endif %}">
<span class="step-number">{% if step > 1 %}✓{% else %}1{% endif %}</span>
<span>Dane firmy</span>
</div>
<div class="wizard-step {% if step == 2 %}active{% elif step > 2 %}completed{% endif %}">
<span class="step-number">{% if step > 2 %}✓{% else %}2{% endif %}</span>
<span>Informacje</span>
</div>
<div class="wizard-step {% if step == 3 %}active{% endif %}">
<span class="step-number">3</span>
<span>Sekcje i zgody</span>
</div>
</div>
</div>
<div class="wizard-content">
<form method="POST" id="wizardForm">
{% if step == 1 %}
<!-- STEP 1: Dane firmy -->
<div class="form-section">
<h2>Pobierz dane z rejestru</h2>
<div class="form-group">
<label>NIP <span class="required">*</span></label>
<div class="nip-lookup">
<input type="text" class="form-control" id="nipInput" name="nip"
value="{{ application.nip or '' }}"
placeholder="0000000000" maxlength="10" pattern="\d{10}">
<button type="button" class="btn-lookup" id="btnLookup">
Sprawdź w rejestrze
</button>
</div>
<div class="form-hint">Wpisz 10-cyfrowy NIP bez myślników</div>
</div>
<div id="registryPreview" class="registry-preview" style="display: none;">
<h4>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22,4 12,14.01 9,11.01"/>
</svg>
Dane pobrane z <span id="registrySource">KRS</span>
</h4>
<div class="registry-data" id="registryData"></div>
</div>
</div>
<div class="form-section">
<h2>Dane firmy</h2>
<div class="form-group">
<label>Nazwa firmy <span class="required">*</span></label>
<input type="text" class="form-control" name="company_name"
value="{{ application.company_name or '' }}" required>
</div>
<div class="form-row">
<div class="form-group">
<label>Kod pocztowy</label>
<input type="text" class="form-control" name="address_postal_code"
value="{{ application.address_postal_code or '' }}"
placeholder="00-000" maxlength="6">
</div>
<div class="form-group">
<label>Miejscowość</label>
<input type="text" class="form-control" name="address_city"
value="{{ application.address_city or '' }}">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Ulica</label>
<input type="text" class="form-control" name="address_street"
value="{{ application.address_street or '' }}">
</div>
<div class="form-group">
<label>Nr budynku/lokalu</label>
<input type="text" class="form-control" name="address_number"
value="{{ application.address_number or '' }}">
</div>
</div>
<input type="hidden" name="krs_number" value="{{ application.krs_number or '' }}">
<input type="hidden" name="regon" value="{{ application.regon or '' }}">
<input type="hidden" name="registry_source" value="{{ application.registry_source or 'manual' }}">
</div>
<div class="form-section">
<h2>Delegaci do Walnego Zgromadzenia</h2>
<div class="form-hint" style="margin-bottom: var(--spacing-md);">
Wskaż osoby reprezentujące firmę na Walnym Zgromadzeniu Członków Izby (min. 1, max. 3)
</div>
<div class="form-group">
<label>Delegat 1 (główny) <span class="required">*</span></label>
<input type="text" class="form-control" name="delegate_1"
value="{{ application.delegate_1 or '' }}"
placeholder="Imię i nazwisko" required>
</div>
<div class="form-row">
<div class="form-group">
<label>Delegat 2 (opcjonalnie)</label>
<input type="text" class="form-control" name="delegate_2"
value="{{ application.delegate_2 or '' }}"
placeholder="Imię i nazwisko">
</div>
<div class="form-group">
<label>Delegat 3 (opcjonalnie)</label>
<input type="text" class="form-control" name="delegate_3"
value="{{ application.delegate_3 or '' }}"
placeholder="Imię i nazwisko">
</div>
</div>
</div>
{% elif step == 2 %}
<!-- STEP 2: Karta informacyjna -->
<div class="form-section">
<h2>Dane kontaktowe</h2>
<div class="form-row">
<div class="form-group">
<label>Strona WWW</label>
<input type="url" class="form-control" name="website"
value="{{ application.website or '' }}"
placeholder="https://przykład.pl">
</div>
<div class="form-group">
<label>Email firmowy <span class="required">*</span></label>
<input type="email" class="form-control" name="email"
value="{{ application.email or '' }}" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Telefon</label>
<input type="tel" class="form-control" name="phone"
value="{{ application.phone or '' }}"
placeholder="+48 000 000 000">
</div>
<div class="form-group">
<label>Nazwa skrócona</label>
<input type="text" class="form-control" name="short_name"
value="{{ application.short_name or '' }}"
placeholder="np. INPI">
</div>
</div>
</div>
<div class="form-section">
<h2>Informacje o firmie</h2>
<div class="form-group">
<label>Opis działalności</label>
<textarea class="form-control" name="description" rows="4"
placeholder="Krótki opis działalności firmy...">{{ application.description or '' }}</textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>Data założenia</label>
<input type="date" class="form-control" name="founded_date"
value="{{ application.founded_date.isoformat() if application.founded_date else '' }}">
</div>
<div class="form-group">
<label>Średnie zatrudnienie</label>
<input type="number" class="form-control" name="employee_count"
value="{{ application.employee_count or '' }}" min="0">
<div class="checkbox-item" style="margin-top: var(--spacing-sm);">
<input type="checkbox" name="show_employee_count" id="showEmployeeCount"
{% if application.show_employee_count %}checked{% endif %}>
<label for="showEmployeeCount">Pokaż publicznie</label>
</div>
</div>
</div>
<div class="form-group">
<label>Średnioroczne obroty</label>
<select class="form-control" name="annual_revenue">
<option value="">Nie podaję</option>
<option value="do_100k" {% if application.annual_revenue == 'do_100k' %}selected{% endif %}>do 100 tys. PLN</option>
<option value="100k_500k" {% if application.annual_revenue == '100k_500k' %}selected{% endif %}>100 - 500 tys. PLN</option>
<option value="500k_2m" {% if application.annual_revenue == '500k_2m' %}selected{% endif %}>500 tys. - 2 mln PLN</option>
<option value="2m_10m" {% if application.annual_revenue == '2m_10m' %}selected{% endif %}>2 - 10 mln PLN</option>
<option value="10m_50m" {% if application.annual_revenue == '10m_50m' %}selected{% endif %}>10 - 50 mln PLN</option>
<option value="powyzej_50m" {% if application.annual_revenue == 'powyzej_50m' %}selected{% endif %}>powyżej 50 mln PLN</option>
</select>
</div>
</div>
<div class="form-section">
<h2>Spółki powiązane (opcjonalnie)</h2>
<div class="form-hint" style="margin-bottom: var(--spacing-md);">
Jeśli firma należy do grupy kapitałowej, podaj nazwy powiązanych spółek (max 5)
</div>
{% for i in range(1, 6) %}
<div class="form-group">
<input type="text" class="form-control" name="related_company_{{ i }}"
value="{{ application.related_companies[i-1] if application.related_companies and application.related_companies|length >= i else '' }}"
placeholder="Nazwa spółki powiązanej">
</div>
{% endfor %}
</div>
{% elif step == 3 %}
<!-- STEP 3: Sekcje i oświadczenia -->
<div class="form-section">
<h2>Sekcje tematyczne <span class="required">*</span></h2>
<div class="form-hint" style="margin-bottom: var(--spacing-md);">
Wybierz sekcje tematyczne odpowiadające profilowi działalności firmy (min. 1)
</div>
<div class="checkbox-group">
{% for value, label in section_choices %}
<div class="checkbox-item">
<input type="checkbox" name="sections" value="{{ value }}" id="section_{{ value }}"
{% if application.sections and value in application.sections %}checked{% endif %}>
<label for="section_{{ value }}">{{ label }}</label>
</div>
{% endfor %}
</div>
<div class="form-group" style="margin-top: var(--spacing-md);">
<label>Jeśli wybrano "Inna", opisz:</label>
<input type="text" class="form-control" name="sections_other"
value="{{ application.sections_other or '' }}"
placeholder="Opis innej sekcji">
</div>
</div>
<div class="form-section">
<h2>Forma prawna <span class="required">*</span></h2>
<div class="radio-group">
{% for value, label in business_type_choices %}
<div class="radio-item">
<input type="radio" name="business_type" value="{{ value }}" id="btype_{{ value }}"
{% if application.business_type == value %}checked{% endif %} required>
<label for="btype_{{ value }}">{{ label }}</label>
</div>
{% endfor %}
</div>
<div class="form-group" style="margin-top: var(--spacing-md);">
<label>Jeśli wybrano "Inna", opisz:</label>
<input type="text" class="form-control" name="business_type_other"
value="{{ application.business_type_other or '' }}"
placeholder="Opis formy prawnej">
</div>
</div>
<div class="form-section">
<h2>Zgody RODO</h2>
<div class="checkbox-item" style="margin-bottom: var(--spacing-md);">
<input type="checkbox" name="consent_email" id="consentEmail"
{% if application.consent_email %}checked{% endif %} required>
<label for="consentEmail">
Wyrażam zgodę na otrzymywanie informacji drogą elektroniczną (email) <span class="required">*</span>
</label>
</div>
<div class="form-group">
<label>Email do kontaktu</label>
<input type="email" class="form-control" name="consent_email_address"
value="{{ application.consent_email_address or application.email or '' }}"
placeholder="adres@email.pl">
</div>
<div class="checkbox-item" style="margin-bottom: var(--spacing-md);">
<input type="checkbox" name="consent_sms" id="consentSms"
{% if application.consent_sms %}checked{% endif %}>
<label for="consentSms">
Wyrażam zgodę na otrzymywanie powiadomień SMS
</label>
</div>
<div class="form-group">
<label>Telefon do SMS</label>
<input type="tel" class="form-control" name="consent_sms_phone"
value="{{ application.consent_sms_phone or application.phone or '' }}"
placeholder="+48 000 000 000">
</div>
</div>
<div class="form-section">
<h2>Oświadczenie</h2>
<div class="declaration-box {% if application.declaration_accepted %}accepted{% endif %}" id="declarationBox">
<div class="declaration-text">
Oświadczam, że zapoznałem/am się ze Statutem Izby Przedsiębiorców NORDA i zobowiązuję się do jego przestrzegania.
Jednocześnie wyrażam zgodę na przetwarzanie moich danych osobowych przez Izbę Przedsiębiorców NORDA
w celach statutowych i marketingowych. Wszystkie podane przeze mnie informacje są prawdziwe i aktualne.
</div>
<div class="checkbox-item">
<input type="checkbox" name="declaration_accepted" id="declarationAccepted"
{% if application.declaration_accepted %}checked{% endif %} required>
<label for="declarationAccepted">
<strong>Akceptuję powyższe oświadczenie</strong> <span class="required">*</span>
</label>
</div>
</div>
</div>
{% endif %}
<div class="wizard-buttons">
<div>
{% if step > 1 %}
<button type="submit" name="action" value="prev" class="btn-wizard btn-prev">
← Wstecz
</button>
{% endif %}
</div>
<div style="display: flex; gap: var(--spacing-md);">
<button type="submit" name="action" value="save" class="btn-wizard btn-save">
Zapisz
</button>
{% if step < 3 %}
<button type="submit" name="action" value="next" class="btn-wizard btn-next">
Dalej →
</button>
{% else %}
<button type="submit" name="action" value="next" class="btn-wizard btn-submit">
Wyślij deklarację
</button>
{% endif %}
</div>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_js %}
{% if step == 1 %}
const nipInput = document.getElementById('nipInput');
const btnLookup = document.getElementById('btnLookup');
const registryPreview = document.getElementById('registryPreview');
const registrySource = document.getElementById('registrySource');
const registryData = document.getElementById('registryData');
btnLookup.addEventListener('click', async function() {
const nip = nipInput.value.replace(/[\s-]/g, '');
if (nip.length !== 10 || !/^\d+$/.test(nip)) {
alert('NIP musi mieć 10 cyfr');
return;
}
btnLookup.disabled = true;
btnLookup.innerHTML = '<span class="loading-spinner"></span> Sprawdzam...';
try {
const response = await fetch('/api/membership/lookup-nip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nip: nip })
});
const result = await response.json();
if (result.success && result.data) {
registrySource.textContent = result.source;
registryPreview.classList.add('success');
registryPreview.style.display = 'block';
const data = result.data;
registryData.innerHTML = `
<div class="registry-data-row"><span class="registry-data-label">Nazwa:</span> ${data.name || '-'}</div>
<div class="registry-data-row"><span class="registry-data-label">Adres:</span> ${data.address_postal_code || ''} ${data.address_city || ''}, ${data.address_street || ''} ${data.address_number || ''}</div>
${data.krs ? `<div class="registry-data-row"><span class="registry-data-label">KRS:</span> ${data.krs}</div>` : ''}
${data.regon ? `<div class="registry-data-row"><span class="registry-data-label">REGON:</span> ${data.regon}</div>` : ''}
`;
// Auto-fill form
if (data.name) document.querySelector('[name="company_name"]').value = data.name;
if (data.address_postal_code) document.querySelector('[name="address_postal_code"]').value = data.address_postal_code;
if (data.address_city) document.querySelector('[name="address_city"]').value = data.address_city;
if (data.address_street) document.querySelector('[name="address_street"]').value = data.address_street;
if (data.address_number) document.querySelector('[name="address_number"]').value = data.address_number;
if (data.krs) document.querySelector('[name="krs_number"]').value = data.krs;
if (data.regon) document.querySelector('[name="regon"]').value = data.regon;
document.querySelector('[name="registry_source"]').value = result.source;
} else {
registryPreview.classList.remove('success');
registryPreview.style.display = 'block';
registryData.innerHTML = '<p>Firma nie została znaleziona w rejestrze. Wypełnij dane ręcznie.</p>';
document.querySelector('[name="registry_source"]').value = 'manual';
}
} catch (error) {
console.error('Lookup error:', error);
alert('Błąd podczas sprawdzania NIP');
} finally {
btnLookup.disabled = false;
btnLookup.innerHTML = 'Sprawdź w rejestrze';
}
});
{% endif %}
{% if step == 3 %}
document.getElementById('declarationAccepted').addEventListener('change', function() {
const box = document.getElementById('declarationBox');
if (this.checked) {
box.classList.add('accepted');
} else {
box.classList.remove('accepted');
}
});
{% endif %}
{% endblock %}

View File

@ -0,0 +1,365 @@
{% extends "base.html" %}
{% block title %}Uzupełnij Dane Firmy - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.request-container {
max-width: 600px;
margin: 0 auto;
}
.request-header {
margin-bottom: var(--spacing-xl);
text-align: center;
}
.request-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
margin: 0 0 var(--spacing-sm) 0;
}
.company-info {
background: var(--surface);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-xl);
}
.company-name {
font-size: var(--font-size-xl);
font-weight: 600;
margin: 0 0 var(--spacing-sm) 0;
}
.company-meta {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.missing-data {
background: var(--warning-light);
border: 1px solid var(--warning);
padding: var(--spacing-md);
border-radius: var(--radius);
margin-top: var(--spacing-md);
}
.missing-data h4 {
margin: 0 0 var(--spacing-xs) 0;
color: var(--warning);
font-size: var(--font-size-sm);
}
.missing-data ul {
margin: 0;
padding-left: var(--spacing-lg);
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.form-section {
background: var(--surface);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
.form-section h2 {
font-size: var(--font-size-lg);
margin: 0 0 var(--spacing-lg) 0;
color: var(--text-primary);
border-bottom: 2px solid var(--border);
padding-bottom: var(--spacing-sm);
}
.form-group {
margin-bottom: var(--spacing-lg);
}
.form-group label {
display: block;
font-weight: 500;
margin-bottom: var(--spacing-xs);
}
.form-control {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-base);
}
.form-control:focus {
outline: none;
border-color: var(--primary);
}
.nip-lookup {
display: flex;
gap: var(--spacing-sm);
}
.nip-lookup .form-control {
flex: 1;
}
.btn-lookup {
padding: var(--spacing-sm) var(--spacing-lg);
background: var(--surface);
border: 1px solid var(--primary);
color: var(--primary);
border-radius: var(--radius);
cursor: pointer;
font-weight: 500;
white-space: nowrap;
}
.btn-lookup:hover {
background: var(--primary);
color: white;
}
.btn-lookup:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.registry-preview {
background: var(--background);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--spacing-lg);
margin-top: var(--spacing-lg);
}
.registry-preview.success {
border-color: var(--success);
background: rgba(var(--success-rgb), 0.05);
}
.registry-preview h4 {
margin: 0 0 var(--spacing-sm) 0;
color: var(--success);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.registry-data {
display: grid;
gap: var(--spacing-xs);
font-size: var(--font-size-sm);
}
.btn-submit {
width: 100%;
padding: var(--spacing-md);
background: var(--primary);
color: white;
border: none;
border-radius: var(--radius);
font-size: var(--font-size-base);
font-weight: 500;
cursor: pointer;
margin-top: var(--spacing-lg);
}
.btn-submit:hover {
opacity: 0.9;
}
.btn-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pending-alert {
background: var(--warning-light);
border: 1px solid var(--warning);
padding: var(--spacing-lg);
border-radius: var(--radius);
text-align: center;
}
.pending-alert h3 {
margin: 0 0 var(--spacing-sm) 0;
color: var(--warning);
}
.loading-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
{% endblock %}
{% block content %}
<div class="request-container">
<div class="request-header">
<h1>Uzupełnij Dane Firmy</h1>
<p class="text-muted">Pobierz aktualne dane z rejestru KRS lub CEIDG</p>
</div>
<div class="company-info">
<h2 class="company-name">{{ company.name }}</h2>
<div class="company-meta">
{% if company.nip %}NIP: {{ company.nip }}{% else %}Brak NIP{% endif %}
{% if company.address_city %} • {{ company.address_city }}{% endif %}
</div>
{% set missing = [] %}
{% if not company.nip %}{% set _ = missing.append('NIP') %}{% endif %}
{% if not company.regon %}{% set _ = missing.append('REGON') %}{% endif %}
{% if not company.address_postal_code %}{% set _ = missing.append('Kod pocztowy') %}{% endif %}
{% if not company.address_city %}{% set _ = missing.append('Miasto') %}{% endif %}
{% if not company.website %}{% set _ = missing.append('Strona WWW') %}{% endif %}
{% if missing %}
<div class="missing-data">
<h4>Brakujące dane:</h4>
<ul>
{% for field in missing %}
<li>{{ field }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% if pending_request %}
<div class="pending-alert">
<h3>Masz już oczekujące zgłoszenie</h3>
<p>Twoje poprzednie zgłoszenie z dnia {{ pending_request.created_at.strftime('%Y-%m-%d') }} jest w trakcie rozpatrywania.</p>
<p><a href="{{ url_for('index') }}">Wróć na stronę główną</a></p>
</div>
{% else %}
<div class="form-section">
<h2>Wprowadź NIP firmy</h2>
<form method="POST" id="dataRequestForm">
<div class="form-group">
<label>NIP firmy</label>
<div class="nip-lookup">
<input type="text" class="form-control" id="nipInput" name="nip"
value="{{ company.nip or '' }}"
placeholder="0000000000" maxlength="10" pattern="\d{10}" required>
<button type="button" class="btn-lookup" id="btnLookup">
Sprawdź
</button>
</div>
</div>
<div id="registryPreview" class="registry-preview" style="display: none;">
<h4>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22,4 12,14.01 9,11.01"/>
</svg>
Dane z rejestru <span id="registrySource">KRS</span>
</h4>
<div class="registry-data" id="registryData"></div>
</div>
<div class="form-group">
<label>Notatka (opcjonalnie)</label>
<textarea class="form-control" name="user_note" rows="3"
placeholder="Dodatkowe informacje dla administratora..."></textarea>
</div>
<button type="submit" class="btn-submit" id="btnSubmit" disabled>
Wyślij zgłoszenie do zatwierdzenia
</button>
</form>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
{% if not pending_request %}
const nipInput = document.getElementById('nipInput');
const btnLookup = document.getElementById('btnLookup');
const btnSubmit = document.getElementById('btnSubmit');
const registryPreview = document.getElementById('registryPreview');
const registrySource = document.getElementById('registrySource');
const registryData = document.getElementById('registryData');
let hasValidNip = false;
btnLookup.addEventListener('click', async function() {
const nip = nipInput.value.replace(/[\s-]/g, '');
if (nip.length !== 10 || !/^\d+$/.test(nip)) {
alert('NIP musi mieć 10 cyfr');
return;
}
btnLookup.disabled = true;
btnLookup.innerHTML = '<span class="loading-spinner"></span>';
try {
const response = await fetch('/api/membership/lookup-nip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nip: nip })
});
const result = await response.json();
if (result.success && result.data) {
registrySource.textContent = result.source;
registryPreview.classList.add('success');
registryPreview.style.display = 'block';
const data = result.data;
registryData.innerHTML = `
<div><strong>Nazwa:</strong> ${data.name || '-'}</div>
<div><strong>Adres:</strong> ${data.address_postal_code || ''} ${data.address_city || ''}</div>
${data.regon ? `<div><strong>REGON:</strong> ${data.regon}</div>` : ''}
${data.krs ? `<div><strong>KRS:</strong> ${data.krs}</div>` : ''}
`;
hasValidNip = true;
btnSubmit.disabled = false;
} else {
registryPreview.classList.remove('success');
registryPreview.style.display = 'block';
registryData.innerHTML = '<p>Firma nie została znaleziona. Sprawdź poprawność NIP.</p>';
hasValidNip = false;
btnSubmit.disabled = true;
}
} catch (error) {
console.error('Lookup error:', error);
alert('Błąd podczas sprawdzania NIP');
} finally {
btnLookup.disabled = false;
btnLookup.innerHTML = 'Sprawdź';
}
});
nipInput.addEventListener('input', function() {
hasValidNip = false;
btnSubmit.disabled = true;
registryPreview.style.display = 'none';
});
document.getElementById('dataRequestForm').addEventListener('submit', function(e) {
if (!hasValidNip) {
e.preventDefault();
alert('Najpierw sprawdź NIP w rejestrze');
}
});
{% endif %}
{% endblock %}

View File

@ -0,0 +1,370 @@
{% extends "base.html" %}
{% block title %}Status Deklaracji - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.status-container {
max-width: 800px;
margin: 0 auto;
}
.status-header {
margin-bottom: var(--spacing-2xl);
}
.status-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
margin: 0 0 var(--spacing-sm) 0;
}
.no-applications {
background: var(--surface);
padding: var(--spacing-2xl);
border-radius: var(--radius-lg);
text-align: center;
}
.no-applications p {
color: var(--text-secondary);
margin-bottom: var(--spacing-lg);
}
.btn-apply {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-md) var(--spacing-xl);
background: var(--primary);
color: white;
text-decoration: none;
border-radius: var(--radius);
font-weight: 500;
transition: var(--transition);
}
.btn-apply:hover {
opacity: 0.9;
}
.application-card {
background: var(--surface);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-lg);
}
.application-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-lg);
}
.application-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.application-nip {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.status-badge {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-md);
border-radius: var(--radius-full);
font-size: var(--font-size-sm);
font-weight: 500;
}
.status-draft {
background: var(--warning-light);
color: var(--warning);
}
.status-submitted {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.status-under_review {
background: rgba(168, 85, 247, 0.1);
color: #a855f7;
}
.status-changes_requested {
background: rgba(251, 146, 60, 0.1);
color: #fb923c;
}
.status-approved {
background: var(--success-light);
color: var(--success);
}
.status-rejected {
background: var(--error-light);
color: var(--error);
}
.application-dates {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
padding: var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
}
.date-item {
font-size: var(--font-size-sm);
}
.date-label {
color: var(--text-secondary);
}
.date-value {
font-weight: 500;
color: var(--text-primary);
}
.review-comment {
background: var(--background);
border-left: 4px solid var(--warning);
padding: var(--spacing-md);
border-radius: var(--radius);
margin-bottom: var(--spacing-lg);
}
.review-comment.rejected {
border-color: var(--error);
}
.review-comment h4 {
margin: 0 0 var(--spacing-sm) 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.review-comment p {
margin: 0;
color: var(--text-primary);
}
.application-actions {
display: flex;
gap: var(--spacing-md);
}
.btn-continue {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-lg);
background: var(--primary);
color: white;
text-decoration: none;
border-radius: var(--radius);
font-weight: 500;
transition: var(--transition);
}
.btn-continue:hover {
opacity: 0.9;
}
.member-badge {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-lg);
background: var(--success-light);
color: var(--success);
border-radius: var(--radius);
font-weight: 500;
}
.timeline {
position: relative;
padding-left: var(--spacing-xl);
}
.timeline::before {
content: '';
position: absolute;
left: 8px;
top: 0;
bottom: 0;
width: 2px;
background: var(--border);
}
.timeline-item {
position: relative;
padding-bottom: var(--spacing-lg);
}
.timeline-item::before {
content: '';
position: absolute;
left: calc(-1 * var(--spacing-xl) + 4px);
top: 4px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--border);
}
.timeline-item.completed::before {
background: var(--success);
}
.timeline-item.current::before {
background: var(--primary);
box-shadow: 0 0 0 4px rgba(var(--primary-rgb), 0.2);
}
.timeline-label {
font-weight: 500;
color: var(--text-primary);
}
.timeline-date {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
</style>
{% endblock %}
{% block content %}
<div class="status-container">
<div class="status-header">
<h1>Moje deklaracje członkowskie</h1>
<p class="text-muted">Historia Twoich zgłoszeń do Izby NORDA</p>
</div>
{% if not applications %}
<div class="no-applications">
<p>Nie masz jeszcze żadnych deklaracji członkowskich.</p>
<a href="{{ url_for('membership.apply') }}" class="btn-apply">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Złóż deklarację
</a>
</div>
{% else %}
{% for app in applications %}
<div class="application-card">
<div class="application-header">
<div>
<h2 class="application-title">{{ app.company_name or 'Nowa deklaracja' }}</h2>
{% if app.nip %}
<div class="application-nip">NIP: {{ app.nip }}</div>
{% endif %}
</div>
<span class="status-badge status-{{ app.status }}">
{{ app.status_label }}
</span>
</div>
<div class="application-dates">
<div class="date-item">
<div class="date-label">Utworzono</div>
<div class="date-value">{{ app.created_at.strftime('%Y-%m-%d %H:%M') if app.created_at else '-' }}</div>
</div>
{% if app.submitted_at %}
<div class="date-item">
<div class="date-label">Wysłano</div>
<div class="date-value">{{ app.submitted_at.strftime('%Y-%m-%d %H:%M') }}</div>
</div>
{% endif %}
{% if app.reviewed_at %}
<div class="date-item">
<div class="date-label">Rozpatrzono</div>
<div class="date-value">{{ app.reviewed_at.strftime('%Y-%m-%d %H:%M') }}</div>
</div>
{% endif %}
</div>
{% if app.review_comment and app.status in ['changes_requested', 'rejected'] %}
<div class="review-comment {% if app.status == 'rejected' %}rejected{% endif %}">
<h4>{% if app.status == 'changes_requested' %}Wymagane poprawki{% else %}Powód odrzucenia{% endif %}</h4>
<p>{{ app.review_comment }}</p>
</div>
{% endif %}
<div class="application-actions">
{% if app.status in ['draft', 'changes_requested'] %}
<a href="{{ url_for('membership.apply_step', step=1) }}" class="btn-continue">
{% if app.status == 'draft' %}
Kontynuuj wypełnianie
{% else %}
Wprowadź poprawki
{% endif %}
</a>
{% elif app.status == 'approved' %}
<div class="member-badge">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22,4 12,14.01 9,11.01"/>
</svg>
{% if app.member_number %}Nr członkowski: {{ app.member_number }}{% else %}Członek Izby NORDA{% endif %}
</div>
{% endif %}
</div>
{% if app.status != 'draft' %}
<div class="timeline" style="margin-top: var(--spacing-xl);">
<div class="timeline-item completed">
<div class="timeline-label">Złożono deklarację</div>
<div class="timeline-date">{{ app.submitted_at.strftime('%Y-%m-%d') if app.submitted_at else '-' }}</div>
</div>
{% if app.status in ['under_review', 'approved', 'rejected', 'changes_requested'] %}
<div class="timeline-item {% if app.status == 'under_review' %}current{% else %}completed{% endif %}">
<div class="timeline-label">W trakcie rozpatrywania</div>
</div>
{% endif %}
{% if app.status == 'changes_requested' %}
<div class="timeline-item current">
<div class="timeline-label">Wymagane poprawki</div>
<div class="timeline-date">{{ app.reviewed_at.strftime('%Y-%m-%d') if app.reviewed_at else '' }}</div>
</div>
{% endif %}
{% if app.status == 'approved' %}
<div class="timeline-item completed">
<div class="timeline-label">Zatwierdzono</div>
<div class="timeline-date">{{ app.reviewed_at.strftime('%Y-%m-%d') if app.reviewed_at else '' }}</div>
</div>
{% endif %}
{% if app.status == 'rejected' %}
<div class="timeline-item" style="opacity: 0.5;">
<div class="timeline-label">Odrzucono</div>
<div class="timeline-date">{{ app.reviewed_at.strftime('%Y-%m-%d') if app.reviewed_at else '' }}</div>
</div>
{% endif %}
</div>
{% endif %}
</div>
{% endfor %}
{% endif %}
</div>
{% endblock %}