Feat: Rechnungsadresse — Profil, Upgrade-Modal Hinweis, Rechnung-erstellen-Button in Upgrade-Cards (SW by-v967)
This commit is contained in:
parent
95b70d5119
commit
0a466ef6ce
9 changed files with 89 additions and 20 deletions
|
|
@ -2444,6 +2444,12 @@ def _migrate(conn_factory):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Migration invoices: {e}")
|
logger.warning(f"Migration invoices: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE users ADD COLUMN billing_address TEXT")
|
||||||
|
logger.info("Migration: billing_address bereit.")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _seed_help_articles(conn):
|
def _seed_help_articles(conn):
|
||||||
"""Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist."""
|
"""Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist."""
|
||||||
|
|
|
||||||
|
|
@ -408,7 +408,7 @@ async def serve_media(path: str, request: _Request):
|
||||||
raise _HE(404, "Nicht gefunden.")
|
raise _HE(404, "Nicht gefunden.")
|
||||||
return _media_response(filepath)
|
return _media_response(filepath)
|
||||||
|
|
||||||
APP_VER = "966" # muss mit APP_VER in app.js übereinstimmen
|
APP_VER = "967" # muss mit APP_VER in app.js übereinstimmen
|
||||||
|
|
||||||
@app.get("/.well-known/assetlinks.json")
|
@app.get("/.well-known/assetlinks.json")
|
||||||
async def assetlinks():
|
async def assetlinks():
|
||||||
|
|
|
||||||
|
|
@ -1137,7 +1137,7 @@ async def list_upgrade_requests(user=Depends(require_admin)):
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
rows = conn.execute("""
|
rows = conn.execute("""
|
||||||
SELECT r.id, r.user_id, r.tier, r.message, r.created_at, r.fulfilled_at,
|
SELECT r.id, r.user_id, r.tier, r.message, r.created_at, r.fulfilled_at,
|
||||||
u.name, u.email
|
u.name, u.email, u.billing_address
|
||||||
FROM upgrade_requests r
|
FROM upgrade_requests r
|
||||||
JOIN users u ON u.id = r.user_id
|
JOIN users u ON u.id = r.user_id
|
||||||
ORDER BY r.fulfilled_at IS NOT NULL, r.created_at DESC
|
ORDER BY r.fulfilled_at IS NOT NULL, r.created_at DESC
|
||||||
|
|
|
||||||
|
|
@ -241,7 +241,8 @@ async def me(user=Depends(get_current_user)):
|
||||||
is_founder, is_partner, founder_number, is_founder_pending,
|
is_founder, is_partner, founder_number, is_founder_pending,
|
||||||
notes_ki_enabled, gassi_stunde_push,
|
notes_ki_enabled, gassi_stunde_push,
|
||||||
preferred_theme, subscription_tier,
|
preferred_theme, subscription_tier,
|
||||||
subscription_expires_at, subscription_cancelled_at, needs_dog_selection
|
subscription_expires_at, subscription_cancelled_at, needs_dog_selection,
|
||||||
|
billing_address
|
||||||
FROM users WHERE id=?""",
|
FROM users WHERE id=?""",
|
||||||
(user["id"],)
|
(user["id"],)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ class ProfileUpdate(BaseModel):
|
||||||
notes_ki_enabled: Optional[int] = None
|
notes_ki_enabled: Optional[int] = None
|
||||||
gassi_stunde_push: Optional[int] = None
|
gassi_stunde_push: Optional[int] = None
|
||||||
preferred_theme: Optional[str] = None
|
preferred_theme: Optional[str] = None
|
||||||
|
billing_address: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
def _load_user(user_id: int) -> dict:
|
def _load_user(user_id: int) -> dict:
|
||||||
|
|
@ -35,7 +36,7 @@ def _load_user(user_id: int) -> dict:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""SELECT id, name, real_name, email, rolle, is_premium, email_verified,
|
"""SELECT id, name, real_name, email, rolle, is_premium, email_verified,
|
||||||
bio, wohnort, erfahrung, social_link,
|
bio, wohnort, erfahrung, social_link,
|
||||||
profil_sichtbarkeit, avatar_url, created_at
|
profil_sichtbarkeit, avatar_url, created_at, billing_address
|
||||||
FROM users WHERE id=?""",
|
FROM users WHERE id=?""",
|
||||||
(user_id,)
|
(user_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
Router, State-Management, Navigation, Initialisierung.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const APP_VER = '966'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
const APP_VER = '967'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||||
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
|
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
|
||||||
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
||||||
// Cache-Bust-Parameter nach Update-Reload sofort entfernen
|
// Cache-Bust-Parameter nach Update-Reload sofort entfernen
|
||||||
|
|
|
||||||
|
|
@ -3540,12 +3540,22 @@ window.Page_admin = (() => {
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2);margin-top:var(--space-3)">
|
||||||
|
<button class="btn adm-invoice-btn"
|
||||||
|
data-name="${_esc(r.name)}" data-email="${_esc(r.email)}"
|
||||||
|
data-tier="${r.tier}" data-address="${_esc(r.billing_address || '')}"
|
||||||
|
style="background:#e67e22;color:#fff;border:none;
|
||||||
|
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
||||||
|
cursor:pointer;font-size:var(--text-sm);font-weight:600">
|
||||||
|
${UI.icon('receipt')} Rechnung erstellen
|
||||||
|
</button>
|
||||||
<button class="btn adm-fulfill-btn" data-id="${r.id}" data-name="${_esc(r.name)}" data-tier="${r.tier}"
|
<button class="btn adm-fulfill-btn" data-id="${r.id}" data-name="${_esc(r.name)}" data-tier="${r.tier}"
|
||||||
style="width:100%;margin-top:var(--space-3);background:#16a34a;color:#fff;border:none;
|
style="background:#16a34a;color:#fff;border:none;
|
||||||
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
||||||
cursor:pointer;font-size:var(--text-sm);font-weight:600">
|
cursor:pointer;font-size:var(--text-sm);font-weight:600">
|
||||||
✓ Freischalten
|
✓ Freischalten
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// Erledigte als kompakte Tabellenzeilen
|
// Erledigte als kompakte Tabellenzeilen
|
||||||
|
|
@ -3610,6 +3620,31 @@ window.Page_admin = (() => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// "Rechnung erstellen" — öffnet Invoice-Modal mit vorbefüllten Nutzerdaten
|
||||||
|
const TIER_ITEMS = {
|
||||||
|
pro: { description: 'Ban Yaro Pro Jahresabo', unit_price: 29.00 },
|
||||||
|
breeder: { description: 'Ban Yaro Züchter Jahresabo', unit_price: 49.00 },
|
||||||
|
};
|
||||||
|
const _year = new Date().getFullYear();
|
||||||
|
const _period = `01.01.${_year} – 31.12.${_year}`;
|
||||||
|
|
||||||
|
el.querySelectorAll('.adm-invoice-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const { name, email, tier, address } = btn.dataset;
|
||||||
|
const tierItem = TIER_ITEMS[tier] || { description: 'Ban Yaro Abo', unit_price: 0 };
|
||||||
|
_openNeueRechnungModal(() => {
|
||||||
|
_tab = 'rechnungen';
|
||||||
|
_renderTab();
|
||||||
|
}, {
|
||||||
|
recipient_name: name,
|
||||||
|
recipient_email: email,
|
||||||
|
recipient_address: address || '',
|
||||||
|
service_period: _period,
|
||||||
|
items: [{ description: tierItem.description, quantity: 1, unit_price: tierItem.unit_price }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
@ -3789,8 +3824,9 @@ window.Page_admin = (() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function _openNeueRechnungModal(reload) {
|
function _openNeueRechnungModal(reload, prefill = null) {
|
||||||
const id = `inv-new-${Date.now()}`;
|
const id = `inv-new-${Date.now()}`;
|
||||||
|
const p = prefill || {};
|
||||||
|
|
||||||
UI.modal.open({
|
UI.modal.open({
|
||||||
title: `${UI.icon('receipt')} Neue Rechnung erstellen`,
|
title: `${UI.icon('receipt')} Neue Rechnung erstellen`,
|
||||||
|
|
@ -3801,25 +3837,32 @@ window.Page_admin = (() => {
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" style="font-size:var(--text-xs)">Empfänger Name *</label>
|
<label class="form-label" style="font-size:var(--text-xs)">Empfänger Name *</label>
|
||||||
<input class="form-control" name="recipient_name" type="text" required placeholder="Max Muster">
|
<input class="form-control" name="recipient_name" type="text" required
|
||||||
|
placeholder="Max Muster" value="${_esc(p.recipient_name || '')}">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" style="font-size:var(--text-xs)">E-Mail</label>
|
<label class="form-label" style="font-size:var(--text-xs)">E-Mail</label>
|
||||||
<input class="form-control" name="recipient_email" type="email" placeholder="max@example.com">
|
<input class="form-control" name="recipient_email" type="email"
|
||||||
|
placeholder="max@example.com" value="${_esc(p.recipient_email || '')}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" style="font-size:var(--text-xs)">Adresse <span style="color:var(--c-text-muted)">(optional)</span></label>
|
<label class="form-label" style="font-size:var(--text-xs)">Adresse
|
||||||
|
${p.recipient_name && !p.recipient_address
|
||||||
|
? `<span style="color:var(--c-warning);font-size:10px"> ⚠ Nutzer hat keine Rechnungsadresse hinterlegt</span>`
|
||||||
|
: '<span style="color:var(--c-text-muted)">(optional)</span>'}
|
||||||
|
</label>
|
||||||
<textarea class="form-control" name="recipient_address" rows="2"
|
<textarea class="form-control" name="recipient_address" rows="2"
|
||||||
placeholder="Musterstr. 1 12345 Berlin"
|
placeholder="Musterstr. 1 12345 Berlin"
|
||||||
style="resize:vertical;font-family:inherit"></textarea>
|
style="resize:vertical;font-family:inherit">${_esc(p.recipient_address || '')}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" style="font-size:var(--text-xs)">Leistungszeitraum <span style="color:var(--c-text-muted)">(optional)</span></label>
|
<label class="form-label" style="font-size:var(--text-xs)">Leistungszeitraum <span style="color:var(--c-text-muted)">(optional)</span></label>
|
||||||
<input class="form-control" name="service_period" type="text"
|
<input class="form-control" name="service_period" type="text"
|
||||||
placeholder="01.01.2026 – 31.12.2026">
|
placeholder="01.01.2026 – 31.12.2026"
|
||||||
|
value="${_esc(p.service_period || '')}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Positionen -->
|
<!-- Positionen -->
|
||||||
|
|
@ -3915,8 +3958,12 @@ window.Page_admin = (() => {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Erste Position hinzufügen
|
// Erste Position — aus Prefill oder Standard
|
||||||
|
if (p.items && p.items.length) {
|
||||||
|
p.items.forEach(it => _addItem(it.description, it.quantity ?? 1, it.unit_price ?? 0));
|
||||||
|
} else {
|
||||||
_addItem('Ban Yaro Pro Jahresabo', 1, 29.00);
|
_addItem('Ban Yaro Pro Jahresabo', 1, 29.00);
|
||||||
|
}
|
||||||
|
|
||||||
// Weitere Position
|
// Weitere Position
|
||||||
document.getElementById(`${id}-add-item`)?.addEventListener('click', () => _addItem());
|
document.getElementById(`${id}-add-item`)?.addEventListener('click', () => _addItem());
|
||||||
|
|
|
||||||
|
|
@ -307,6 +307,12 @@ window.Page_settings = (() => {
|
||||||
Wir schalten deinen Account manuell frei — innerhalb von 24 Stunden.
|
Wir schalten deinen Account manuell frei — innerhalb von 24 Stunden.
|
||||||
Wir melden uns mit den Zahlungsdetails per E-Mail.
|
Wir melden uns mit den Zahlungsdetails per E-Mail.
|
||||||
</div>
|
</div>
|
||||||
|
${!_appState.user?.billing_address ? `
|
||||||
|
<div style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
||||||
|
background:#fff8f0;border:1px solid #f0a060;
|
||||||
|
font-size:var(--text-xs);color:#c05000;line-height:1.6;margin-top:var(--space-2)">
|
||||||
|
💡 Tipp: Trag deine <strong>Rechnungsadresse</strong> im Profil ein — dann können wir die Rechnung vollständig ausstellen.
|
||||||
|
</div>` : ''}
|
||||||
${breederForm}
|
${breederForm}
|
||||||
</div>`,
|
</div>`,
|
||||||
footer: `
|
footer: `
|
||||||
|
|
@ -1135,6 +1141,13 @@ window.Page_settings = (() => {
|
||||||
value="${_esc(u.social_link || '')}"
|
value="${_esc(u.social_link || '')}"
|
||||||
style="${inputStyle}">
|
style="${inputStyle}">
|
||||||
</div>
|
</div>
|
||||||
|
<div style="border-top:1px solid var(--c-border);padding-top:var(--space-3);margin-top:var(--space-1)">
|
||||||
|
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:2px">Rechnungsadresse</label>
|
||||||
|
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">Wird auf Rechnungen gedruckt. Straße in Zeile 1, PLZ + Ort in Zeile 2.</div>
|
||||||
|
<textarea name="billing_address" rows="2" maxlength="200"
|
||||||
|
placeholder="Musterstraße 1 12345 Berlin"
|
||||||
|
style="${inputStyle};resize:vertical;font-family:inherit">${_esc(u.billing_address || '')}</textarea>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Profil-Sichtbarkeit</label>
|
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Profil-Sichtbarkeit</label>
|
||||||
<select name="profil_sichtbarkeit" style="${inputStyle}">${sichtbarkeitOpts}</select>
|
<select name="profil_sichtbarkeit" style="${inputStyle}">${sichtbarkeitOpts}</select>
|
||||||
|
|
@ -1161,6 +1174,7 @@ window.Page_settings = (() => {
|
||||||
erfahrung: fd.erfahrung || '',
|
erfahrung: fd.erfahrung || '',
|
||||||
social_link: fd.social_link || '',
|
social_link: fd.social_link || '',
|
||||||
profil_sichtbarkeit: fd.profil_sichtbarkeit || 'public',
|
profil_sichtbarkeit: fd.profil_sichtbarkeit || 'public',
|
||||||
|
billing_address: fd.billing_address || '',
|
||||||
});
|
});
|
||||||
Object.assign(_appState.user, updated);
|
Object.assign(_appState.user, updated);
|
||||||
UI.modal.close?.();
|
UI.modal.close?.();
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v966';
|
const CACHE_VERSION = 'by-v967';
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||||
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue