Improved features, implemented moco integration

This commit is contained in:
2026-02-18 19:59:14 +01:00
parent af2c9e7fd8
commit 8e4f155a16
11 changed files with 827 additions and 427 deletions

View File

@@ -52,6 +52,7 @@
</thead>
<tbody class="divide-y divide-brand-bg-light">
{% for key in keys %}
{% set key_offers = offers_by_key.get(key.id | string, []) %}
<tr class="hover:bg-brand-bg/30">
<td class="px-6 py-4">
<div class="font-mono text-white font-medium">{{ key.key }}</div>
@@ -67,44 +68,42 @@
</td>
<td class="px-6 py-4">
{% if key.used %}
<span class="px-3 py-1 bg-gray-600/30 text-gray-400 rounded-lg text-sm">
Verwendet
</span>
<span class="px-3 py-1 bg-gray-600/30 text-gray-400 rounded-lg text-sm">Verwendet</span>
{% else %}
<span class="px-3 py-1 bg-green-600/30 text-green-400 rounded-lg text-sm">
Verfügbar
</span>
<span class="px-3 py-1 bg-green-600/30 text-green-400 rounded-lg text-sm">Verfügbar</span>
{% endif %}
</td>
<td class="px-6 py-4 text-sm text-gray-400">
{{ key.created_at.strftime('%d.%m.%Y') if key.created_at else '-' }}
{% if key_offers %}
<button onclick="toggleOffers('{{ key.id }}')"
class="ml-2 px-2 py-0.5 bg-brand-highlight/20 text-brand-highlight rounded text-xs font-medium hover:bg-brand-highlight/30 transition-colors">
📄 {{ key_offers | length }} Angebot{{ 'e' if key_offers | length != 1 else '' }}
</button>
{% endif %}
</td>
<td class="px-6 py-4 text-right">
<button onclick="editKey('{{ key.id }}', {{ key.max_employees }}, {{ key.daily_token_limit or '' }}, '{{ key.description or '' }}')"
class="text-blue-400 hover:text-blue-300 p-2 rounded transition-colors"
title="Bearbeiten">
class="text-blue-400 hover:text-blue-300 p-2 rounded transition-colors" title="Bearbeiten">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<button onclick="openOfferModal('{{ key.id }}', {{ key.max_employees }}, {{ key.daily_token_limit or 0 }}, '{{ key.description or '' }}')"
class="text-green-400 hover:text-green-300 p-2 rounded transition-colors"
title="Angebot senden">
class="text-green-400 hover:text-green-300 p-2 rounded transition-colors" title="Angebot in MOCO erstellen">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</button>
{% if not key.used %}
<button onclick="copyKey('{{ key.key }}')"
class="text-brand-highlight hover:text-brand-highlight-dark p-2 rounded transition-colors"
title="Kopieren">
class="text-brand-highlight hover:text-brand-highlight-dark p-2 rounded transition-colors" title="Kopieren">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</button>
<button onclick="deleteKey('{{ key.id }}')"
class="text-red-400 hover:text-red-300 p-2 rounded transition-colors"
title="Löschen">
class="text-red-400 hover:text-red-300 p-2 rounded transition-colors" title="Löschen">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
@@ -112,6 +111,41 @@
{% endif %}
</td>
</tr>
{% if key_offers %}
<tr id="offers-{{ key.id }}" class="hidden bg-brand-bg/20">
<td colspan="5" class="px-6 py-3">
<div class="space-y-2">
{% for offer in key_offers %}
<div class="flex items-center justify-between bg-brand-bg rounded-lg px-4 py-2.5 border border-brand-bg-light">
<div class="flex items-center gap-4 text-sm">
<a href="{{ offer.moco_offer_url }}" target="_blank"
class="font-medium text-white hover:text-brand-highlight transition-colors">
{{ offer.moco_offer_identifier or offer.offer_title or 'Angebot' }}
</a>
<span class="text-gray-400">{{ offer.company_name }}</span>
<span class="text-gray-400">
{% if offer.price %}{{ "%.2f"|format(offer.price) }} €{% endif %}
{% if offer.payment_frequency %} / {{ offer.payment_frequency }}{% endif %}
</span>
<span class="text-gray-500 text-xs">{{ offer.created_at.strftime('%d.%m.%Y') if offer.created_at else '' }}</span>
</div>
<div class="flex items-center gap-2">
{% if offer.status == 'sent' %}
<span class="px-2 py-0.5 bg-green-600/30 text-green-400 rounded text-xs">Versendet</span>
{% else %}
<span class="px-2 py-0.5 bg-yellow-600/30 text-yellow-400 rounded text-xs">Entwurf</span>
<button onclick="openSendModal('{{ offer.id }}', '{{ offer.moco_offer_identifier or offer.offer_title }}', '{{ offer.company_name }}')"
class="px-3 py-1 bg-brand-highlight text-brand-bg rounded text-xs font-semibold hover:bg-brand-highlight-dark transition-colors">
Versenden
</button>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
@@ -166,13 +200,14 @@
<!-- Offer Modal -->
<div id="offerModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="card-bg rounded-xl border p-8 max-w-lg w-full mx-4">
<h2 class="text-2xl font-bold text-white mb-1">Angebot senden</h2>
<p class="text-gray-400 text-sm mb-6">Erstellt ein professionelles PDF-Angebot und sendet es per E-Mail.</p>
<h2 class="text-2xl font-bold text-white mb-1">Angebot in MOCO erstellen</h2>
<p class="text-gray-400 text-sm mb-6">Erstellt ein Angebot direkt in MOCO. Versand erfolgt manuell in MOCO.</p>
<form id="offerForm" class="space-y-4">
<input type="hidden" id="offer_key_id" name="key_id">
<input type="hidden" id="offer_company_name_hidden" name="company_name">
<!-- Calculated Info -->
<!-- Plan Info -->
<div id="offerCalcInfo" class="bg-brand-bg/60 rounded-lg p-4 text-sm space-y-1 border border-gray-600">
<div class="flex justify-between"><span class="text-gray-400">Plan:</span><span id="offer_plan" class="text-white font-medium"></span></div>
<div class="flex justify-between"><span class="text-gray-400">Mitarbeiter:</span><span id="offer_employees" class="text-white"></span></div>
@@ -181,6 +216,16 @@
<div class="border-t border-gray-600 pt-2 mt-2 flex justify-between"><span class="text-gray-400">Empfohlener Preis:</span><span id="offer_suggested" class="text-brand-highlight font-bold"></span></div>
</div>
<!-- Company Dropdown -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Firma (MOCO)</label>
<select id="offer_company_select" name="company_id" required
onchange="onCompanySelect(this)"
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white focus:border-brand-highlight focus:outline-none">
<option value="">Wird geladen…</option>
</select>
</div>
<!-- Price -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Angebotspreis (€/Monat)</label>
@@ -213,20 +258,13 @@
Jährlicher Gesamtbetrag: <span id="yearly-total" class="text-white font-semibold"></span>
</div>
<!-- Email -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">E-Mail-Adresse</label>
<input type="email" id="offer_email" name="email" placeholder="kunde@beispiel.de" required
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white focus:border-brand-highlight focus:outline-none">
</div>
<div id="offer-status" class="hidden text-sm rounded-lg px-4 py-3"></div>
<div class="flex gap-3 pt-2">
<button type="submit" id="offerSubmitBtn"
class="flex-1 px-6 py-3 btn-primary rounded-lg font-medium flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
Angebot senden
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Angebot in MOCO erstellen
</button>
<button type="button" onclick="closeOfferModal()"
class="px-6 py-3 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors">
@@ -237,6 +275,33 @@
</div>
</div>
<!-- Send Offer Confirmation Modal -->
<div id="sendModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="card-bg rounded-xl border p-8 max-w-md w-full mx-4">
<h2 class="text-2xl font-bold text-white mb-1">Angebot versenden</h2>
<p class="text-gray-400 text-sm mb-6">Das Angebot wird per E-Mail an die hinterlegten Empfänger in MOCO gesendet.</p>
<div id="sendOfferInfo" class="bg-brand-bg/60 rounded-lg p-4 text-sm space-y-1 border border-gray-600 mb-6">
<div class="flex justify-between"><span class="text-gray-400">Angebot:</span><span id="send_offer_title" class="text-white font-medium"></span></div>
<div class="flex justify-between"><span class="text-gray-400">Firma:</span><span id="send_offer_company" class="text-white"></span></div>
</div>
<div id="send-status" class="hidden text-sm rounded-lg px-4 py-3 mb-4"></div>
<div class="flex gap-3">
<button id="sendConfirmBtn" onclick="confirmSendOffer()"
class="flex-1 px-6 py-3 btn-primary rounded-lg font-medium flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
Jetzt versenden
</button>
<button type="button" onclick="closeSendModal()"
class="px-6 py-3 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors">
Abbrechen
</button>
</div>
</div>
</div>
<!-- Generate Modal -->
<div id="generateModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="card-bg rounded-xl border p-8 max-w-md w-full mx-4">
@@ -374,7 +439,7 @@ async function deleteKey(keyId) {
}
}
// ── Offer Modal ────────────────────────────────────────────────────────────
// ── Offer Modal (MOCO) ─────────────────────────────────────────────────────
const AVG_TOKENS_PER_POST = 50000;
const API_COST_PER_1K_EUR = 0.003;
const SERVER_SHARE_EUR = 1.60;
@@ -384,12 +449,10 @@ function calcSuggestedPrice(dailyTokenLimit) {
const monthlyTokens = dailyTokenLimit * 30;
const apiCostEur = (monthlyTokens / 1000) * API_COST_PER_1K_EUR;
const raw = apiCostEur * 2.5 + SERVER_SHARE_EUR * 2 + 5;
return Math.ceil(raw / 5) * 5; // round up to nearest 5€
return Math.ceil(raw / 5) * 5;
}
function fmtNum(n) {
return n.toLocaleString('de-DE');
}
function fmtNum(n) { return n.toLocaleString('de-DE'); }
function openOfferModal(keyId, maxEmployees, dailyTokenLimit, description) {
document.getElementById('offer_key_id').value = keyId;
@@ -409,8 +472,13 @@ function openOfferModal(keyId, maxEmployees, dailyTokenLimit, description) {
document.getElementById('offer_suggested').textContent = '';
}
// Reset company select
const sel = document.getElementById('offer_company_select');
sel.innerHTML = '<option value="">Wird geladen…</option>';
document.getElementById('offer_company_name_hidden').value = '';
loadCompanies();
selectFreq('monatlich');
document.getElementById('offer_email').value = '';
document.getElementById('offer-status').classList.add('hidden');
document.getElementById('offerModal').classList.remove('hidden');
}
@@ -423,10 +491,8 @@ function closeOfferModal() {
function selectFreq(freq) {
document.getElementById('offer_payment_frequency').value = freq;
['monatlich', 'jährlich', 'einmalig'].forEach(f => {
const btn = document.getElementById('freq-' + f);
btn.classList.toggle('active-freq', f === freq);
document.getElementById('freq-' + f).classList.toggle('active-freq', f === freq);
});
// Show yearly total hint
const price = parseFloat(document.getElementById('offer_price').value) || 0;
const hint = document.getElementById('yearly-hint');
if (freq === 'jährlich' && price > 0) {
@@ -438,43 +504,141 @@ function selectFreq(freq) {
}
document.getElementById('offer_price').addEventListener('input', () => {
if (document.getElementById('offer_payment_frequency').value === 'jährlich') {
selectFreq('jährlich');
}
if (document.getElementById('offer_payment_frequency').value === 'jährlich') selectFreq('jährlich');
});
// ── Company select ─────────────────────────────────────────────────────────
async function loadCompanies() {
const sel = document.getElementById('offer_company_select');
try {
const resp = await fetch('/admin/api/moco/companies');
if (!resp.ok) throw new Error('Fehler beim Laden');
const companies = await resp.json();
sel.innerHTML = '<option value="">Firma auswählen…</option>';
companies.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name;
sel.appendChild(opt);
});
} catch (e) {
sel.innerHTML = '<option value="">Fehler beim Laden der Firmen</option>';
console.error('Company load error', e);
}
}
function onCompanySelect(sel) {
const name = sel.options[sel.selectedIndex]?.text || '';
document.getElementById('offer_company_name_hidden').value = sel.value ? name : '';
}
document.getElementById('offerForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const keyId = formData.get('key_id');
const companyId = formData.get('company_id');
const statusEl = document.getElementById('offer-status');
const submitBtn = document.getElementById('offerSubmitBtn');
if (!companyId) {
statusEl.className = 'text-sm rounded-lg px-4 py-3 bg-red-900/40 border border-red-600 text-red-300';
statusEl.textContent = 'Bitte eine Firma auswählen.';
statusEl.classList.remove('hidden');
return;
}
submitBtn.disabled = true;
submitBtn.textContent = 'Wird gesendet…';
submitBtn.textContent = 'Wird erstellt…';
statusEl.classList.add('hidden');
try {
const response = await fetch(`/admin/api/license-keys/${keyId}/send-offer`, {
const response = await fetch(`/admin/api/license-keys/${keyId}/create-offer`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Fehler beim Senden');
if (!response.ok) throw new Error(data.detail || 'Fehler beim Erstellen');
statusEl.className = 'text-sm rounded-lg px-4 py-3 bg-green-900/40 border border-green-600 text-green-300';
statusEl.textContent = data.message || 'Angebot erfolgreich gesendet!';
statusEl.innerHTML = 'Angebot erfolgreich erstellt! '
+ (data.offer_url ? `<a href="${data.offer_url}" target="_blank" class="underline font-semibold text-green-300 hover:text-white">In MOCO öffnen →</a>` : '');
statusEl.classList.remove('hidden');
submitBtn.textContent = '✓ Gesendet';
submitBtn.textContent = '✓ Erstellt';
// Reset button after 4 seconds so another offer can be created
setTimeout(() => {
submitBtn.disabled = false;
submitBtn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> Angebot in MOCO erstellen';
location.reload();
}, 3000);
} catch (error) {
statusEl.className = 'text-sm rounded-lg px-4 py-3 bg-red-900/40 border border-red-600 text-red-300';
statusEl.textContent = 'Fehler: ' + error.message;
statusEl.classList.remove('hidden');
submitBtn.disabled = false;
submitBtn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg> Angebot senden';
submitBtn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> Angebot in MOCO erstellen';
}
});
// ── Offer sub-rows toggle ───────────────────────────────────────────────────
function toggleOffers(keyId) {
const row = document.getElementById('offers-' + keyId);
if (!row) return;
row.classList.toggle('hidden');
}
// ── Send Offer Modal ────────────────────────────────────────────────────────
let currentSendOfferId = null;
function openSendModal(offerId, title, company) {
currentSendOfferId = offerId;
document.getElementById('send_offer_title').textContent = title || 'Angebot';
document.getElementById('send_offer_company').textContent = company || '';
const statusEl = document.getElementById('send-status');
statusEl.classList.add('hidden');
statusEl.textContent = '';
const btn = document.getElementById('sendConfirmBtn');
btn.disabled = false;
btn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg> Jetzt versenden';
document.getElementById('sendModal').classList.remove('hidden');
}
function closeSendModal() {
document.getElementById('sendModal').classList.add('hidden');
currentSendOfferId = null;
}
async function confirmSendOffer() {
if (!currentSendOfferId) return;
const statusEl = document.getElementById('send-status');
const btn = document.getElementById('sendConfirmBtn');
btn.disabled = true;
btn.textContent = 'Wird versendet…';
statusEl.classList.add('hidden');
try {
const response = await fetch(`/admin/api/license-key-offers/${currentSendOfferId}/send`, {
method: 'POST'
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Fehler beim Versenden');
statusEl.className = 'text-sm rounded-lg px-4 py-3 bg-green-900/40 border border-green-600 text-green-300';
statusEl.textContent = 'Angebot erfolgreich versendet!';
statusEl.classList.remove('hidden');
btn.textContent = '✓ Versendet';
setTimeout(() => {
closeSendModal();
location.reload();
}, 2000);
} catch (error) {
statusEl.className = 'text-sm rounded-lg px-4 py-3 bg-red-900/40 border border-red-600 text-red-300';
statusEl.textContent = 'Fehler: ' + error.message;
statusEl.classList.remove('hidden');
btn.disabled = false;
btn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg> Erneut versuchen';
}
}
</script>
{% endblock %}

View File

@@ -705,8 +705,8 @@
<div id="editView" class="hidden">
<textarea id="editTextarea" class="edit-textarea">{{ post.post_content }}</textarea>
<div class="flex items-center justify-between mt-4">
<span class="text-sm text-gray-400">
<span id="charCount">{{ post.post_content | length }}</span> Zeichen
<span class="text-sm" id="charCountLabel">
<span id="charCount">{{ post.post_content | length }}</span> / 3000 Zeichen
</span>
<div class="flex items-center gap-3">
<button onclick="cancelEdit()" class="px-4 py-2 bg-brand-bg hover:bg-brand-bg-light text-gray-300 rounded-lg transition-colors">
@@ -1137,7 +1137,16 @@ function setPreviewMode(mode) {
function updateCharCount() {
const textarea = document.getElementById('editTextarea');
const charCount = document.getElementById('charCount');
charCount.textContent = textarea.value.length;
const label = document.getElementById('charCountLabel');
const len = textarea.value.length;
charCount.textContent = len;
if (len > 3000) {
label.style.color = '#ef4444';
} else if (len > 2700) {
label.style.color = '#f59e0b';
} else {
label.style.color = '#9ca3af';
}
}
function cancelEdit() {
@@ -1154,6 +1163,11 @@ async function saveEdit() {
return;
}
if (newContent.length > 3000) {
showToast('Post überschreitet das LinkedIn-Limit von 3000 Zeichen.', 'error');
return;
}
// Show loading state
const originalBtnHTML = saveBtn.innerHTML;
saveBtn.innerHTML = '<div class="loading-spinner"></div>';
@@ -1979,10 +1993,11 @@ document.addEventListener('DOMContentLoaded', () => {
// Initialize media upload (multi-media support)
initMediaUpload();
// Add event listener for textarea character count
// Add event listener for textarea character count + initialize color
const textarea = document.getElementById('editTextarea');
if (textarea) {
textarea.addEventListener('input', updateCharCount);
updateCharCount();
}
// Allow Enter key to apply custom suggestion