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:
parent
cc83186486
commit
0f8aca1435
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
549
blueprints/admin/routes_membership.py
Normal file
549
blueprints/admin/routes_membership.py
Normal 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()
|
||||
@ -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
|
||||
|
||||
558
blueprints/api/routes_membership.py
Normal file
558
blueprints/api/routes_membership.py
Normal 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()
|
||||
16
blueprints/membership/__init__.py
Normal file
16
blueprints/membership/__init__.py
Normal 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
|
||||
349
blueprints/membership/routes.py
Normal file
349
blueprints/membership/routes.py
Normal 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
|
||||
@ -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()
|
||||
|
||||
226
database.py
226
database.py
@ -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
|
||||
# ============================================================
|
||||
|
||||
183
database/migrations/042_membership_applications.sql
Normal file
183
database/migrations/042_membership_applications.sql
Normal 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
|
||||
-- ============================================================
|
||||
380
templates/admin/company_requests.html
Normal file
380
templates/admin/company_requests.html
Normal 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 %}
|
||||
282
templates/admin/membership.html
Normal file
282
templates/admin/membership.html
Normal 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 %}
|
||||
737
templates/admin/membership_detail.html
Normal file
737
templates/admin/membership_detail.html
Normal 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')">×</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')">×</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')">×</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 %}
|
||||
@ -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"/>
|
||||
|
||||
@ -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">
|
||||
|
||||
762
templates/membership/apply.html
Normal file
762
templates/membership/apply.html
Normal 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 %}
|
||||
365
templates/membership/data_request.html
Normal file
365
templates/membership/data_request.html
Normal 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 %}
|
||||
370
templates/membership/status.html
Normal file
370
templates/membership/status.html
Normal 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 %}
|
||||
Loading…
Reference in New Issue
Block a user