feat: Add user approval workflow for registry data changes

When admin proposes changes from KRS/CEIDG registry, the application
now goes to 'pending_user_approval' status. User must review and
accept/reject proposed changes before final approval.

Changes:
- New status: pending_user_approval
- New fields: proposed_changes, proposed_changes_at, proposed_changes_by_id
- Admin endpoint: POST /admin/membership/<id>/propose-changes
- User endpoints: GET/POST /membership/review-changes/<id>/accept|reject
- New template: templates/membership/review_changes.html
- Migration: 043_membership_proposed_changes.sql

Workflow: submitted → under_review → pending_user_approval → under_review → approved

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-01 14:56:16 +01:00
parent 1f806b66b0
commit e733d26e36
7 changed files with 890 additions and 56 deletions

View File

@ -372,10 +372,13 @@ def admin_membership_start_review(app_id):
db.close()
@bp.route('/membership/<int:app_id>/update-from-registry', methods=['POST'])
@bp.route('/membership/<int:app_id>/propose-changes', methods=['POST'])
@login_required
def admin_membership_update_from_registry(app_id):
"""Update membership application with data from KRS/CEIDG registry."""
def admin_membership_propose_changes(app_id):
"""
Propose changes from registry data for user approval.
Instead of directly updating, save proposed changes and notify user.
"""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
@ -388,72 +391,104 @@ def admin_membership_update_from_registry(app_id):
if application.status not in ['submitted', 'under_review']:
return jsonify({
'success': False,
'error': 'Można aktualizować tylko deklaracje oczekujące na rozpatrzenie'
'error': 'Można proponować zmiany tylko dla deklaracji oczekujących na rozpatrzenie'
}), 400
data = request.get_json() or {}
updated_fields = []
registry_data = data.get('registry_data', {})
comment = data.get('comment', '').strip()
# Update fields from registry data
if data.get('name'):
application.company_name = data['name']
updated_fields.append('company_name')
# Build proposed changes with old and new values
proposed_changes = {}
if data.get('address_postal_code'):
application.address_postal_code = data['address_postal_code']
updated_fields.append('address_postal_code')
field_mappings = {
'name': ('company_name', application.company_name),
'address_postal_code': ('address_postal_code', application.address_postal_code),
'address_city': ('address_city', application.address_city),
'address_street': ('address_street', application.address_street),
'address_number': ('address_number', application.address_number),
'regon': ('regon', application.regon),
'krs': ('krs_number', application.krs_number),
'founded_date': ('founded_date', str(application.founded_date) if application.founded_date else None),
}
if data.get('address_city'):
application.address_city = data['address_city']
updated_fields.append('address_city')
for registry_key, (app_field, old_value) in field_mappings.items():
new_value = registry_data.get(registry_key)
if new_value and str(new_value) != str(old_value or ''):
proposed_changes[app_field] = {
'old': old_value,
'new': new_value,
'label': _get_field_label(app_field)
}
if data.get('address_street'):
application.address_street = data['address_street']
updated_fields.append('address_street')
if not proposed_changes:
return jsonify({
'success': False,
'error': 'Brak zmian do zaproponowania - dane są identyczne'
}), 400
if data.get('address_number'):
application.address_number = data['address_number']
updated_fields.append('address_number')
# Save proposed changes
application.proposed_changes = proposed_changes
application.proposed_changes_at = datetime.now()
application.proposed_changes_by_id = current_user.id
application.proposed_changes_comment = comment
application.status = 'pending_user_approval'
if data.get('regon'):
application.regon = data['regon']
updated_fields.append('regon')
if data.get('krs'):
application.krs_number = data['krs']
updated_fields.append('krs_number')
if data.get('founded_date'):
try:
from datetime import datetime as dt
application.founded_date = dt.strptime(data['founded_date'], '%Y-%m-%d').date()
updated_fields.append('founded_date')
except (ValueError, TypeError):
pass
# Store registry data
application.registry_data = data
application.registry_source = data.get('source', 'KRS')
# Store registry data for reference
application.registry_data = registry_data
application.registry_source = registry_data.get('source', 'KRS')
db.commit()
logger.info(
f"Membership application {app_id} updated from registry by {current_user.email}. "
f"Updated fields: {updated_fields}"
f"Membership application {app_id}: changes proposed by {current_user.email}. "
f"Proposed fields: {list(proposed_changes.keys())}"
)
return jsonify({
'success': True,
'updated_fields': updated_fields
'proposed_changes': proposed_changes,
'message': 'Zmiany zostały zaproponowane. Użytkownik otrzyma powiadomienie.'
})
except Exception as e:
db.rollback()
logger.error(f"Error updating application {app_id} from registry: {e}")
logger.error(f"Error proposing changes for application {app_id}: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
def _get_field_label(field_name):
"""Get human-readable label for field name."""
labels = {
'company_name': 'Nazwa firmy',
'address_postal_code': 'Kod pocztowy',
'address_city': 'Miejscowość',
'address_street': 'Ulica',
'address_number': 'Numer budynku/lokalu',
'regon': 'REGON',
'krs_number': 'Numer KRS',
'founded_date': 'Data założenia',
}
return labels.get(field_name, field_name)
@bp.route('/membership/<int:app_id>/update-from-registry', methods=['POST'])
@login_required
def admin_membership_update_from_registry(app_id):
"""
[DEPRECATED - use propose-changes instead]
Direct update is now replaced by propose-changes workflow.
This endpoint is kept for backward compatibility but redirects to propose-changes.
"""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
# Redirect to new workflow
data = request.get_json() or {}
return admin_membership_propose_changes.__wrapped__(app_id)
# ============================================================
# COMPANY DATA REQUESTS
# ============================================================

View File

@ -37,10 +37,14 @@ def apply():
# 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'])
MembershipApplication.status.in_(['draft', 'submitted', 'under_review', 'pending_user_approval', 'changes_requested'])
).first()
if existing:
# If pending user approval, redirect to review page
if existing.status == 'pending_user_approval':
flash('Administrator zaproponował zmiany do Twojej deklaracji. Przejrzyj je i zaakceptuj lub odrzuć.', 'info')
return redirect(url_for('membership.review_changes', app_id=existing.id))
# If already submitted, redirect to status page
if existing.status in ['submitted', 'under_review']:
flash('Masz już wysłaną deklarację oczekującą na rozpatrzenie.', 'info')
@ -253,6 +257,164 @@ def status():
db.close()
# ============================================================
# PROPOSED CHANGES REVIEW (User approval workflow)
# ============================================================
@bp.route('/review-changes/<int:app_id>')
@login_required
def review_changes(app_id):
"""
Page for user to review proposed changes from admin.
"""
db = SessionLocal()
try:
application = db.query(MembershipApplication).get(app_id)
if not application:
flash('Nie znaleziono deklaracji.', 'error')
return redirect(url_for('membership.status'))
# Verify ownership
if application.user_id != current_user.id:
flash('Brak dostępu do tej deklaracji.', 'error')
return redirect(url_for('membership.status'))
# Check status
if application.status != 'pending_user_approval':
flash('Ta deklaracja nie wymaga przeglądu zmian.', 'info')
return redirect(url_for('membership.status'))
return render_template(
'membership/review_changes.html',
application=application
)
finally:
db.close()
@bp.route('/review-changes/<int:app_id>/accept', methods=['POST'])
@login_required
def accept_changes(app_id):
"""
User accepts proposed changes from admin.
Changes are applied and application returns to under_review.
"""
db = SessionLocal()
try:
application = db.query(MembershipApplication).get(app_id)
if not application:
return jsonify({'success': False, 'error': 'Nie znaleziono deklaracji'}), 404
if application.user_id != current_user.id:
return jsonify({'success': False, 'error': 'Brak dostępu'}), 403
if application.status != 'pending_user_approval':
return jsonify({
'success': False,
'error': 'Ta deklaracja nie oczekuje na akceptację zmian'
}), 400
proposed = application.proposed_changes or {}
# Apply all proposed changes
applied_fields = []
for field_name, change in proposed.items():
new_value = change.get('new')
if new_value is not None:
if field_name == 'founded_date':
try:
setattr(application, field_name, datetime.strptime(new_value, '%Y-%m-%d').date())
except (ValueError, TypeError):
setattr(application, field_name, None)
else:
setattr(application, field_name, new_value)
applied_fields.append(field_name)
# Update status - back to under_review for final approval
application.status = 'under_review'
application.updated_at = datetime.now()
db.commit()
logger.info(
f"User {current_user.id} accepted proposed changes for application {app_id}. "
f"Applied fields: {applied_fields}"
)
return jsonify({
'success': True,
'applied_fields': applied_fields,
'message': 'Zmiany zostały zaakceptowane. Twoja deklaracja wróciła do rozpatrzenia.'
})
except Exception as e:
db.rollback()
logger.error(f"Error accepting changes for application {app_id}: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@bp.route('/review-changes/<int:app_id>/reject', methods=['POST'])
@login_required
def reject_changes(app_id):
"""
User rejects proposed changes from admin.
Application returns to under_review with original data.
"""
db = SessionLocal()
try:
application = db.query(MembershipApplication).get(app_id)
if not application:
return jsonify({'success': False, 'error': 'Nie znaleziono deklaracji'}), 404
if application.user_id != current_user.id:
return jsonify({'success': False, 'error': 'Brak dostępu'}), 403
if application.status != 'pending_user_approval':
return jsonify({
'success': False,
'error': 'Ta deklaracja nie oczekuje na akceptację zmian'
}), 400
data = request.get_json() or {}
user_comment = data.get('comment', '').strip()
# Clear proposed changes - keep original data
application.proposed_changes = None
application.proposed_changes_at = None
application.proposed_changes_by_id = None
# Add user's rejection reason to review_comment
if user_comment:
existing_comment = application.review_comment or ''
application.review_comment = f"{existing_comment}\n\n[Użytkownik odrzucił propozycje zmian: {user_comment}]".strip()
# Back to under_review with original data
application.status = 'under_review'
application.updated_at = datetime.now()
db.commit()
logger.info(
f"User {current_user.id} rejected proposed changes for application {app_id}. "
f"Reason: {user_comment or 'brak'}"
)
return jsonify({
'success': True,
'message': 'Odrzuciłeś proponowane zmiany. Twoja deklaracja zachowuje oryginalne dane.'
})
except Exception as e:
db.rollback()
logger.error(f"Error rejecting changes for application {app_id}: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
# ============================================================
# COMPANY DATA REQUEST (Modal-based)
# ============================================================

View File

@ -4274,6 +4274,12 @@ class MembershipApplication(Base):
reviewed_by_id = Column(Integer, ForeignKey('users.id'))
review_comment = Column(Text)
# Proposed changes workflow (admin → user approval)
proposed_changes = Column(JSONBType) # {"field": {"old": x, "new": y}, ...}
proposed_changes_at = Column(DateTime)
proposed_changes_by_id = Column(Integer, ForeignKey('users.id'))
proposed_changes_comment = Column(Text)
# Po zatwierdzeniu
company_id = Column(Integer, ForeignKey('companies.id'))
member_number = Column(String(20))
@ -4281,6 +4287,7 @@ class MembershipApplication(Base):
# Relationships
user = relationship('User', foreign_keys=[user_id], backref='membership_applications')
reviewed_by = relationship('User', foreign_keys=[reviewed_by_id])
proposed_by = relationship('User', foreign_keys=[proposed_changes_by_id])
company = relationship('Company')
# Constants
@ -4288,6 +4295,7 @@ class MembershipApplication(Base):
('draft', 'Szkic'),
('submitted', 'Wysłane'),
('under_review', 'W trakcie rozpatrywania'),
('pending_user_approval', 'Oczekuje na akceptację użytkownika'),
('changes_requested', 'Prośba o poprawki'),
('approved', 'Zatwierdzone'),
('rejected', 'Odrzucone'),

View File

@ -0,0 +1,23 @@
-- Migration: 043_membership_proposed_changes.sql
-- Date: 2026-02-01
-- Description: Add workflow for admin-proposed changes requiring user approval
-- Add new columns for proposed changes workflow
ALTER TABLE membership_applications
ADD COLUMN IF NOT EXISTS proposed_changes JSONB,
ADD COLUMN IF NOT EXISTS proposed_changes_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS proposed_changes_by_id INTEGER REFERENCES users(id),
ADD COLUMN IF NOT EXISTS proposed_changes_comment TEXT;
-- Add index for efficient querying by status
CREATE INDEX IF NOT EXISTS idx_membership_proposed_status
ON membership_applications(status)
WHERE status = 'pending_user_approval';
-- Grant permissions
GRANT ALL ON TABLE membership_applications TO nordabiz_app;
COMMENT ON COLUMN membership_applications.proposed_changes IS 'JSONB containing proposed field changes from registry data';
COMMENT ON COLUMN membership_applications.proposed_changes_at IS 'When the changes were proposed by admin';
COMMENT ON COLUMN membership_applications.proposed_changes_by_id IS 'Admin user who proposed the changes';
COMMENT ON COLUMN membership_applications.proposed_changes_comment IS 'Optional comment from admin explaining the changes';

View File

@ -43,6 +43,7 @@
.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-pending_user_approval { background: rgba(14, 165, 233, 0.1); color: #0ea5e9; }
.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); }
@ -513,9 +514,12 @@
<h5 id="registryResultTitle">Dane z rejestru</h5>
<div class="registry-diff" id="registryDiff"></div>
<div style="margin-top: var(--spacing-md);">
<button class="btn-action btn-secondary" onclick="applyRegistryData()" id="btnApplyRegistry" style="display: none;">
Zastosuj dane z rejestru
<button class="btn-action btn-secondary" onclick="openProposeModal()" id="btnApplyRegistry" style="display: none;">
Zaproponuj zmiany użytkownikowi
</button>
<p style="font-size: var(--font-size-xs); color: var(--text-secondary); margin-top: var(--spacing-xs);">
Użytkownik otrzyma powiadomienie i będzie mógł zaakceptować lub odrzucić proponowane zmiany.
</p>
</div>
</div>
</div>
@ -762,6 +766,18 @@
{% endif %}
</div>
{% elif application.status == 'pending_user_approval' %}
<div class="alert" style="background: rgba(14, 165, 233, 0.1); border: 1px solid rgba(14, 165, 233, 0.3); color: #0ea5e9;">
<strong>Oczekuje na akceptację użytkownika</strong>
<br>Zaproponowano zmiany danych z rejestru. Użytkownik musi je zaakceptować lub odrzucić.
{% if application.proposed_changes_at %}
<br><small>Zaproponowano: {{ application.proposed_changes_at.strftime('%Y-%m-%d %H:%M') }}</small>
{% endif %}
{% if application.proposed_changes_comment %}
<br><small>Komentarz: {{ application.proposed_changes_comment }}</small>
{% endif %}
</div>
{% elif application.status == 'changes_requested' %}
<div class="alert alert-warning">
Oczekuje na poprawki od zgłaszającego.
@ -851,6 +867,29 @@
</div>
</div>
</div>
<!-- Modal: Zaproponuj zmiany z rejestru -->
<div class="modal" id="proposeModal">
<div class="modal-content">
<div class="modal-header">
<h3>Zaproponuj zmiany użytkownikowi</h3>
<button class="modal-close" onclick="closeModal('proposeModal')">&times;</button>
</div>
<p style="margin-bottom: var(--spacing-md);">
Użytkownik otrzyma powiadomienie o proponowanych zmianach i będzie mógł je zaakceptować lub odrzucić.
Dopiero po akceptacji zmiany zostaną zastosowane.
</p>
<div id="proposedChangesSummary" style="background: var(--background); padding: var(--spacing-md); border-radius: var(--radius); margin-bottom: var(--spacing-md); font-size: var(--font-size-sm);"></div>
<div class="form-group">
<label>Komentarz dla użytkownika (opcjonalnie)</label>
<textarea class="form-control" id="proposeComment" rows="2" placeholder="Wyjaśnij dlaczego proponujesz te zmiany..."></textarea>
</div>
<div class="modal-buttons">
<button class="btn-cancel" onclick="closeModal('proposeModal')">Anuluj</button>
<button class="btn-action btn-review" onclick="proposeChanges()">Wyślij do użytkownika</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
@ -980,29 +1019,71 @@ async function lookupRegistry() {
}
}
async function applyRegistryData() {
function openProposeModal() {
if (!registryData) {
alert('Brak danych do zastosowania');
alert('Brak danych do zaproponowania');
return;
}
if (!confirm('Czy na pewno chcesz zaktualizować dane deklaracji danymi z rejestru?')) {
// Show summary of proposed changes
const currentData = {
company_name: '{{ application.company_name|e }}',
address_postal_code: '{{ application.address_postal_code|e }}',
address_city: '{{ application.address_city|e }}',
address_street: '{{ application.address_street|e }}',
address_number: '{{ application.address_number|e }}',
regon: '{{ application.regon|e }}',
krs_number: '{{ application.krs_number|e }}'
};
const fields = [
{ key: 'name', label: 'Nazwa', current: currentData.company_name },
{ key: 'address_postal_code', label: 'Kod pocztowy', current: currentData.address_postal_code },
{ key: 'address_city', label: 'Miejscowość', current: currentData.address_city },
{ key: 'address_street', label: 'Ulica', current: currentData.address_street },
{ key: 'address_number', label: 'Nr budynku', current: currentData.address_number },
{ key: 'regon', label: 'REGON', current: currentData.regon },
{ key: 'krs', label: 'KRS', current: currentData.krs_number }
];
let summaryHtml = '<strong>Proponowane zmiany:</strong><ul style="margin: var(--spacing-sm) 0; padding-left: var(--spacing-lg);">';
fields.forEach(f => {
const newVal = registryData[f.key] || '';
const oldVal = f.current || '';
if (newVal && newVal !== oldVal) {
summaryHtml += `<li><strong>${f.label}:</strong> ${oldVal || '(brak)'} → ${newVal}</li>`;
}
});
summaryHtml += '</ul>';
document.getElementById('proposedChangesSummary').innerHTML = summaryHtml;
document.getElementById('proposeModal').classList.add('active');
}
async function proposeChanges() {
if (!registryData) {
alert('Brak danych do zaproponowania');
return;
}
const comment = document.getElementById('proposeComment').value.trim();
try {
const response = await fetch(`/admin/membership/${appId}/update-from-registry`, {
const response = await fetch(`/admin/membership/${appId}/propose-changes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify(registryData)
body: JSON.stringify({
registry_data: registryData,
comment: comment
})
});
const result = await response.json();
if (result.success) {
alert('Dane zostały zaktualizowane');
alert('Zmiany zostały zaproponowane użytkownikowi. Status deklaracji zmieniono na "Oczekuje na akceptację użytkownika".');
location.reload();
} else {
alert(result.error || 'Błąd aktualizacji');
alert(result.error || 'Błąd');
}
} catch (e) {
alert('Błąd połączenia');

View File

@ -0,0 +1,509 @@
{% extends "base.html" %}
{% block title %}Przegląd proponowanych zmian - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.review-container {
max-width: 800px;
margin: 0 auto;
}
.review-header {
margin-bottom: var(--spacing-2xl);
}
.review-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
margin: 0 0 var(--spacing-sm) 0;
}
.review-header p {
color: var(--text-secondary);
margin: 0;
}
.review-card {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
}
.review-card-header {
background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);
color: white;
padding: var(--spacing-xl);
}
.review-card-header h2 {
margin: 0 0 var(--spacing-sm) 0;
font-size: var(--font-size-lg);
}
.review-card-header .company-info {
opacity: 0.9;
font-size: var(--font-size-sm);
}
.review-card-body {
padding: var(--spacing-xl);
}
.info-box {
background: rgba(14, 165, 233, 0.1);
border: 1px solid rgba(14, 165, 233, 0.3);
border-radius: var(--radius);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.info-box p {
margin: 0;
color: var(--text-primary);
font-size: var(--font-size-sm);
}
.info-box strong {
color: #0ea5e9;
}
.changes-list {
margin-bottom: var(--spacing-xl);
}
.change-item {
background: var(--background);
border-radius: var(--radius);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-md);
border-left: 4px solid var(--primary);
}
.change-label {
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.change-label svg {
color: var(--primary);
}
.change-values {
display: grid;
grid-template-columns: 1fr 40px 1fr;
align-items: center;
gap: var(--spacing-md);
margin-top: var(--spacing-md);
}
.old-value, .new-value {
padding: var(--spacing-md);
border-radius: var(--radius);
font-size: var(--font-size-sm);
}
.old-value {
background: rgba(239, 68, 68, 0.1);
border: 1px dashed rgba(239, 68, 68, 0.3);
color: var(--text-secondary);
text-decoration: line-through;
}
.new-value {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
color: var(--text-primary);
font-weight: 500;
}
.arrow-icon {
color: var(--text-secondary);
text-align: center;
}
.value-empty {
color: var(--text-secondary);
font-style: italic;
}
.admin-comment {
background: var(--background);
border-radius: var(--radius);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.admin-comment-header {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.admin-comment p {
margin: 0;
color: var(--text-primary);
}
.actions-section {
border-top: 1px solid var(--border);
padding-top: var(--spacing-xl);
}
.actions-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-lg);
}
.action-card {
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
text-align: center;
}
.action-card.accept {
background: rgba(34, 197, 94, 0.1);
border: 2px solid rgba(34, 197, 94, 0.3);
}
.action-card.reject {
background: rgba(239, 68, 68, 0.05);
border: 2px solid rgba(239, 68, 68, 0.2);
}
.action-card h3 {
margin: 0 0 var(--spacing-sm) 0;
font-size: var(--font-size-lg);
}
.action-card.accept h3 {
color: #16a34a;
}
.action-card.reject h3 {
color: #dc2626;
}
.action-card p {
margin: 0 0 var(--spacing-lg) 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.btn-action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
padding: var(--spacing-md) var(--spacing-xl);
border: none;
border-radius: var(--radius);
font-weight: 600;
cursor: pointer;
transition: var(--transition);
width: 100%;
}
.btn-accept {
background: #16a34a;
color: white;
}
.btn-accept:hover {
background: #15803d;
}
.btn-reject {
background: transparent;
color: #dc2626;
border: 2px solid #dc2626;
}
.btn-reject:hover {
background: rgba(239, 68, 68, 0.1);
}
.reject-comment {
margin-top: var(--spacing-md);
display: none;
}
.reject-comment.show {
display: block;
}
.reject-comment textarea {
width: 100%;
padding: var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
resize: vertical;
min-height: 80px;
font-size: var(--font-size-sm);
}
.reject-comment textarea:focus {
outline: none;
border-color: var(--primary);
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
}
.loading-overlay.show {
display: flex;
}
.loading-box {
background: var(--surface);
padding: var(--spacing-2xl);
border-radius: var(--radius-lg);
text-align: center;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto var(--spacing-md);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 600px) {
.actions-grid {
grid-template-columns: 1fr;
}
.change-values {
grid-template-columns: 1fr;
}
.arrow-icon {
transform: rotate(90deg);
}
}
</style>
{% endblock %}
{% block content %}
<div class="review-container">
<div class="review-header">
<h1>Przegląd proponowanych zmian</h1>
<p>Administrator zaproponował aktualizację danych Twojej deklaracji na podstawie rejestru KRS/CEIDG</p>
</div>
<div class="review-card">
<div class="review-card-header">
<h2>{{ application.company_name }}</h2>
<div class="company-info">
NIP: {{ application.nip }}
{% if application.krs_number %}| KRS: {{ application.krs_number }}{% endif %}
</div>
</div>
<div class="review-card-body">
<div class="info-box">
<p>
<strong>Dane pobrane z rejestru:</strong>
Poniższe zmiany zostały zaproponowane na podstawie oficjalnych danych z
{% if application.registry_source == 'KRS' %}
Krajowego Rejestru Sądowego (KRS).
{% elif application.registry_source == 'CEIDG' %}
Centralnej Ewidencji i Informacji o Działalności Gospodarczej (CEIDG).
{% else %}
rejestru publicznego.
{% endif %}
Przejrzyj je i zdecyduj, czy chcesz je zaakceptować.
</p>
</div>
{% if application.proposed_changes %}
<div class="changes-list">
<h3 style="margin-bottom: var(--spacing-lg); font-size: var(--font-size-base);">Proponowane zmiany:</h3>
{% for field_name, change in application.proposed_changes.items() %}
<div class="change-item">
<div class="change-label">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
{{ change.label }}
</div>
<div class="change-values">
<div class="old-value">
{% if change.old %}{{ change.old }}{% else %}<span class="value-empty">(brak danych)</span>{% endif %}
</div>
<div class="arrow-icon"></div>
<div class="new-value">
{{ change.new }}
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if application.proposed_changes_comment %}
<div class="admin-comment">
<div class="admin-comment-header">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
</svg>
Komentarz administratora
</div>
<p>{{ application.proposed_changes_comment }}</p>
</div>
{% endif %}
<div class="actions-section">
<div class="actions-grid">
<div class="action-card accept">
<h3>Akceptuję zmiany</h3>
<p>Dane zostaną zaktualizowane zgodnie z rejestrem. Deklaracja wróci do rozpatrzenia.</p>
<button type="button" class="btn-action btn-accept" onclick="acceptChanges()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
Zaakceptuj zmiany
</button>
</div>
<div class="action-card reject">
<h3>Odrzucam zmiany</h3>
<p>Dane pozostaną bez zmian. Deklaracja wróci do rozpatrzenia z oryginalnymi danymi.</p>
<button type="button" class="btn-action btn-reject" onclick="showRejectForm()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
Odrzuć zmiany
</button>
<div class="reject-comment" id="rejectComment">
<textarea id="rejectReason" placeholder="Opcjonalnie: podaj powód odrzucenia zmian..."></textarea>
<button type="button" class="btn-action btn-reject" style="margin-top: var(--spacing-sm);" onclick="rejectChanges()">
Potwierdź odrzucenie
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="loading-overlay" id="loadingOverlay">
<div class="loading-box">
<div class="loading-spinner"></div>
<p>Przetwarzanie...</p>
</div>
</div>
{% endblock %}
{% block extra_js %}
const csrfToken = '{{ csrf_token() }}';
const appId = {{ application.id }};
function showLoading() {
document.getElementById('loadingOverlay').classList.add('show');
}
function hideLoading() {
document.getElementById('loadingOverlay').classList.remove('show');
}
function showRejectForm() {
document.getElementById('rejectComment').classList.toggle('show');
}
async function acceptChanges() {
if (!confirm('Czy na pewno chcesz zaakceptować proponowane zmiany?')) {
return;
}
showLoading();
try {
const response = await fetch(`/membership/review-changes/${appId}/accept`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
alert('Zmiany zostały zaakceptowane. Twoja deklaracja wróciła do rozpatrzenia.');
window.location.href = '{{ url_for("membership.status") }}';
} else {
alert('Błąd: ' + (data.error || 'Nieznany błąd'));
}
} catch (error) {
console.error('Error:', error);
alert('Wystąpił błąd połączenia. Spróbuj ponownie.');
} finally {
hideLoading();
}
}
async function rejectChanges() {
if (!confirm('Czy na pewno chcesz odrzucić proponowane zmiany? Dane pozostaną bez zmian.')) {
return;
}
showLoading();
const comment = document.getElementById('rejectReason').value.trim();
try {
const response = await fetch(`/membership/review-changes/${appId}/reject`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ comment: comment })
});
const data = await response.json();
if (data.success) {
alert('Zmiany zostały odrzucone. Twoja deklaracja zachowuje oryginalne dane.');
window.location.href = '{{ url_for("membership.status") }}';
} else {
alert('Błąd: ' + (data.error || 'Nieznany błąd'));
}
} catch (error) {
console.error('Error:', error);
alert('Wystąpił błąd połączenia. Spróbuj ponownie.');
} finally {
hideLoading();
}
}
{% endblock %}

View File

@ -105,6 +105,11 @@
color: #fb923c;
}
.status-pending_user_approval {
background: rgba(14, 165, 233, 0.1);
color: #0ea5e9;
}
.status-approved {
background: var(--success-light);
color: var(--success);
@ -417,7 +422,11 @@
{% endif %}
<div class="application-actions">
{% if app.status in ['draft', 'changes_requested'] %}
{% if app.status == 'pending_user_approval' %}
<a href="{{ url_for('membership.review_changes', app_id=app.id) }}" class="btn-continue" style="background: #0ea5e9;">
Przejrzyj proponowane zmiany →
</a>
{% elif 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
@ -444,12 +453,19 @@
<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'] %}
{% if app.status in ['under_review', 'pending_user_approval', '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 == 'pending_user_approval' %}
<div class="timeline-item current">
<div class="timeline-label">Oczekuje na Twoją akceptację</div>
<div class="timeline-date">{{ app.proposed_changes_at.strftime('%Y-%m-%d') if app.proposed_changes_at else '' }}</div>
</div>
{% endif %}
{% if app.status == 'changes_requested' %}
<div class="timeline-item current">
<div class="timeline-label">Wymagane poprawki</div>