Jobs: Bewerbungssystem für Social-Media-Manager/in

Backend:
- job_applications + job_application_docs Tabellen in DB
- luna_trial_until Spalte in users (Migration)
- routes/jobs.py: POST /apply (FormData + Datei-Upload, max 3×10MB),
  GET /my-application, GET /luna-trial-status
- Admin: GET/PATCH /admin/applications, GET /admin/applications/{id}/docs/{doc_id}
- Bei Bewerbung: 14-Tage Luna-Probezugang automatisch aktiviert
- Bei Annahme: is_social_media=1 + Gründer-Status gesetzt
- Status-Mails (pending/reviewing/accepted/rejected) via email_html-Template
- auth.py: require_social_media prüft auch luna_trial_until

Frontend:
- pages/jobs.js: Stellenausschreibung + Bewerbungsformular
  (Name, E-Mail, Hund, Social-Handle, Motivation, Datei-Upload)
- Luna-Probezugang Teaser mit Countdown wenn aktiv
- Bestehende Bewerbung: Status-Screen statt Formular
- app.js: 'jobs' Seite registriert
- admin.js: neuer Tab 'Bewerbungen' (filtert nach Status,
  Statuswechsel per Dropdown, Detailansicht mit Anhang-Download,
  Admin-Notiz-Feld)
- admin.js: Tab 'Jobs' → 'Scheduler' umbenannt
This commit is contained in:
rene 2026-05-01 09:30:05 +02:00
parent 59856e61a1
commit f378edab5d
7 changed files with 738 additions and 4 deletions

View file

@ -18,7 +18,8 @@ window.Page_admin = (() => {
{ id: 'social', label: 'Social Media', icon: 'camera' },
{ id: 'analytics', label: 'Analytics', icon: 'target' },
{ id: 'system', label: 'System', icon: 'gear' },
{ id: 'jobs', label: 'Jobs', icon: 'clock' },
{ id: 'jobs', label: 'Scheduler', icon: 'clock' },
{ id: 'bewerbungen', label: 'Bewerbungen', icon: 'user-plus' },
{ id: 'partner', label: 'Partner', icon: 'handshake' },
{ id: 'outreach', label: 'Outreach', icon: 'envelope-simple' },
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
@ -93,6 +94,7 @@ window.Page_admin = (() => {
case 'partner': await _renderPartner(el); break;
case 'outreach': await _renderOutreach(el); break;
case 'audit': await _renderAudit(el); break;
case 'bewerbungen': await _renderBewerbungen(el); break;
}
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
@ -2375,6 +2377,125 @@ window.Page_admin = (() => {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ------------------------------------------------------------------
// BEWERBUNGEN — Social-Media-Job
// ------------------------------------------------------------------
async function _renderBewerbungen(el) {
let _statusFilter = 'pending';
async function _load() {
el.innerHTML = `
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-4);flex-wrap:wrap;align-items:center">
${['pending','reviewing','accepted','rejected','alle'].map(s => `
<button class="btn btn-sm ${s===_statusFilter?'btn-primary':'btn-ghost'} adm-bew-filter" data-s="${s}">
${s==='pending'?'⏳ Neu':s==='reviewing'?'🔍 In Prüfung':s==='accepted'?'✅ Angenommen':s==='rejected'?'❌ Abgelehnt':'Alle'}
</button>`).join('')}
</div>
<div id="adm-bew-list">${UI.skeleton(3)}</div>`;
el.querySelectorAll('.adm-bew-filter').forEach(btn => {
btn.addEventListener('click', () => {
_statusFilter = btn.dataset.s;
_load();
});
});
try {
const rows = await API.get(`/jobs/admin/applications?status=${_statusFilter}`);
const list = el.querySelector('#adm-bew-list');
if (!rows.length) {
list.innerHTML = _emptyState('user-plus', 'Keine Bewerbungen', 'Noch keine Bewerbungen in diesem Status.');
return;
}
list.innerHTML = rows.map(r => `
<div class="card" style="margin-bottom:var(--space-3);padding:var(--space-4)" data-id="${r.id}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-3)">
<div style="flex:1;min-width:0">
<div style="font-weight:700;font-size:var(--text-base)">${_esc(r.name)}
${r.username ? `<span style="color:var(--c-text-muted);font-weight:400;font-size:var(--text-sm)">(@${_esc(r.username)})</span>` : ''}
</div>
<div style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-top:2px">
${_esc(r.email)} · @${_esc(r.social_handle||'—')}
${r.dog_name ? ` · 🐕 ${_esc(r.dog_name)} (${_esc(r.dog_rasse||'')})` : ''}
</div>
<div style="color:var(--c-text-muted);font-size:var(--text-xs);margin-top:2px">
${r.created_at?.slice(0,16).replace('T',' ')} · ${r.doc_count} Anhang/Anhänge
</div>
<div style="margin-top:var(--space-2);font-size:var(--text-sm);color:var(--c-text-secondary);
background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-2) var(--space-3)">
${_esc((r.motivation||'').slice(0,200))}${(r.motivation||'').length>200?'…':''}
</div>
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-2);min-width:120px">
<button class="btn btn-sm btn-primary adm-bew-view" data-id="${r.id}">Details</button>
<select class="form-control adm-bew-status" data-id="${r.id}"
style="font-size:var(--text-xs);padding:var(--space-1) var(--space-2)">
<option value="pending" ${r.status==='pending' ?'selected':''}> Neu</option>
<option value="reviewing" ${r.status==='reviewing'?'selected':''}>🔍 Prüfung</option>
<option value="accepted" ${r.status==='accepted' ?'selected':''}> Angenommen</option>
<option value="rejected" ${r.status==='rejected' ?'selected':''}> Abgelehnt</option>
</select>
</div>
</div>
</div>`).join('');
list.querySelectorAll('.adm-bew-status').forEach(sel => {
sel.addEventListener('change', async () => {
const id = sel.dataset.id;
await API.patch(`/jobs/admin/applications/${id}`, { status: sel.value });
UI.toast.success('Status aktualisiert.');
setTimeout(_load, 500);
});
});
list.querySelectorAll('.adm-bew-view').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const app = await API.get(`/jobs/admin/applications/${id}`);
const docsHtml = app.docs?.length
? app.docs.map(d => `<a href="/api/jobs/admin/applications/${id}/docs/${d.id}"
target="_blank" style="display:block;color:var(--c-primary);font-size:var(--text-sm);margin:4px 0">
📎 ${_esc(d.filename)}</a>`).join('')
: '<span style="color:var(--c-text-muted);font-size:var(--text-sm)">Keine Anhänge</span>';
UI.modal.open({
title: `Bewerbung — ${_esc(app.name)}`,
body: `
<div style="display:grid;gap:var(--space-3)">
<div><b>E-Mail:</b> ${_esc(app.email)}</div>
<div><b>Social:</b> @${_esc(app.social_handle||'')}</div>
${app.dog_name ? `<div><b>Hund:</b> ${_esc(app.dog_name)} (${_esc(app.dog_rasse||'')})</div>` : ''}
<div><b>Motivation:</b><br>
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-3);
margin-top:var(--space-1);font-size:var(--text-sm);white-space:pre-wrap">${_esc(app.motivation)}</div>
</div>
<div><b>Anhänge:</b><br>${docsHtml}</div>
<div>
<b>Admin-Notiz:</b>
<textarea id="adm-bew-note" class="form-control" rows="2" style="margin-top:var(--space-1)"
placeholder="Interne Notiz / Nachricht an Bewerber">${_esc(app.admin_note||'')}</textarea>
</div>
</div>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="adm-bew-save-note">Notiz speichern</button>`,
});
document.getElementById('adm-bew-save-note')?.addEventListener('click', async () => {
const note = document.getElementById('adm-bew-note')?.value || '';
await API.patch(`/jobs/admin/applications/${id}`, { admin_note: note });
UI.toast.success('Notiz gespeichert.');
UI.modal.close();
});
});
});
} catch (e) {
el.querySelector('#adm-bew-list').innerHTML = _emptyState('warning', 'Fehler', e.message);
}
}
await _load();
}
// ------------------------------------------------------------------
return { init, refresh, onDogChange };