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
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:
parent
6067492b1d
commit
d06507a7c3
@ -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
|
||||
# ============================================================
|
||||
|
||||
@ -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():
|
||||
"""
|
||||
|
||||
@ -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)
|
||||
|
||||
8
database/migrations/064_guest_type.sql
Normal file
8
database/migrations/064_guest_type.sql
Normal 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;
|
||||
@ -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 %}
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user