feat: company colleague picker + admin add person to paid events
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

- EventGuest.guest_type: 'member' (member rate) or 'external' (guest rate)
- Dropdown of company colleagues when adding member-type guest
- Manual entry option for members not on portal
- Admin payment panel: "Dodaj osobę" with "Dodaj + opłacone" shortcut
- Migration 064: guest_type column

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-04-08 11:48:46 +02:00
parent 6067492b1d
commit d06507a7c3
6 changed files with 259 additions and 7 deletions

View File

@ -1445,6 +1445,59 @@ def admin_event_payment_amount(event_id):
db.close()
@bp.route('/kalendarz/<int:event_id>/platnosci/dodaj', methods=['POST'])
@login_required
@role_required(SystemRole.OFFICE_MANAGER)
def admin_event_payment_add(event_id):
"""Dodaj osobę na wydarzenie (z panelu płatności)"""
db = SessionLocal()
try:
event = db.query(NordaEvent).filter(NordaEvent.id == event_id).first()
if not event or not event.is_paid:
return jsonify({'success': False, 'error': 'Wydarzenie nie istnieje lub nie jest płatne'}), 404
data = request.get_json() or {}
first_name = (data.get('first_name') or '').strip()
last_name = (data.get('last_name') or '').strip()
guest_type = data.get('guest_type', 'member')
mark_paid = data.get('mark_paid', False)
if not first_name and not last_name:
return jsonify({'success': False, 'error': 'Podaj imię i/lub nazwisko'}), 400
if guest_type not in ('member', 'external'):
guest_type = 'external'
amount = event.price_member if guest_type == 'member' else event.price_guest
guest = EventGuest(
event_id=event_id,
host_user_id=current_user.id,
first_name=first_name or None,
last_name=last_name or None,
guest_type=guest_type,
payment_amount=amount,
)
if mark_paid:
guest.payment_status = 'paid'
guest.payment_confirmed_by = current_user.id
guest.payment_confirmed_at = datetime.now()
db.add(guest)
db.commit()
name = f"{first_name} {last_name}".strip()
status_msg = ' (opłacone)' if mark_paid else ''
return jsonify({'success': True, 'message': f'Dodano: {name}{status_msg}'})
except Exception as e:
db.rollback()
logger.error(f"Error adding person to event: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
# ============================================================
# RELEASE NOTIFICATIONS
# ============================================================

View File

@ -418,16 +418,21 @@ def add_guest(event_id):
if not first_name and not last_name and not organization:
return jsonify({'success': False, 'error': 'Podaj przynajmniej imię, nazwisko lub firmę'}), 400
guest_type = (data.get('guest_type') or 'external').strip()
if guest_type not in ('external', 'member'):
guest_type = 'external'
guest = EventGuest(
event_id=event_id,
host_user_id=current_user.id,
first_name=first_name or None,
last_name=last_name or None,
organization=organization or None,
guest_type=guest_type,
)
# Auto-assign payment amount for paid events (guests always pay guest rate)
# Auto-assign payment amount based on guest type
if event.is_paid:
guest.payment_amount = event.price_guest
guest.payment_amount = event.price_member if guest_type == 'member' else event.price_guest
db.add(guest)
db.commit()
@ -522,6 +527,50 @@ def delete_guest(event_id, guest_id):
db.close()
@bp.route('/<int:event_id>/company-colleagues', methods=['GET'], endpoint='calendar_company_colleagues')
@login_required
def company_colleagues(event_id):
"""Pobierz listę kolegów z firmy do dropdownu przy dodawaniu gościa"""
if not current_user.company_id:
return jsonify([])
db = SessionLocal()
try:
from database import User
colleagues = db.query(User).filter(
User.company_id == current_user.company_id,
User.id != current_user.id,
User.is_active == True
).order_by(User.name).all()
# Check who is already registered
registered_ids = set(
a.user_id for a in db.query(EventAttendee).filter(EventAttendee.event_id == event_id).all()
)
# Check who is already added as guest
guest_names = set()
for g in db.query(EventGuest).filter(EventGuest.event_id == event_id).all():
guest_names.add(f"{g.first_name or ''} {g.last_name or ''}".strip().lower())
result = []
for c in colleagues:
name_parts = (c.name or '').split(' ', 1)
first = name_parts[0] if name_parts else ''
last = name_parts[1] if len(name_parts) > 1 else ''
already = c.id in registered_ids or c.name.lower() in guest_names if c.name else False
result.append({
'id': c.id,
'name': c.name or c.email.split('@')[0],
'first_name': first,
'last_name': last,
'already_registered': already,
})
return jsonify(result)
finally:
db.close()
@bp.route('/ical', endpoint='calendar_ical')
def ical_feed():
"""

View File

@ -2370,6 +2370,7 @@ class EventGuest(Base):
first_name = Column(String(100))
last_name = Column(String(100))
organization = Column(String(255))
guest_type = Column(String(20), default='external') # 'external' or 'member'
created_at = Column(DateTime, default=datetime.now, nullable=False)
# Payment tracking (for paid events)

View File

@ -0,0 +1,8 @@
-- Migration 064: Guest type — distinguish member vs external guests
-- Date: 2026-04-08
ALTER TABLE event_guests ADD COLUMN IF NOT EXISTS guest_type VARCHAR(20) DEFAULT 'external';
-- 'external' = gość spoza Izby (price_guest rate)
-- 'member' = osoba z firmy członkowskiej (price_member rate)
GRANT ALL ON TABLE event_guests TO nordabiz_app;

View File

@ -83,10 +83,39 @@
Powrót do listy wydarzeń
</a>
<div class="payments-header">
<div class="payments-header" style="display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: var(--spacing-md);">
<div>
<h1>Płatności — {{ event.title }}</h1>
<p class="text-muted">{{ event.event_date.strftime('%d.%m.%Y') }}{% if event.time_start %} o {{ event.time_start.strftime('%H:%M') }}{% endif %} · Członek: {{ "%.0f"|format(event.price_member) }} zł · Gość: {{ "%.0f"|format(event.price_guest) }} zł</p>
</div>
<button class="btn btn-primary" onclick="toggleAddPerson()" style="white-space: nowrap;">+ Dodaj osobę</button>
</div>
<div id="add-person-form" style="display: none; background: var(--surface); border-radius: var(--radius-lg); padding: var(--spacing-lg); box-shadow: var(--shadow); margin-bottom: var(--spacing-xl);">
<h3 style="margin: 0 0 var(--spacing-md);">Dodaj osobę na wydarzenie</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: var(--spacing-sm); align-items: end;">
<div>
<label style="font-size: var(--font-size-xs); color: var(--text-secondary);">Imię *</label>
<input type="text" id="add-first-name" class="form-control" maxlength="100" style="font-size: var(--font-size-sm);">
</div>
<div>
<label style="font-size: var(--font-size-xs); color: var(--text-secondary);">Nazwisko *</label>
<input type="text" id="add-last-name" class="form-control" maxlength="100" style="font-size: var(--font-size-sm);">
</div>
<div>
<label style="font-size: var(--font-size-xs); color: var(--text-secondary);">Typ</label>
<select id="add-guest-type" class="form-control" style="font-size: var(--font-size-sm);">
<option value="member">Członek Izby ({{ "%.0f"|format(event.price_member) }} zł)</option>
<option value="external">Gość spoza Izby ({{ "%.0f"|format(event.price_guest) }} zł)</option>
</select>
</div>
<div style="display: flex; gap: var(--spacing-xs);">
<button class="btn btn-primary" onclick="addPerson(false)" style="font-size: var(--font-size-sm);">Dodaj</button>
<button class="btn btn-outline" onclick="addPerson(true)" style="font-size: var(--font-size-sm);" title="Dodaj i oznacz jako opłacone">Dodaj + opłacone</button>
</div>
</div>
<p id="add-person-error" style="display: none; color: var(--error); font-size: var(--font-size-xs); margin: 8px 0 0;"></p>
</div>
<div class="stats-bar">
<div class="stat-card">
@ -164,7 +193,7 @@
<div class="confirmed-info">gość: {{ guest.host.name or guest.host.email.split('@')[0] }}</div>
</td>
<td>{{ guest.organization or '—' }}</td>
<td><span class="badge-type-guest">Gość towarzyszący</span></td>
<td><span class="badge-type-{{ 'member' if guest.guest_type == 'member' else 'guest' }}">{{ 'Członek (gość)' if guest.guest_type == 'member' else 'Gość spoza Izby' }}</span></td>
<td class="amount-cell" onclick="editAmount('guest', {{ guest.id }}, this)" title="Kliknij, aby zmienić kwotę">
{{ "%.0f"|format(guest.payment_amount) if guest.payment_amount else '—' }} zł
</td>
@ -278,4 +307,43 @@ function editAmount(type, id, cell) {
if (e.key === 'Escape') { cell.textContent = (current || '—') + ' zł'; }
});
}
function toggleAddPerson() {
const form = document.getElementById('add-person-form');
form.style.display = form.style.display === 'none' ? 'block' : 'none';
if (form.style.display === 'block') document.getElementById('add-first-name').focus();
}
async function addPerson(markPaid) {
const firstName = document.getElementById('add-first-name').value.trim();
const lastName = document.getElementById('add-last-name').value.trim();
const guestType = document.getElementById('add-guest-type').value;
const errEl = document.getElementById('add-person-error');
if (!firstName && !lastName) {
errEl.textContent = 'Podaj imię i/lub nazwisko';
errEl.style.display = 'block';
return;
}
errEl.style.display = 'none';
try {
const resp = await fetch('/admin/kalendarz/' + eventId + '/platnosci/dodaj', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({ first_name: firstName, last_name: lastName, guest_type: guestType, mark_paid: markPaid })
});
const data = await resp.json();
if (data.success) {
showToast(data.message, 'success');
setTimeout(() => location.reload(), 500);
} else {
errEl.textContent = data.error || 'Błąd';
errEl.style.display = 'block';
}
} catch (e) {
errEl.textContent = 'Błąd połączenia';
errEl.style.display = 'block';
}
}
{% endblock %}

View File

@ -4,6 +4,9 @@
{% block extra_css %}
<style>
.guest-type-btn { transition: var(--transition); }
.guest-type-btn.active { background: var(--primary); color: white; border-color: var(--primary); }
.event-header {
margin-bottom: var(--spacing-xl);
}
@ -613,7 +616,27 @@
<div id="guest-form" style="display: none; margin-top: 12px; padding: 16px; background: var(--surface); border-radius: var(--radius); border: 1px solid var(--border);">
<input type="hidden" id="guest-edit-id" value="">
<input type="hidden" id="guest-type" value="external">
<div style="display: flex; flex-direction: column; gap: 10px;">
{% if event.is_paid and current_user.company_id %}
<div id="guest-type-selector" style="display: flex; gap: 8px; margin-bottom: 4px;">
<button type="button" class="btn btn-outline guest-type-btn active" data-type="member" onclick="selectGuestType('member')" style="font-size: 0.85em; flex: 1;">
Osoba z firmy{% if event.price_member %} ({{ "%.0f"|format(event.price_member) }} zł){% endif %}
</button>
<button type="button" class="btn btn-outline guest-type-btn" data-type="external" onclick="selectGuestType('external')" style="font-size: 0.85em; flex: 1;">
Gość spoza Izby{% if event.price_guest %} ({{ "%.0f"|format(event.price_guest) }} zł){% endif %}
</button>
</div>
<div id="colleague-picker" style="margin-bottom: 4px;">
<label style="font-size: 0.85em; color: var(--text-secondary);">Wybierz osobę z firmy</label>
<select id="colleague-select" class="form-control" style="margin-top: 2px;" onchange="onColleagueSelect()">
<option value="">— wpisz dane ręcznie —</option>
</select>
</div>
{% endif %}
<div>
<label for="guest-first-name" style="font-size: 0.85em; color: var(--text-secondary);">Imię</label>
<input type="text" id="guest-first-name" maxlength="100" class="form-control" style="margin-top: 2px;">
@ -802,6 +825,8 @@ async function toggleRSVP() {
}
/* --- Guest management --- */
let colleaguesLoaded = false;
function toggleGuestForm() {
const form = document.getElementById('guest-form');
const btn = document.getElementById('add-guest-btn');
@ -812,6 +837,14 @@ function toggleGuestForm() {
document.getElementById('guest-org').value = '';
document.getElementById('guest-submit-btn').textContent = 'Dodaj';
document.getElementById('guest-form-error').style.display = 'none';
// Reset guest type to member if paid event with company
const typeSelector = document.getElementById('guest-type-selector');
if (typeSelector) {
selectGuestType('member');
loadColleagues();
}
const cs = document.getElementById('colleague-select');
if (cs) cs.value = '';
form.style.display = 'block';
btn.style.display = 'none';
document.getElementById('guest-first-name').focus();
@ -820,6 +853,46 @@ function toggleGuestForm() {
}
}
function selectGuestType(type) {
document.getElementById('guest-type').value = type;
document.querySelectorAll('.guest-type-btn').forEach(b => {
b.classList.toggle('active', b.dataset.type === type);
});
const picker = document.getElementById('colleague-picker');
if (picker) {
picker.style.display = type === 'member' ? 'block' : 'none';
}
}
async function loadColleagues() {
if (colleaguesLoaded) return;
const select = document.getElementById('colleague-select');
if (!select) return;
try {
const resp = await fetch('/kalendarz/{{ event.id }}/company-colleagues');
const data = await resp.json();
data.forEach(c => {
const opt = document.createElement('option');
opt.value = JSON.stringify(c);
opt.textContent = c.name + (c.already_registered ? ' (już zapisany/a)' : '');
if (c.already_registered) opt.disabled = true;
select.appendChild(opt);
});
colleaguesLoaded = true;
} catch(e) {}
}
function onColleagueSelect() {
const select = document.getElementById('colleague-select');
if (!select.value) return;
try {
const c = JSON.parse(select.value);
document.getElementById('guest-first-name').value = c.first_name || '';
document.getElementById('guest-last-name').value = c.last_name || '';
document.getElementById('guest-org').value = '{{ current_user.company.name|default("", true)|e }}';
} catch(e) {}
}
function cancelGuestForm() {
document.getElementById('guest-form').style.display = 'none';
const btn = document.getElementById('add-guest-btn');
@ -863,7 +936,7 @@ async function submitGuest() {
const resp = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({ first_name: firstName, last_name: lastName, organization: org })
body: JSON.stringify({ first_name: firstName, last_name: lastName, organization: org, guest_type: document.getElementById('guest-type').value || 'external' })
});
const data = await resp.json();
if (data.success) {