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:
parent
1f806b66b0
commit
e733d26e36
@ -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
|
||||
# ============================================================
|
||||
|
||||
@ -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)
|
||||
# ============================================================
|
||||
|
||||
@ -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'),
|
||||
|
||||
23
database/migrations/043_membership_proposed_changes.sql
Normal file
23
database/migrations/043_membership_proposed_changes.sql
Normal 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';
|
||||
@ -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')">×</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');
|
||||
|
||||
509
templates/membership/review_changes.html
Normal file
509
templates/membership/review_changes.html
Normal 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 %}
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user