Betreiber-Zentralstelle, Mandanten-Design und schlankerer Einstieg
Funktions-Schalter je Mandant (app/features.php, tenant_features): der Betreiber schaltet FAQ, Selbsteintrag, PDF, PayPal-Eingang, CSV-Import, Mailversand, Jahresabschluss, Datenexport und eigenes Design pro Mandant frei. Gesperrte Funktionen verschwinden aus Menue und Schaltflaechen, ihre Seiten weisen Aufrufe und POSTs ab. Das Back-Office ist jetzt fuer Platform-Admins im Menue verlinkt statt nur per URL erreichbar. Eigenes Design je Mandant: Akzentfarbe und Logo in den Mandant- Einstellungen, eingebettet ueber app/branding.php; das Logo liegt geschuetzt in var/tenant_logos und wird nur ueber tenant-logo-anzeigen.php an den eigenen Mandanten ausgeliefert. Weniger Startinformationen: Startpaket neuer Mandanten auf zwei Beispielfragen gekuerzt, Anleitung von ~1400 auf ~750 Woerter gestrafft und um Abschnitte zu gesperrten Funktionen bereinigt. Vorder-/Rueckseite erst bei mehr als 50 Personen (vorher schon ab 50). Die Schwelle liegt jetzt gemeinsam in app/ledger.php und gilt auch fuer die Vorder-/Rueckseiten-Auswahl beim Erfassen, wo sie bisher unabhaengig von der Teamgroesse angeboten wurde. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+81
-133
@@ -2,6 +2,7 @@
|
||||
|
||||
include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$saasUser = saas_current_user($pdo);
|
||||
@@ -16,6 +17,13 @@ $istAdmin = $saasUser !== null && saas_user_has_role(['owner', 'admin'], $saasUs
|
||||
// Einstellungen fuer die Hinweise zu den aktivierten Zahlungswegen.
|
||||
$settings = $saasUser !== null ? saas_fetch_tenant_settings($pdo, (int)$saasUser['tenant_id']) : null;
|
||||
|
||||
// Gleiche Logik wie im Menue: was der Betreiber fuer diesen Mandanten
|
||||
// gesperrt hat, wird hier auch nicht erklaert.
|
||||
$anleitungTenantId = $saasUser !== null ? (int)$saasUser['tenant_id'] : 0;
|
||||
$kann = static function (string $feature) use ($pdo, $anleitungTenantId): bool {
|
||||
return $anleitungTenantId <= 0 || app_feature_enabled($pdo, $anleitungTenantId, $feature);
|
||||
};
|
||||
|
||||
include "header.php";
|
||||
include "headerline.php";
|
||||
include "nav.php";
|
||||
@@ -25,8 +33,8 @@ include "nav.php";
|
||||
<div class="content">
|
||||
|
||||
<h2>Anleitung</h2>
|
||||
<p>Diese Seite erklärt die wichtigsten Funktionen der Kaffeeliste. Oben stehen
|
||||
die Punkte, die jedes Mitglied betreffen, darunter die Verwaltungsfunktionen.</p>
|
||||
<p>Kurzüberblick über die Funktionen. Oben steht, was jedes Mitglied betrifft,
|
||||
darunter die Verwaltung.</p>
|
||||
|
||||
<?php if ($saasUser === null): ?>
|
||||
<div class="hint-box">
|
||||
@@ -40,34 +48,20 @@ include "nav.php";
|
||||
<h2>Für alle Mitglieder</h2>
|
||||
|
||||
<h3>Meine Kaffeeliste</h3>
|
||||
<p>Das ist deine Startseite. Sie zeigt deinen <b>aktuellen Stand</b>, deine
|
||||
Jahresübersicht sowie die letzten Einzahlungen und Striche.</p>
|
||||
<ul>
|
||||
<li><b>Guthaben</b> (positiver Betrag): Du hast mehr eingezahlt, als du verbraucht hast.</li>
|
||||
<li><b>Schulden</b> (negativer Betrag): Es ist Zeit für eine Einzahlung.</li>
|
||||
</ul>
|
||||
<p>In der <b>Monatsübersicht</b> siehst du, wie viele Striche in welchem Monat
|
||||
zusammengekommen sind.</p>
|
||||
<p>Deine Startseite zeigt den aktuellen Stand, die Jahres- und Monatsübersicht sowie
|
||||
die letzten Buchungen. Positiver Betrag = Guthaben, negativer Betrag = offener Betrag.</p>
|
||||
|
||||
<?php if ($kann('self_entry')): ?>
|
||||
<h3>Striche selbst eintragen</h3>
|
||||
<p>Wenn die Verwaltung das freigeschaltet hat, kannst du deinen Kaffee direkt auf
|
||||
der Startseite eintragen – ein oder zwei Striche pro Vorgang.</p>
|
||||
<div class="hint-box">
|
||||
<p><b>Vertippt?</b> Direkt darunter kannst du deinen <b>zuletzt selbst
|
||||
eingetragenen</b> Strich wieder stornieren. Striche, die der Kassenwart über die
|
||||
Sammelerfassung eingetragen hat, kannst du nicht selbst stornieren – wende dich
|
||||
dafür an die Verwaltung.</p>
|
||||
</div>
|
||||
<p>Wenn die Verwaltung das freigeschaltet hat, trägst du deinen Kaffee direkt auf der
|
||||
Startseite ein. Vertippt? Mit „Letzten Strich stornieren" nimmst du deinen jüngsten
|
||||
<b>selbst</b> eingetragenen Strich zurück – vom Kassenwart erfasste Striche nicht.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3>Bezahlen</h3>
|
||||
<p>Welche Wege zur Verfügung stehen, legt die Verwaltung fest. Möglich sind:</p>
|
||||
<ul>
|
||||
<li><b>Barzahlung</b> – beim angegebenen Ansprechpartner.</li>
|
||||
<li><b>PayPal</b> – über die Schaltflächen auf der Startseite.</li>
|
||||
<li><b>Überweisung</b> – auf die angegebene IBAN.</li>
|
||||
</ul>
|
||||
<p>Welche Wege offen stehen, legt die Verwaltung fest: Barzahlung, PayPal oder Überweisung.</p>
|
||||
<?php if ($settings !== null): ?>
|
||||
<p><small>In eurem Mandanten sind aktuell aktiv:
|
||||
<p><small>Bei euch aktiv:
|
||||
<?php
|
||||
$wege = [];
|
||||
if ((int)$settings['cash_enabled'] === 1) { $wege[] = 'Barzahlung'; }
|
||||
@@ -77,148 +71,103 @@ include "nav.php";
|
||||
?>.</small></p>
|
||||
<?php endif; ?>
|
||||
<div class="hint-box">
|
||||
<p><b>Wichtig bei PayPal:</b> Nutzt die Funktion „Geld an Freunde senden“. Bei
|
||||
Zahlungen für „Waren und Dienstleistungen“ zieht PayPal Gebühren ab – gutgeschrieben
|
||||
wird dann nur der Betrag, der tatsächlich angekommen ist.</p>
|
||||
<p><b>Wichtig bei PayPal:</b> „Geld an Freunde senden" nutzen. Bei „Waren und
|
||||
Dienstleistungen" zieht PayPal Gebühren ab – gutgeschrieben wird nur, was ankommt.</p>
|
||||
</div>
|
||||
|
||||
<h3>Anzeigenamen ändern</h3>
|
||||
<p>Unter <b>Namensanpassung</b> kannst du festlegen, wie dein Name auf der
|
||||
Kaffeeliste und im Ausdruck erscheint (maximal 20 Zeichen).</p>
|
||||
|
||||
<h3>Kundenkonto</h3>
|
||||
<p>Unter <b>Kundenkonto</b> findest du deine Zugangsdaten, kannst deine E-Mail-Adresse
|
||||
bestätigen und dein Passwort ändern.</p>
|
||||
<h3>Eigene Angaben</h3>
|
||||
<p>Unter <b>Namensanpassung</b> legst du fest, wie dein Name in der Liste und im Ausdruck
|
||||
erscheint (max. 20 Zeichen). Unter <b>Kundenkonto</b> änderst du Passwort und E-Mail.</p>
|
||||
|
||||
<?php if ($istKassenwart): ?>
|
||||
<hr>
|
||||
|
||||
<h2>Für Kassenwart und Verwaltung</h2>
|
||||
<p><small>Sichtbar für die Rollen <b>Kassenwart</b>, <b>Administrator</b> und
|
||||
<b>Inhaber</b>.</small></p>
|
||||
|
||||
<h3>Einzahlung eintragen</h3>
|
||||
<p>Trage hier ein, wer wie viel eingezahlt hat. Du kannst mehrere Mitglieder in
|
||||
einem Durchgang erfassen; leere Felder werden übersprungen.</p>
|
||||
<h3>Buchen</h3>
|
||||
<ul>
|
||||
<li><b>Bemerkung</b>: optionale Notiz, damit später nachvollziehbar bleibt, worum es ging.</li>
|
||||
<li><b>Negativer Betrag</b> = Abzug (z. B. Auszahlung bei Austritt oder eine
|
||||
Erstattung). Dabei ist eine Bemerkung <b>Pflicht</b>.</li>
|
||||
<li><b>Einzahlung eintragen</b>: mehrere Mitglieder in einem Durchgang; leere Felder
|
||||
werden übersprungen. Negativer Betrag = Abzug, dann ist eine Bemerkung Pflicht.</li>
|
||||
<li><b>Striche eintragen</b>: Sammelerfassung für die Papierliste. Der Preis pro Strich
|
||||
kommt aus den Einstellungen und ist pro Vorgang überschreibbar.</li>
|
||||
<li><b>Letzte Einträge</b>: falsche Buchungen hier <b>stornieren</b>, nicht per
|
||||
Gegenbuchung ausgleichen – sonst zählen beide als Umsatz in der Auswertung.</li>
|
||||
</ul>
|
||||
<div class="hint-box">
|
||||
<p><b>Falsch eingetragen ist kein Abzug.</b> Wenn eine Buchung schlicht falsch war,
|
||||
storniere sie unter <b>Letzte Einträge</b>. Ein Gegen-Abzug würde die Auswertung
|
||||
verfälschen, weil dann beide Buchungen als Umsatz zählen.</p>
|
||||
<p>Zum Schutz vor Vertippern werden Beträge über 1.000 € abgelehnt. Schlägt eine
|
||||
Zeile fehl, wird <b>nichts</b> gebucht und deine Eingaben bleiben stehen.</p>
|
||||
</div>
|
||||
|
||||
<h3>Striche eintragen</h3>
|
||||
<p>Sammelerfassung für die Papierliste. Über die Schaltflächen wählst du:</p>
|
||||
<ul>
|
||||
<li><b>Vorderseite</b>: Vieltrinker – Mitglieder mit mindestens 10 Strichen im Listenfenster.</li>
|
||||
<li><b>Rückseite</b>: alle übrigen aktiven Mitglieder.</li>
|
||||
<li><b>Alle</b>: die komplette Liste.</li>
|
||||
</ul>
|
||||
<p>Der <b>Preis pro Strich</b> wird aus den Mandant-Einstellungen vorbelegt und kann
|
||||
für den einzelnen Vorgang überschrieben werden.</p>
|
||||
|
||||
<h3>Letzte Einträge – stornieren</h3>
|
||||
<p>Zeigt die letzten 100 Einzahlungen und Striche. Fehlerhafte Buchungen werden
|
||||
<b>storniert</b>, nicht gelöscht: Sie verschwinden aus der Auswertung, bleiben aber
|
||||
im Journal nachvollziehbar.</p>
|
||||
|
||||
<?php if ($kann('paypal_inbox')): ?>
|
||||
<h3>PayPal-Zahlungen automatisch verbuchen</h3>
|
||||
<p>Die Kaffeeliste kann PayPal-Zahlungen selbstständig gutschreiben:</p>
|
||||
<ol>
|
||||
<li>Auf der Seite <b>PayPal-Zahlungen</b> steht eure persönliche Eingangsadresse.</li>
|
||||
<li>Richtet in eurem Mailprogramm eine <b>automatische Weiterleitung</b> der
|
||||
PayPal-Benachrichtigungen an diese Adresse ein.</li>
|
||||
<li>Erkannte Zahlungen werden dem passenden Mitglied automatisch gutgeschrieben.</li>
|
||||
</ol>
|
||||
<p>Gutgeschrieben wird immer der Betrag, der <b>tatsächlich angekommen</b> ist – bei
|
||||
Waren & Dienstleistungen also nach Abzug der PayPal-Gebühr.</p>
|
||||
<div class="hint-box">
|
||||
<p>Zahlungen, die sich nicht eindeutig zuordnen lassen, landen in der Warteschlange
|
||||
„Offene Zahlungen“ und können dort von Hand einem Mitglied zugeordnet oder ignoriert
|
||||
werden. Damit die automatische Zuordnung möglichst oft greift, hinterlege den
|
||||
<b>PayPal-Namen</b> beim Mitglied.</p>
|
||||
</div>
|
||||
<p>Auf der Seite <b>PayPal-Zahlungen</b> steht eure Eingangsadresse. Leitet die
|
||||
PayPal-Benachrichtigungen aus eurem Postfach automatisch dorthin weiter, dann werden
|
||||
erkannte Zahlungen selbstständig gutgeschrieben – immer der tatsächlich angekommene
|
||||
Betrag. Nicht eindeutig zuordenbare Zahlungen landen in der Warteschlange „Offene
|
||||
Zahlungen". Ein hinterlegter <b>PayPal-Name</b> beim Mitglied verbessert die Trefferquote.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3>CSV-Import</h3>
|
||||
<p>Über <b>CSV Upload</b> lassen sich Einzahlungen aus einer Datei einlesen. Der Import
|
||||
zeigt zuerst eine <b>Vorschau</b>; erst danach wird gebucht. Zeilen ohne eindeutige
|
||||
Zuordnung werden markiert und nicht verbucht.</p>
|
||||
|
||||
<h3>Kaffeeliste, Ausdruck und Auswertung</h3>
|
||||
<h3>Auswertung und Ausdruck</h3>
|
||||
<ul>
|
||||
<li><b>Kaffeeliste anzeigen</b>: Gesamtübersicht aller aktiven Mitglieder. Ein Klick
|
||||
auf den Namen öffnet die Einzelauswertung.</li>
|
||||
<li><b>PDF-Export</b>: die Liste zum Ausdrucken und Aufhängen.</li>
|
||||
<li><b>Journal-Vorschau</b>: alle Buchungen mit Herkunft – nützlich zur Kontrolle.</li>
|
||||
<li><b>Kaffeeliste anzeigen</b>: Gesamtübersicht; ein Klick auf den Namen öffnet die
|
||||
Einzelauswertung.</li>
|
||||
<?php if ($kann('pdf_export')): ?>
|
||||
<li><b>PDF-Export</b>: die Liste zum Aufhängen. Bis 50 Mitglieder eine Seite, darüber
|
||||
Vorder- und Rückseite.</li>
|
||||
<?php endif; ?>
|
||||
<li><b>Journal-Vorschau</b>: alle Buchungen mit Herkunft.</li>
|
||||
<?php if ($kann('csv_import')): ?>
|
||||
<li><b>CSV Upload</b>: Einzahlungen aus einer Datei einlesen – erst Vorschau, dann
|
||||
buchen. Zeilen ohne eindeutige Zuordnung werden nicht verbucht.</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
|
||||
<h3>Info-Mails versenden</h3>
|
||||
<p>Unter <b>Mailversand</b> lassen sich Mitglieder über ihren Stand informieren. Der
|
||||
<b>Dry-Run</b> zeigt zuerst, wer eine Mail bekäme, ohne etwas zu verschicken. Das
|
||||
Versandlog hält fest, was tatsächlich rausging.</p>
|
||||
|
||||
<h3>Jahresabschluss</h3>
|
||||
<p>Verteilt einen frei wählbaren Gesamtbetrag als Guthaben auf alle Mitglieder –
|
||||
anteilig zu ihren Strichen im laufenden Jahr. Auch hier gilt: erst <b>Dry-Run</b>
|
||||
zur Vorschau, dann buchen. Beim echten Lauf werden die Mitglieder per Mail informiert.</p>
|
||||
<?php if ($kann('mail_dispatch') || $kann('year_closing')): ?>
|
||||
<h3>Mails und Jahresabschluss</h3>
|
||||
<ul>
|
||||
<?php if ($kann('mail_dispatch')): ?>
|
||||
<li><b>Info-Mail versenden</b>: informiert Mitglieder über ihren Stand. Der Dry-Run
|
||||
zeigt vorher, wer eine Mail bekäme.</li>
|
||||
<?php endif; ?>
|
||||
<?php if ($kann('year_closing')): ?>
|
||||
<li><b>Jahresabschluss</b>: verteilt einen frei wählbaren Betrag anteilig zu den
|
||||
Strichen des Jahres als Guthaben. Auch hier erst Dry-Run, dann buchen.</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($istAdmin): ?>
|
||||
<hr>
|
||||
|
||||
<h2>Für Administratoren</h2>
|
||||
<p><small>Sichtbar für die Rollen <b>Administrator</b> und <b>Inhaber</b>.</small></p>
|
||||
|
||||
<h3>Mitglieder verwalten</h3>
|
||||
<ul>
|
||||
<li><b>Anlegen und bearbeiten</b> von Mitgliedern (Name, E-Mail, PayPal-Name).</li>
|
||||
<li><b>PayPal-Name</b>: entscheidend für die automatische Zuordnung von Zahlungen.</li>
|
||||
<li><b>Deaktivieren</b> statt löschen: Das Mitglied verschwindet aus den Listen,
|
||||
die Buchungshistorie bleibt erhalten.</li>
|
||||
<li><b>Anonymisieren</b>: ersetzt Name und E-Mail durch einen Platzhalter (DSGVO-Auskunft),
|
||||
die Buchungen der Kasse bleiben für die Abrechnung bestehen.</li>
|
||||
<li><b>Zugang gewähren / entziehen</b>: steuert, ob ein Mitglied sich anmelden kann.</li>
|
||||
<li><b>CSV-Import</b> für viele Mitglieder auf einmal – eine passende Vorlage gibt es
|
||||
zum Herunterladen.</li>
|
||||
<li><b>Deaktivieren</b> statt löschen: das Mitglied verschwindet aus den Listen, die
|
||||
Buchungshistorie bleibt.</li>
|
||||
<li><b>Anonymisieren</b>: ersetzt Name und E-Mail durch einen Platzhalter, die Buchungen
|
||||
der Kasse bleiben bestehen.</li>
|
||||
<li><b>Zugang gewähren / entziehen</b> steuert, ob sich jemand anmelden kann.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Hinweise verwalten</h3>
|
||||
<p>Ein Hinweis erscheint als gelber Balken oben auf jeder Seite – z. B. „Kaffeekasse
|
||||
ist leer“. Jeder Hinweis hat ein <b>Gültig bis</b>-Datum und verschwindet danach
|
||||
automatisch.</p>
|
||||
<h3>Hinweise</h3>
|
||||
<p>Ein Hinweis erscheint als gelber Balken oben auf jeder Seite und verschwindet nach dem
|
||||
<b>Gültig bis</b>-Datum automatisch.</p>
|
||||
|
||||
<h3>Mandant-Einstellungen</h3>
|
||||
<p>Hier stellst du das Verhalten der Kaffeeliste ein:</p>
|
||||
<ul>
|
||||
<li><b>Preis pro Strich</b> – Grundlage jeder Verbrauchsbuchung.</li>
|
||||
<li><b>Listenfenster in Tagen</b> – Zeitraum für die Vorder-/Rückseiten-Aufteilung.</li>
|
||||
<li><b>Schulden-Warnschwelle</b> – ab welchem Minus gewarnt bzw. erinnert wird.</li>
|
||||
<li><b>Bezahloptionen</b> – Barzahlung, PayPal und Überweisung samt Kontaktdaten.
|
||||
Nur aktivierte Wege erscheinen bei den Mitgliedern.</li>
|
||||
<li><b>Web-Striche erlauben</b> – ob Mitglieder selbst eintragen dürfen.</li>
|
||||
<li><b>Ausdruck</b> – Sortierung, Zeilenhöhe, leere Zeilen, Wasserzeichen und Fußzeile.</li>
|
||||
<li><b>Zahlungserinnerungen</b> – automatische Mails an Mitglieder unterhalb der
|
||||
Warnschwelle, samt Mindestabstand zwischen zwei Erinnerungen.</li>
|
||||
</ul>
|
||||
<p>Im <b>Protokoll</b> unten auf der Seite steht, wer wann welche Änderung vorgenommen hat.</p>
|
||||
<p>Preis pro Strich, Listenfenster, Warnschwelle, Bezahloptionen, Web-Striche, Gestaltung
|
||||
des Ausdrucks<?php echo $kann('branding') ? ', eigenes Design (Logo und Akzentfarbe)' : ''; ?>
|
||||
und Zahlungserinnerungen. Unten steht das Protokoll aller Admin-Änderungen.</p>
|
||||
|
||||
<h3>Konto, Abo und Daten</h3>
|
||||
<ul>
|
||||
<li><b>Kundenkonto</b>: Tarif, Rechnungen und Zahlungsdaten.</li>
|
||||
<li><b>Datenexport</b>: alle Daten des Mandanten als Datei – für ein Backup oder
|
||||
einen Umzug.</li>
|
||||
<li><b>Mandant löschen</b>: entfernt den Mandanten mit allen Daten endgültig.</li>
|
||||
<?php if ($kann('data_export')): ?>
|
||||
<li><b>Datenexport</b>: alle Daten des Mandanten als Datei.</li>
|
||||
<?php endif; ?>
|
||||
<li><b>Mandant löschen</b>: entfernt alles endgültig<?php echo $kann('data_export') ? ' – vorher Datenexport ziehen' : ''; ?>.</li>
|
||||
</ul>
|
||||
<div class="hint-box error">
|
||||
<p><b>Das Löschen eines Mandanten kann nicht rückgängig gemacht werden.</b> Lade dir
|
||||
vorher über den Datenexport eine Kopie herunter.</p>
|
||||
</div>
|
||||
|
||||
<h3>Rollen im Überblick</h3>
|
||||
<table>
|
||||
@@ -231,11 +180,10 @@ include "nav.php";
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($kann('faq')): ?>
|
||||
<hr>
|
||||
|
||||
<h3>Noch Fragen?</h3>
|
||||
<p>Häufige Fragen und die Antworten eurer Verwaltung stehen unter
|
||||
<a href="faq.php">FAQ Kaffeeliste</a>.</p>
|
||||
<p>Weitere Fragen beantwortet eure Verwaltung unter <a href="faq.php">FAQ Kaffeeliste</a>.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/saas-auth.php';
|
||||
require_once __DIR__ . '/features.php';
|
||||
|
||||
/**
|
||||
* Logo und Akzentfarbe des Mandanten, an dem der angemeldete Nutzer haengt.
|
||||
* Ohne Anmeldung, ohne freigeschaltete Funktion „Eigenes Design" oder ohne
|
||||
* hinterlegte Werte kommt ueberall ein leerer String zurueck - der Aufrufer
|
||||
* bleibt dann beim Standarddesign.
|
||||
*
|
||||
* Das Ergebnis wird pro Request gecached: header.php laeuft auf jeder Seite
|
||||
* und soll dafuer nicht mehrfach die Einstellungen laden.
|
||||
*
|
||||
* @return array{color: string, logo_url: string, tenant_name: string}
|
||||
*/
|
||||
function app_branding_for_current_user(PDO $pdo): array
|
||||
{
|
||||
static $cached = null;
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$leer = ['color' => '', 'logo_url' => '', 'tenant_name' => ''];
|
||||
|
||||
try {
|
||||
$user = saas_current_user($pdo);
|
||||
if ($user === null) {
|
||||
return $cached = $leer;
|
||||
}
|
||||
|
||||
$tenantId = (int)$user['tenant_id'];
|
||||
if (!app_feature_enabled($pdo, $tenantId, 'branding')) {
|
||||
return $cached = $leer;
|
||||
}
|
||||
|
||||
$settings = saas_fetch_tenant_settings($pdo, $tenantId);
|
||||
if ($settings === null) {
|
||||
return $cached = $leer;
|
||||
}
|
||||
|
||||
$color = (string)($settings['brand_color'] ?? '');
|
||||
// Zweite Pruefung neben der Formularvalidierung: was hier nicht wie
|
||||
// ein Hex-Wert aussieht, wird nie in ein Stylesheet geschrieben.
|
||||
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
|
||||
$color = '';
|
||||
}
|
||||
|
||||
return $cached = [
|
||||
'color' => strtolower($color),
|
||||
'logo_url' => trim((string)($settings['brand_logo'] ?? '')) !== '' ? 'tenant-logo-anzeigen.php' : '',
|
||||
'tenant_name' => (string)($settings['name'] ?? ''),
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
// Branding ist Beiwerk: faellt es aus (z. B. Migration noch nicht
|
||||
// eingespielt), laedt die Seite trotzdem im Standarddesign.
|
||||
return $cached = $leer;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt die Farbueberschreibungen fuer das Template-Stylesheet. main.css
|
||||
* verwendet durchgaengig genau einen Akzentton (#38761d) - hier werden die
|
||||
* sichtbaren Stellen davon auf die Mandantenfarbe umgestellt.
|
||||
*/
|
||||
function app_branding_css(string $color): string
|
||||
{
|
||||
$c = preg_match('/^#[0-9a-f]{6}$/', strtolower($color)) ? strtolower($color) : '#38761d';
|
||||
|
||||
return <<<CSS
|
||||
:root { --brand-color: {$c}; }
|
||||
a { color: var(--brand-color); }
|
||||
a:hover { border-bottom-color: var(--brand-color); color: var(--brand-color) !important; }
|
||||
header.major > :last-child { border-bottom-color: var(--brand-color); }
|
||||
input[type="text"]:focus, input[type="password"]:focus, input[type="email"]:focus,
|
||||
input[type="tel"]:focus, input[type="search"]:focus, input[type="url"]:focus,
|
||||
input[type="number"]:focus, input[type="date"]:focus, input[type="datetime-local"]:focus,
|
||||
select:focus, textarea:focus,
|
||||
input[type="checkbox"]:focus + label:before, input[type="radio"]:focus + label:before {
|
||||
border-color: var(--brand-color);
|
||||
box-shadow: 0 0 0 1px var(--brand-color);
|
||||
}
|
||||
input[type="checkbox"]:checked + label:before, input[type="radio"]:checked + label:before {
|
||||
background: var(--brand-color);
|
||||
border-color: var(--brand-color);
|
||||
}
|
||||
input[type="submit"], input[type="reset"], input[type="button"], button, .button {
|
||||
box-shadow: inset 0 0 0 2px var(--brand-color);
|
||||
color: var(--brand-color) !important;
|
||||
}
|
||||
input[type="submit"].primary, input[type="reset"].primary, input[type="button"].primary,
|
||||
button.primary, .button.primary {
|
||||
background-color: var(--brand-color);
|
||||
box-shadow: none;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
#header { border-bottom-color: var(--brand-color); }
|
||||
#menu ul a:hover, #menu ul span:hover { color: var(--brand-color); }
|
||||
.tenant-brand-line { border-bottom: solid 1px rgba(0, 0, 0, 0.08); }
|
||||
|
||||
CSS;
|
||||
}
|
||||
+7
-8
@@ -65,18 +65,17 @@ function faq_soft_delete(PDO $pdo, int $tenantId, int $id): bool
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds a small, generic starter FAQ for a brand-new tenant (mirrors the
|
||||
* excerpt already shown on the public landing page), so new customers do
|
||||
* not start with a completely empty FAQ. They can edit or delete every
|
||||
* entry afterwards.
|
||||
* Seeds two example questions for a brand-new tenant, damit die FAQ-Seite
|
||||
* nicht voellig leer startet und sofort erkennbar ist, wie ein Eintrag
|
||||
* aussieht. Bewusst nur zwei Beispiele statt eines fertigen Katalogs: die
|
||||
* Antworten sind Platzhalter, die jeder Mandant ohnehin auf seine eigene
|
||||
* Teekueche umschreibt. Beide Eintraege sind frei editier- und loeschbar.
|
||||
*/
|
||||
function faq_seed_default_entries(PDO $pdo, int $tenantId): void
|
||||
{
|
||||
$starters = [
|
||||
['Wie starten wir mit unserer Kaffeeliste?', 'Mitglieder unter „Mitglieder verwalten" anlegen, danach direkt Striche und Einzahlungen erfassen. Es ist keine weitere Einrichtung nötig.'],
|
||||
['Wer kann Mitglieder verwalten oder Einstellungen ändern?', 'Das hängt von der zugewiesenen Rolle ab: Administrator und Kassenwart können Buchungen vornehmen, nur Administrator und Inhaber können Einstellungen und Zugänge verwalten.'],
|
||||
['Können wir per PayPal bezahlen?', 'Ja, wenn PayPal in den Mandant-Einstellungen aktiviert ist. Einzahlungen lassen sich zusätzlich jederzeit manuell erfassen.'],
|
||||
['Wie erfahre ich meinen aktuellen Stand?', 'Im eigenen Dashboard unter „Meine Kaffeeliste" ist der aktuelle Stand jederzeit sichtbar.'],
|
||||
['Wie starten wir mit unserer Kaffeeliste?', 'Beispielantwort – bitte anpassen: Mitglieder unter „Mitglieder verwalten" anlegen, danach direkt Striche und Einzahlungen erfassen.'],
|
||||
['Wie erfahre ich meinen aktuellen Stand?', 'Beispielantwort – bitte anpassen: Unter „Meine Kaffeeliste" ist der aktuelle Stand jederzeit sichtbar.'],
|
||||
];
|
||||
|
||||
$stmt = $pdo->prepare('INSERT INTO faq_entries (tenant_id, question, answer, sort_order) VALUES (?, ?, ?, ?)');
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
/**
|
||||
* Funktions-Schalter je Mandant, gepflegt vom Betreiber im Back-Office
|
||||
* (backoffice-mandant.php). Sie liegen eine Ebene ueber den
|
||||
* Mandant-Einstellungen: der Kunde stellt dort ein, *wie* eine Funktion
|
||||
* arbeitet - hier entscheidet der Betreiber, ob sie ihm ueberhaupt zur
|
||||
* Verfuegung steht.
|
||||
*
|
||||
* Bewusst kein Bestandteil von saas_user_has_role(): Rollen regeln, wer
|
||||
* innerhalb eines Mandanten etwas darf, Features regeln den Funktionsumfang
|
||||
* des Mandanten als Ganzes. Beide Pruefungen stehen deshalb nebeneinander.
|
||||
*
|
||||
* @return array<string, array{label: string, description: string}>
|
||||
*/
|
||||
function app_feature_catalog(): array
|
||||
{
|
||||
return [
|
||||
'faq' => [
|
||||
'label' => 'FAQ-Seite',
|
||||
'description' => 'Eigene Fragen und Antworten für die Mitglieder des Mandanten.',
|
||||
],
|
||||
'self_entry' => [
|
||||
'label' => 'Striche selbst eintragen',
|
||||
'description' => 'Mitglieder tragen ihre Striche im eigenen Dashboard ein (zusätzlich zur Mandant-Einstellung „Web-Striche erlauben").',
|
||||
],
|
||||
'pdf_export' => [
|
||||
'label' => 'Kaffeeliste als PDF',
|
||||
'description' => 'Ausdruck der Strichliste zum Aufhängen.',
|
||||
],
|
||||
'paypal_inbox' => [
|
||||
'label' => 'PayPal-Zahlungseingang',
|
||||
'description' => 'Automatisches Verbuchen weitergeleiteter PayPal-Mails samt Zuordnungs-Warteschlange.',
|
||||
],
|
||||
'csv_import' => [
|
||||
'label' => 'CSV-Import',
|
||||
'description' => 'Einlesen von Einzahlungen und Mitgliedern aus einer Datei.',
|
||||
],
|
||||
'mail_dispatch' => [
|
||||
'label' => 'Info-Mails und Erinnerungen',
|
||||
'description' => 'Sammelmails an Mitglieder und automatische Zahlungserinnerungen.',
|
||||
],
|
||||
'year_closing' => [
|
||||
'label' => 'Jahresabschluss',
|
||||
'description' => 'Verteilen eines Gesamtbetrags als Guthaben auf alle Mitglieder.',
|
||||
],
|
||||
'data_export' => [
|
||||
'label' => 'Datenexport',
|
||||
'description' => 'Herunterladen aller Mandantendaten durch den Kunden selbst.',
|
||||
],
|
||||
'branding' => [
|
||||
'label' => 'Eigenes Design',
|
||||
'description' => 'Eigenes Logo und eigene Akzentfarbe in der App-Oberfläche des Mandanten.',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard fuer jede Funktion, wenn der Betreiber nichts abweichend gesetzt
|
||||
* hat. Alles ist freigeschaltet - ein neuer Kunde soll den vollen
|
||||
* Funktionsumfang bekommen, ohne dass der Betreiber erst etwas einschalten
|
||||
* muss.
|
||||
*/
|
||||
function app_feature_default(string $featureKey): bool
|
||||
{
|
||||
return array_key_exists($featureKey, app_feature_catalog());
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktueller Stand aller bekannten Funktionen fuer einen Mandanten.
|
||||
*
|
||||
* @return array<string, bool>
|
||||
*/
|
||||
function app_features_for_tenant(PDO $pdo, int $tenantId): array
|
||||
{
|
||||
$state = [];
|
||||
foreach (array_keys(app_feature_catalog()) as $key) {
|
||||
$state[$key] = app_feature_default($key);
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare('SELECT feature_key, enabled FROM tenant_features WHERE tenant_id = ?');
|
||||
$stmt->execute([$tenantId]);
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$key = (string)$row['feature_key'];
|
||||
if (array_key_exists($key, $state)) {
|
||||
$state[$key] = (int)$row['enabled'] === 1;
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Fehlt die Tabelle (Migration noch nicht eingespielt), bleibt es bei
|
||||
// den Standardwerten - eine nicht ausgerollte Migration darf keine
|
||||
// Funktion sperren.
|
||||
return $state;
|
||||
}
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Einzelne Funktion pruefen. Ergebnis wird pro Request zwischengespeichert,
|
||||
* damit Seiten mit mehreren Pruefungen (Navigation!) nicht je Aufruf erneut
|
||||
* die Datenbank befragen.
|
||||
*/
|
||||
function app_feature_enabled(PDO $pdo, int $tenantId, string $featureKey): bool
|
||||
{
|
||||
static $cache = [];
|
||||
|
||||
if ($tenantId <= 0) {
|
||||
return app_feature_default($featureKey);
|
||||
}
|
||||
|
||||
if (!array_key_exists($tenantId, $cache)) {
|
||||
$cache[$tenantId] = app_features_for_tenant($pdo, $tenantId);
|
||||
}
|
||||
|
||||
return $cache[$tenantId][$featureKey] ?? app_feature_default($featureKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Schalter eines Mandanten neu. Erwartet die Formulardaten des
|
||||
* Back-Office-Formulars: jede angehakte Funktion steht als Schluessel in
|
||||
* $input['features'].
|
||||
*
|
||||
* @param array<string, mixed> $submittedFeatures
|
||||
* @return list<string> Liste der geaenderten Funktionsschluessel
|
||||
*/
|
||||
function app_features_update(PDO $pdo, int $tenantId, array $submittedFeatures): array
|
||||
{
|
||||
$before = app_features_for_tenant($pdo, $tenantId);
|
||||
$changed = [];
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO tenant_features (tenant_id, feature_key, enabled)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE enabled = VALUES(enabled)'
|
||||
);
|
||||
|
||||
foreach (array_keys(app_feature_catalog()) as $key) {
|
||||
$enabled = !empty($submittedFeatures[$key]);
|
||||
$stmt->execute([$tenantId, $key, $enabled ? 1 : 0]);
|
||||
if (($before[$key] ?? true) !== $enabled) {
|
||||
$changed[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seitenwaechter: bricht mit einer verstaendlichen Meldung ab, wenn der
|
||||
* Betreiber die Funktion fuer diesen Mandanten abgeschaltet hat. Die Meldung
|
||||
* wird bewusst in die bereits geoeffnete Seite geschrieben, damit sie im
|
||||
* gewohnten Layout erscheint statt als nackter Text.
|
||||
*/
|
||||
function app_feature_notice_html(string $featureKey): string
|
||||
{
|
||||
$label = app_feature_catalog()[$featureKey]['label'] ?? $featureKey;
|
||||
|
||||
return '<div class="hint-box error"><p><b>' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8')
|
||||
. ' ist für euren Zugang nicht freigeschaltet.</b><br>'
|
||||
. 'Wende dich an den Betreiber der Kaffeeliste, wenn ihr diese Funktion nutzen möchtet.</p></div>';
|
||||
}
|
||||
@@ -523,6 +523,21 @@ function ledger_record_payment(PDO $pdo, int $tenantId, int $participantId, int
|
||||
return (int)$pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bis zu dieser Mitgliederzahl passt die Strichliste auf ein einzelnes Blatt.
|
||||
* Erst darueber wird sie auf Vorder- und Rueckseite aufgeteilt - und nur dann
|
||||
* ergeben auch die Vorder-/Rueckseiten-Ansichten beim Erfassen einen Sinn.
|
||||
*/
|
||||
const LEDGER_SINGLE_PAGE_MAX_MEMBERS = 50;
|
||||
|
||||
/**
|
||||
* Braucht dieser Mandant eine zweiseitige Liste (Vorder- und Rueckseite)?
|
||||
*/
|
||||
function ledger_uses_two_sided_sheet(int $activeMemberCount): bool
|
||||
{
|
||||
return $activeMemberCount > LEDGER_SINGLE_PAGE_MAX_MEMBERS;
|
||||
}
|
||||
|
||||
/**
|
||||
* "100-Tage-Liste" Vorder-/Rueckseiten-Aufteilung: aktive Teilnehmer, deren
|
||||
* summierte Striche im nachlaufenden Fenster die Zehner-Schwelle erreichen
|
||||
|
||||
+16
-4
@@ -286,7 +286,9 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
|
||||
ts.cash_contact,
|
||||
ts.bank_transfer_enabled,
|
||||
ts.bank_account_holder,
|
||||
ts.bank_iban
|
||||
ts.bank_iban,
|
||||
ts.brand_logo,
|
||||
ts.brand_color
|
||||
FROM tenants t
|
||||
LEFT JOIN tenant_settings ts ON ts.tenant_id = t.id
|
||||
WHERE t.id = ?
|
||||
@@ -332,6 +334,8 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
|
||||
'bank_transfer_enabled' => (int)$settings['bank_transfer_enabled'],
|
||||
'bank_account_holder' => (string)$settings['bank_account_holder'],
|
||||
'bank_iban' => (string)$settings['bank_iban'],
|
||||
'brand_logo' => (string)$settings['brand_logo'],
|
||||
'brand_color' => (string)$settings['brand_color'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -353,8 +357,14 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
||||
$pdfWatermarkText = trim((string)($input['pdf_watermark_text'] ?? ''));
|
||||
$pdfFooterText = trim((string)($input['pdf_footer_text'] ?? ''));
|
||||
$paymentReminderIntervalDays = filter_var($input['payment_reminder_interval_days'] ?? null, FILTER_VALIDATE_INT);
|
||||
$brandColor = strtolower(trim((string)($input['brand_color'] ?? '')));
|
||||
|
||||
$errors = [];
|
||||
// Leer = Standarddesign. Sonst ein vollstaendiger Hex-Wert, der ohne
|
||||
// weitere Aufbereitung in das erzeugte Stylesheet geschrieben werden kann.
|
||||
if ($brandColor !== '' && !preg_match('/^#[0-9a-f]{6}$/', $brandColor)) {
|
||||
$errors[] = 'Die Akzentfarbe muss ein Hex-Wert wie #38761d sein.';
|
||||
}
|
||||
if (strlen($tenantName) < 3 || strlen($tenantName) > 255) {
|
||||
$errors[] = 'Der Kundenname muss zwischen 3 und 255 Zeichen lang sein.';
|
||||
}
|
||||
@@ -433,8 +443,8 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
||||
pdf_watermark_text, pdf_footer_text,
|
||||
payment_reminder_enabled, payment_reminder_interval_days,
|
||||
cash_enabled, cash_contact, bank_transfer_enabled,
|
||||
bank_account_holder, bank_iban)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
bank_account_holder, bank_iban, brand_color)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
mark_price_cents = VALUES(mark_price_cents),
|
||||
self_entry_enabled = VALUES(self_entry_enabled),
|
||||
@@ -453,7 +463,8 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
||||
cash_contact = VALUES(cash_contact),
|
||||
bank_transfer_enabled = VALUES(bank_transfer_enabled),
|
||||
bank_account_holder = VALUES(bank_account_holder),
|
||||
bank_iban = VALUES(bank_iban)'
|
||||
bank_iban = VALUES(bank_iban),
|
||||
brand_color = VALUES(brand_color)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$tenantId,
|
||||
@@ -475,6 +486,7 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
||||
$bankTransferEnabled,
|
||||
$bankAccountHolder,
|
||||
$bankIban,
|
||||
$brandColor,
|
||||
]);
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
+16
-4
@@ -28,14 +28,25 @@ function tenant_logo_path(string $filename): ?string
|
||||
return is_file($path) ? $path : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dateiname-Praefix je Verwendungszweck. Das Wasserzeichen behaelt bewusst
|
||||
* seinen historischen Praefix "logo_", damit bereits hochgeladene Dateien
|
||||
* weiter gefunden werden.
|
||||
*/
|
||||
function tenant_logo_prefix(string $kind): string
|
||||
{
|
||||
return $kind === 'brand' ? 'brand_' : 'logo_';
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt ein hochgeladenes Logo (PNG oder JPEG) entgegen, prueft und speichert
|
||||
* es unter einem festen Namen pro Mandant.
|
||||
* es unter einem festen Namen pro Mandant und Verwendungszweck.
|
||||
*
|
||||
* @param array $file Eintrag aus $_FILES
|
||||
* @param string $kind 'watermark' (Ausdruck) oder 'brand' (App-Oberflaeche)
|
||||
* @return array{ok: bool, filename?: string, error?: string}
|
||||
*/
|
||||
function tenant_logo_store(int $tenantId, array $file): array
|
||||
function tenant_logo_store(int $tenantId, array $file, string $kind = 'watermark'): array
|
||||
{
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
return ['ok' => false, 'error' => 'Das Logo konnte nicht hochgeladen werden.'];
|
||||
@@ -63,14 +74,15 @@ function tenant_logo_store(int $tenantId, array $file): array
|
||||
}
|
||||
|
||||
// Alte Varianten (andere Endung) desselben Mandanten entfernen.
|
||||
$prefix = tenant_logo_prefix($kind);
|
||||
foreach (['png', 'jpg'] as $altExt) {
|
||||
$old = $dir . '/logo_' . $tenantId . '.' . $altExt;
|
||||
$old = $dir . '/' . $prefix . $tenantId . '.' . $altExt;
|
||||
if (is_file($old)) {
|
||||
@unlink($old);
|
||||
}
|
||||
}
|
||||
|
||||
$filename = 'logo_' . $tenantId . '.' . $ext;
|
||||
$filename = $prefix . $tenantId . '.' . $ext;
|
||||
if (!move_uploaded_file($tmp, $dir . '/' . $filename)) {
|
||||
return ['ok' => false, 'error' => 'Das Logo konnte nicht gespeichert werden.'];
|
||||
}
|
||||
|
||||
@@ -129,6 +129,25 @@ div.hint-box {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Kopfzeile mit Mandanten-Logo (headerline.php)
|
||||
*
|
||||
* Das Logo soll erkennbar, aber kein Blickfang sein - die Kaffeeliste bleibt
|
||||
* eine Arbeitsoberflaeche. Feste Maximalhoehe, damit ein hochkantes oder sehr
|
||||
* grosses Logo nicht die halbe Seite einnimmt.
|
||||
* ------------------------------------------------------------------------ */
|
||||
.tenant-brand-line {
|
||||
margin-bottom: 1.5em;
|
||||
padding-bottom: 1em;
|
||||
}
|
||||
|
||||
.tenant-brand-logo {
|
||||
display: block;
|
||||
max-height: 60px;
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Hervorgehobene Tabellenzeile (aktuell gebuchter Tarif)
|
||||
*
|
||||
|
||||
+55
-1
@@ -2,16 +2,35 @@
|
||||
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/platform-admin.php';
|
||||
require_once __DIR__ . '/app/features.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$user = app_require_platform_admin($pdo);
|
||||
|
||||
$tenantId = filter_input(INPUT_GET, 'tenant_id', FILTER_VALIDATE_INT);
|
||||
// Die Mandanten-ID kommt beim Speichern der Funktions-Schalter aus dem
|
||||
// Formular, sonst aus der URL.
|
||||
$tenantId = filter_input(INPUT_POST, 'tenant_id', FILTER_VALIDATE_INT)
|
||||
?: filter_input(INPUT_GET, 'tenant_id', FILTER_VALIDATE_INT);
|
||||
if ($tenantId === null || $tenantId === false || $tenantId <= 0) {
|
||||
http_response_code(400);
|
||||
exit('Ungültige Mandanten-ID.');
|
||||
}
|
||||
|
||||
$featureMeldung = null;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['aktion'] ?? '') === 'features_speichern') {
|
||||
app_require_csrf();
|
||||
|
||||
$geaendert = app_features_update($pdo, $tenantId, (array)($_POST['features'] ?? []));
|
||||
if ($geaendert !== []) {
|
||||
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'backoffice.features_updated', 'tenant', $tenantId, [
|
||||
'changed' => $geaendert,
|
||||
]);
|
||||
}
|
||||
$featureMeldung = $geaendert === []
|
||||
? 'Keine Änderung an den Funktionen.'
|
||||
: 'Die Funktionen wurden gespeichert.';
|
||||
}
|
||||
|
||||
$detail = app_backoffice_fetch_tenant_detail($pdo, $tenantId);
|
||||
if ($detail === null) {
|
||||
http_response_code(404);
|
||||
@@ -23,6 +42,8 @@ if ($detail === null) {
|
||||
// nachvollziehbar bleibt (siehe AVV-Transparenzpflicht).
|
||||
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'backoffice.tenant_viewed', 'tenant', $tenantId);
|
||||
|
||||
$tenantFeatures = app_features_for_tenant($pdo, $tenantId);
|
||||
|
||||
$rollenLabels = ['owner' => 'Inhaber', 'admin' => 'Administrator', 'treasurer' => 'Kassenwart', 'member' => 'Mitglied', 'viewer' => 'Betrachter'];
|
||||
|
||||
include 'header.php';
|
||||
@@ -51,6 +72,39 @@ include 'nav.php';
|
||||
<button type="submit">Datenexport dieses Mandanten herunterladen</button>
|
||||
</form>
|
||||
|
||||
<h3>Freigeschaltete Funktionen</h3>
|
||||
<p>Zentral vom Betreiber gesteuert: abgehakte Funktionen sind für diesen Mandanten
|
||||
gesperrt und verschwinden dort aus Menü und Seiten. Die Einstellungen des Kunden
|
||||
bleiben dabei erhalten.</p>
|
||||
|
||||
<?php if ($featureMeldung !== null): ?>
|
||||
<div class="hint-box success"><p><?php echo saas_html($featureMeldung); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="backoffice-mandant.php?tenant_id=<?php echo (int)$tenantId; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="features_speichern">
|
||||
<input type="hidden" name="tenant_id" value="<?php echo (int)$tenantId; ?>">
|
||||
<div class="row">
|
||||
<?php foreach (app_feature_catalog() as $featureKey => $feature): ?>
|
||||
<div class="col-6 col-12-small">
|
||||
<input type="checkbox"
|
||||
id="feature_<?php echo saas_html($featureKey); ?>"
|
||||
name="features[<?php echo saas_html($featureKey); ?>]"
|
||||
value="1"
|
||||
<?php echo ($tenantFeatures[$featureKey] ?? true) ? 'checked' : ''; ?>>
|
||||
<label for="feature_<?php echo saas_html($featureKey); ?>">
|
||||
<?php echo saas_html($feature['label']); ?>
|
||||
</label>
|
||||
<small><?php echo saas_html($feature['description']); ?></small>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<ul class="actions">
|
||||
<li><button type="submit">Funktionen speichern</button></li>
|
||||
</ul>
|
||||
</form>
|
||||
|
||||
<h3>Mitglieder mit Zugang</h3>
|
||||
<table>
|
||||
<tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th>Status</th></tr>
|
||||
|
||||
@@ -6,6 +6,7 @@ include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/imports.php";
|
||||
require_once __DIR__ . "/app/audit.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
app_require_csrf();
|
||||
include "header.php";
|
||||
include "headerline.php";
|
||||
@@ -36,6 +37,12 @@ if (!$hasAccess) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!app_feature_enabled($pdo, $tenantId, 'csv_import')) {
|
||||
echo app_feature_notice_html('csv_import');
|
||||
include "footer.php";
|
||||
exit;
|
||||
}
|
||||
|
||||
function csv_upload_dir(): string
|
||||
{
|
||||
return APP_ROOT . '/var/uploads';
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Funktions-Schalter je Mandant, gepflegt vom Betreiber im Back-Office.
|
||||
--
|
||||
-- Bewusst getrennt von tenant_settings: dort stellt der Kunde selbst ein, wie
|
||||
-- seine Kaffeeliste arbeitet. Hier entscheidet der Betreiber, welche
|
||||
-- Funktionen dem Mandanten ueberhaupt zur Verfuegung stehen (z. B. zum
|
||||
-- Abschalten einer Funktion, die bei einem Kunden Probleme macht).
|
||||
--
|
||||
-- Ohne Zeile gilt eine Funktion als aktiv (siehe app/features.php); die
|
||||
-- Tabelle haelt also nur die Abweichungen vom Standard.
|
||||
CREATE TABLE IF NOT EXISTS tenant_features (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INT NOT NULL,
|
||||
feature_key VARCHAR(64) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_tenant_feature (tenant_id, feature_key),
|
||||
CONSTRAINT fk_tenant_features_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Eigenes Design je Mandant: Logo und Akzentfarbe fuer die App-Oberflaeche.
|
||||
--
|
||||
-- Getrennt von pdf_watermark_logo: das Wasserzeichen ist ein blasses
|
||||
-- Hintergrundbild fuer den Ausdruck, das Marken-Logo dagegen wird in der
|
||||
-- Kopfzeile jeder Seite scharf angezeigt. Leere Werte = Standarddesign.
|
||||
ALTER TABLE tenant_settings
|
||||
ADD COLUMN brand_logo VARCHAR(255) NOT NULL DEFAULT '' AFTER bank_iban,
|
||||
ADD COLUMN brand_color VARCHAR(7) NOT NULL DEFAULT '' AFTER brand_logo;
|
||||
@@ -3,6 +3,7 @@
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/data-export.php';
|
||||
require_once __DIR__ . '/app/audit.php';
|
||||
require_once __DIR__ . '/app/features.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$user = saas_require_login();
|
||||
@@ -12,6 +13,11 @@ if (!saas_can_manage_tenant_settings($user)) {
|
||||
exit('Kein Zugriff.');
|
||||
}
|
||||
|
||||
if (!app_feature_enabled($pdo, (int)$user['tenant_id'], 'data_export')) {
|
||||
http_response_code(403);
|
||||
exit('Der Datenexport ist für euren Zugang nicht freigeschaltet.');
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
app_require_csrf();
|
||||
|
||||
|
||||
@@ -29,8 +29,13 @@ dort ist ab zwei Mitgliedschaften auch „Mandant wechseln" ergänzt.
|
||||
|
||||
- ✅ Adaptives Layout nach Mitgliederanzahl (Zeilenhöhe 16–40px je nach
|
||||
Mitgliederzahl).
|
||||
- ✅ Vorder-/Rückseite bzw. mehrere Seiten erst ab 50 Mitgliedern
|
||||
(`EXPORT_MULTI_PAGE_THRESHOLD`), darunter eine einseitige Liste.
|
||||
- ✅ Vorder-/Rückseite bzw. mehrere Seiten erst bei **mehr als** 50
|
||||
Mitgliedern, bis einschließlich 50 eine einseitige Liste. Die Schwelle
|
||||
liegt seit 2026-08-17 als `LEDGER_SINGLE_PAGE_MAX_MEMBERS` /
|
||||
`ledger_uses_two_sided_sheet()` in `app/ledger.php` und gilt damit
|
||||
einheitlich für den Ausdruck **und** die Vorder-/Rückseiten-Auswahl beim
|
||||
Erfassen (`stricheintragen.php`, `einzahlung.php`) – dort erschienen die
|
||||
Buttons vorher auch bei Kleinstteams, für die es nur ein Blatt gibt.
|
||||
- ✅ Admin-Einstellung „Freie Zeilen für neue Mitglieder"
|
||||
(`tenant_settings.pdf_show_empty_rows`, Formular in
|
||||
`mandant-einstellungen.php`).
|
||||
|
||||
+65
-5
@@ -54,15 +54,75 @@ demselben Grund wie beim Setzen).
|
||||
|
||||
- `backoffice.php`: Übersicht aller Mandanten mit Kürzel, Status,
|
||||
Erstelldatum, Teilnehmerzahl (aktiv/gesamt) und Saldensumme.
|
||||
- `backoffice-mandant.php?tenant_id=X`: reine Leseansicht eines einzelnen
|
||||
Mandanten – Einstellungen, Mitglieder mit Rolle, letzte 20 Buchungen,
|
||||
letzte 20 Admin-Aktionen. Absichtlich **kein** Schreibzugriff auf
|
||||
Mandantendaten von hier aus, um das Risiko einer versehentlichen
|
||||
Fremdänderung auszuschließen.
|
||||
- `backoffice-mandant.php?tenant_id=X`: Ansicht eines einzelnen Mandanten
|
||||
– Einstellungen, Mitglieder mit Rolle, letzte 20 Buchungen, letzte 20
|
||||
Admin-Aktionen, dazu die Funktions-Schalter (siehe unten). Absichtlich
|
||||
**kein** Schreibzugriff auf die eigentlichen Mandantendaten (Buchungen,
|
||||
Mitglieder, Einstellungen des Kunden) von hier aus, um das Risiko einer
|
||||
versehentlichen Fremdänderung auszuschließen.
|
||||
- `backoffice-export.php`: nutzt dieselbe `app_export_tenant_data()`-
|
||||
Funktion wie der Selbstbedienungs-Export, aber ausgelöst durch den
|
||||
Platform-Admin für einen beliebigen Mandanten.
|
||||
|
||||
## Funktions-Schalter je Mandant
|
||||
|
||||
Stand: 2026-08-17. Zentralstelle des Betreibers, um einzelne Funktionen
|
||||
pro Mandant freizuschalten oder zu sperren – gepflegt in
|
||||
`backoffice-mandant.php`, umgesetzt in `app/features.php` und der Tabelle
|
||||
`tenant_features` (Migration `0025_tenant_features.sql`).
|
||||
|
||||
Schaltbar sind: FAQ-Seite, Striche selbst eintragen, Kaffeeliste als PDF,
|
||||
PayPal-Zahlungseingang, CSV-Import, Info-Mails und Erinnerungen,
|
||||
Jahresabschluss, Datenexport, eigenes Design.
|
||||
|
||||
Design-Entscheidungen:
|
||||
|
||||
- **Getrennt von `tenant_settings`.** Dort stellt der Kunde ein, *wie*
|
||||
eine Funktion arbeitet; hier entscheidet der Betreiber, ob sie ihm
|
||||
überhaupt zur Verfügung steht. Beide Prüfungen stehen nebeneinander,
|
||||
z. B. beim Selbsteintrag: `self_entry_enabled` (Kunde) **und**
|
||||
Feature `self_entry` (Betreiber).
|
||||
- **Ohne Zeile = freigeschaltet.** `tenant_features` hält nur die
|
||||
Abweichungen vom Standard; ein neuer Mandant bekommt automatisch den
|
||||
vollen Funktionsumfang, ohne dass der Betreiber etwas einschalten muss.
|
||||
Fehlt die Tabelle (Migration noch nicht ausgerollt), bleibt ebenfalls
|
||||
alles freigeschaltet – eine ausstehende Migration darf keine Funktion
|
||||
sperren.
|
||||
- **Gesperrt heißt unsichtbar.** Die betroffenen Menüpunkte und
|
||||
Schaltflächen verschwinden (`footer.php`, `kaffeeliste.php`,
|
||||
`einzahlung.php`, `konto.php`); ein direkter Aufruf der Seite endet mit
|
||||
einem Hinweis statt mit einem Fehler. Die POST-Verarbeitung der
|
||||
betroffenen Seiten hängt an derselben Prüfung, eine gesperrte Funktion
|
||||
lässt sich also auch nicht per Formular-POST auslösen.
|
||||
- Jede Änderung landet als `backoffice.features_updated` im Audit-Log des
|
||||
betroffenen Mandanten – wie jeder andere Back-Office-Zugriff auch.
|
||||
|
||||
Das Back-Office ist außerdem jetzt für Platform-Admins im Menü verlinkt
|
||||
(Gruppe „Betreiber" in `footer.php`); vorher war es nur über die direkte
|
||||
URL erreichbar.
|
||||
|
||||
## Eigenes Design je Mandant
|
||||
|
||||
Stand: 2026-08-17, hängt am Feature `branding`. Der Kunde hinterlegt in
|
||||
`mandant-einstellungen.php` eine Akzentfarbe und ein Logo:
|
||||
|
||||
- Die Farbe (`tenant_settings.brand_color`, validiert als `#rrggbb`) wird
|
||||
in `header.php` als kleines Stylesheet eingebettet
|
||||
(`app/branding.php`), das die Akzentfarbe des Templates überschreibt.
|
||||
Eine eigene CSS-Datei je Mandant wäre ein zusätzlicher Request für
|
||||
wenige Zeilen.
|
||||
- Das Logo (`tenant_settings.brand_logo`) liegt wie das PDF-Wasserzeichen
|
||||
in `var/tenant_logos` und ist damit **nicht** direkt per URL abrufbar –
|
||||
sonst wäre jedes Kundenlogo unter einer ratbaren Adresse öffentlich.
|
||||
Ausgeliefert wird es über `tenant-logo-anzeigen.php`, das ausschließlich
|
||||
das Logo des Mandanten des angemeldeten Nutzers ausgibt.
|
||||
- Wasserzeichen (Ausdruck) und Marken-Logo (Oberfläche) sind getrennte
|
||||
Dateien mit eigenem Namenspräfix (`logo_` bzw. `brand_`).
|
||||
- Sperrt der Betreiber `branding`, verschwindet der Abschnitt aus den
|
||||
Einstellungen und die Oberfläche fällt aufs Standarddesign zurück. Die
|
||||
gespeicherte Farbe bleibt erhalten und gilt nach einer erneuten
|
||||
Freischaltung wieder.
|
||||
|
||||
## Verhältnis zum Selbstbedienungs-Export (`datenexport.php`)
|
||||
|
||||
Bewusste Entscheidung: Der bestehende Selbstbedienungs-Export für
|
||||
|
||||
+16
-1
@@ -4,6 +4,7 @@ ob_start();
|
||||
|
||||
include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
app_require_csrf();
|
||||
include "header.php";
|
||||
|
||||
@@ -193,7 +194,17 @@ if ($_SERVER["REQUEST_METHOD"] == "POST" ) {
|
||||
$action = (string)($_GET['action'] ?? 'alle');
|
||||
}
|
||||
|
||||
$mitarbeiter = einzahlung_fetch_participants($pdo, $tenantId, $action);
|
||||
// Vorder-/Rueckseite nur, wenn der Ausdruck auch wirklich zwei Seiten hat
|
||||
// (siehe ledger_uses_two_sided_sheet). Bei kleinen Teams gibt es nur ein Blatt.
|
||||
$alleAktiven = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]);
|
||||
$zweiseitig = ledger_uses_two_sided_sheet(count($alleAktiven));
|
||||
if (!$zweiseitig) {
|
||||
$action = 'alle';
|
||||
}
|
||||
|
||||
$mitarbeiter = $action === 'alle'
|
||||
? $alleAktiven
|
||||
: einzahlung_fetch_participants($pdo, $tenantId, $action);
|
||||
|
||||
?>
|
||||
|
||||
@@ -223,6 +234,7 @@ $mitarbeiter = einzahlung_fetch_participants($pdo, $tenantId, $action);
|
||||
</div>
|
||||
|
||||
<ul class="actions">
|
||||
<?php if ($zweiseitig): ?>
|
||||
<li>
|
||||
<form action="einzahlung.php" method="get">
|
||||
<input type="hidden" name="action" value="vorderseite">
|
||||
@@ -235,6 +247,7 @@ $mitarbeiter = einzahlung_fetch_participants($pdo, $tenantId, $action);
|
||||
<button type="submit">Rückseite</button>
|
||||
</form>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<li>
|
||||
<form action="einzahlung.php" method="get">
|
||||
<input type="hidden" name="action" value="alle">
|
||||
@@ -246,11 +259,13 @@ $mitarbeiter = einzahlung_fetch_participants($pdo, $tenantId, $action);
|
||||
<button type="submit">Letzten Einträge</button>
|
||||
</form>
|
||||
</li>
|
||||
<?php if (app_feature_enabled($pdo, $tenantId, 'csv_import')): ?>
|
||||
<li>
|
||||
<form action="csvupload.php" method="get">
|
||||
<button type="submit">CSV Upload</button>
|
||||
</form>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
|
||||
+10
-4
@@ -3,6 +3,7 @@
|
||||
include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/tenant-logo.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$saasUser = saas_current_user($pdo);
|
||||
@@ -19,6 +20,11 @@ if (!$hasAccess) {
|
||||
exit('Kein Zugriff.');
|
||||
}
|
||||
|
||||
if (!app_feature_enabled($pdo, $tenantId, 'pdf_export')) {
|
||||
http_response_code(403);
|
||||
exit('Der Ausdruck der Kaffeeliste ist für euren Zugang nicht freigeschaltet.');
|
||||
}
|
||||
|
||||
$settings = saas_fetch_tenant_settings($pdo, $tenantId);
|
||||
$windowDays = $settings['sheet_window_days'] ?? 100;
|
||||
$markPrice = number_format(($settings['mark_price_cents'] ?? 20) / 100, 2, ',', '.');
|
||||
@@ -29,9 +35,9 @@ $watermarkText = trim((string)($settings['pdf_watermark_text'] ?? ''));
|
||||
$footerText = trim((string)($settings['pdf_footer_text'] ?? ''));
|
||||
$watermarkLogoPath = tenant_logo_path((string)($settings['pdf_watermark_logo'] ?? '')) ?? '';
|
||||
|
||||
// Ab dieser Mitgliederzahl wird auf zwei Seiten (Vorder-/Rueckseite) statt
|
||||
// einer einzelnen Seite gedruckt.
|
||||
define('EXPORT_MULTI_PAGE_THRESHOLD', 50);
|
||||
// Vorder-/Rueckseite erst oberhalb der gemeinsamen Schwelle aus app/ledger.php
|
||||
// (LEDGER_SINGLE_PAGE_MAX_MEMBERS): bis einschliesslich 50 Personen bleibt der
|
||||
// Ausdruck bewusst einseitig.
|
||||
// TCPDF's writeHTML() ignoriert CSS-Zeilenhoehen/-innenabstaende auf
|
||||
// Tabellenzeilen vollstaendig (getestet: weder "height" auf <tr> noch
|
||||
// "padding" auf <td> haben irgendeinen Einfluss auf die gerenderte Zeilen-
|
||||
@@ -57,7 +63,7 @@ define('EXPORT_ROW_OVERHEAD_MM', 0.2);
|
||||
|
||||
$allActive = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]);
|
||||
$memberCount = count($allActive);
|
||||
$isMultiPage = $memberCount >= EXPORT_MULTI_PAGE_THRESHOLD;
|
||||
$isMultiPage = ledger_uses_two_sided_sheet($memberCount);
|
||||
|
||||
/**
|
||||
* Verfuegbare Hoehe (mm) fuer die Datenzeilen einer A4-Seite: Gesamthoehe
|
||||
|
||||
@@ -4,6 +4,7 @@ include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/faq.php";
|
||||
require_once __DIR__ . "/app/audit.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
app_require_csrf();
|
||||
include "header.php";
|
||||
include "headerline.php";
|
||||
@@ -37,6 +38,12 @@ if (!$hasAccess) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!app_feature_enabled($pdo, $tenantId, 'faq')) {
|
||||
echo app_feature_notice_html('faq');
|
||||
include "footer.php";
|
||||
exit;
|
||||
}
|
||||
|
||||
$meldung = null;
|
||||
$fehler = null;
|
||||
$bearbeitenId = null;
|
||||
|
||||
+39
@@ -30,6 +30,31 @@ if ($saasNavUser !== null && function_exists('saas_list_user_memberships')) {
|
||||
$saasHasMultipleTenants = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Vom Betreiber gesperrte Funktionen erscheinen gar nicht erst im Menue -
|
||||
// ein Menuepunkt, der nur zu einer Absage fuehrt, ist schlechter als keiner.
|
||||
require_once __DIR__ . '/app/features.php';
|
||||
$saasNavTenantId = $saasNavUser !== null ? (int)$saasNavUser['tenant_id'] : 0;
|
||||
$saasNavFeature = static function (string $key) use ($saasNavTenantId): bool {
|
||||
if ($saasNavTenantId <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return app_feature_enabled(app_db_pdo(), $saasNavTenantId, $key);
|
||||
};
|
||||
|
||||
// Das Back-Office war bisher nur ueber die direkte URL erreichbar. Es ist die
|
||||
// Zentralstelle des Betreibers und gehoert deshalb ins Menue - aber nur fuer
|
||||
// Platform-Admins, nicht fuer Mandanten-Rollen.
|
||||
$saasNavIsPlatformAdmin = false;
|
||||
if ($saasNavUser !== null) {
|
||||
try {
|
||||
require_once __DIR__ . '/app/platform-admin.php';
|
||||
$saasNavIsPlatformAdmin = app_is_platform_admin(app_db_pdo(), (int)$saasNavUser['user_id']);
|
||||
} catch (Throwable $e) {
|
||||
$saasNavIsPlatformAdmin = false;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<ul>
|
||||
<li>
|
||||
@@ -37,7 +62,9 @@ if ($saasNavUser !== null && function_exists('saas_list_user_memberships')) {
|
||||
<ul class="menu-group-items">
|
||||
<li><a href="index.php">Meine Kaffeeliste</a></li>
|
||||
<li><a href="anleitung.php">Anleitung</a></li>
|
||||
<?php if ($saasNavFeature('faq')): ?>
|
||||
<li><a href="faq.php">FAQ Kaffeeliste</a></li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</li>
|
||||
<?php if ($canUseVerwaltungGroup): ?>
|
||||
@@ -45,11 +72,15 @@ if ($saasNavUser !== null && function_exists('saas_list_user_memberships')) {
|
||||
<span class="menu-group-label">Verwaltung</span>
|
||||
<ul class="menu-group-items">
|
||||
<li><a href="einzahlung.php">Einzahlung eintragen</a></li>
|
||||
<?php if ($saasNavFeature('paypal_inbox')): ?>
|
||||
<li><a href="paypal-zuordnung.php">PayPal-Zahlungen</a></li>
|
||||
<?php endif; ?>
|
||||
<li><a href="stricheintragen.php">Striche eintragen</a></li>
|
||||
<li><a href="kaffeeliste.php">Kaffeeliste anzeigen</a></li>
|
||||
<li><a href="ledger-preview.php">Journal-Vorschau</a></li>
|
||||
<?php if ($saasNavFeature('year_closing')): ?>
|
||||
<li><a href="jahresauswertung.php">Jahresabschluss</a></li>
|
||||
<?php endif; ?>
|
||||
<?php if ($saasCanManageMembersNav): ?>
|
||||
<li><a href="mitarbeiterverwalten.php">Mitglieder verwalten</a></li>
|
||||
<li><a href="hinweise.php">Hinweise verwalten</a></li>
|
||||
@@ -74,6 +105,14 @@ if ($saasNavUser !== null && function_exists('saas_list_user_memberships')) {
|
||||
<li><a href="logout.php">Logout</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php if ($saasNavIsPlatformAdmin): ?>
|
||||
<li>
|
||||
<span class="menu-group-label">Betreiber</span>
|
||||
<ul class="menu-group-items">
|
||||
<li><a href="backoffice.php">Back-Office</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
+17
-2
@@ -11,12 +11,27 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/app.css" />
|
||||
</head>
|
||||
<body class="is-preload">
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/app/ledger.php';
|
||||
require_once __DIR__ . '/app/notices.php';
|
||||
require_once __DIR__ . '/app/features.php';
|
||||
require_once __DIR__ . '/app/branding.php';
|
||||
|
||||
// Eigenes Design des Mandanten: die Akzentfarbe wird als kleines Stylesheet
|
||||
// direkt hier eingebettet - eine eigene CSS-Datei je Mandant waere ein
|
||||
// zusaetzlicher Request fuer wenige Zeilen. Die Farbe ist als Hex-Wert
|
||||
// validiert (saas_update_tenant_settings), kann also nicht aus der
|
||||
// Style-Deklaration ausbrechen.
|
||||
$headerBranding = app_branding_for_current_user(app_db_pdo());
|
||||
if ($headerBranding['color'] !== '') {
|
||||
echo "\t\t<style>\n" . app_branding_css($headerBranding['color']) . "\t\t</style>\n";
|
||||
}
|
||||
|
||||
?>
|
||||
</head>
|
||||
<body class="is-preload">
|
||||
<?php
|
||||
|
||||
// Aktuellen Hinweis fuer den passenden Mandanten abrufen: eingeloggte
|
||||
// SaaS-Nutzer sehen den Hinweis ihres Mandanten, der Legacy-/Dev-Fallback
|
||||
|
||||
+17
-1
@@ -1,2 +1,18 @@
|
||||
<?php
|
||||
// Reserved for an optional header line.
|
||||
|
||||
// Kopfzeile des Mandanten: zeigt das hinterlegte Logo, wenn der Betreiber das
|
||||
// eigene Design freigeschaltet hat und der Kunde eines hochgeladen hat.
|
||||
// Ohne Logo bleibt die Zeile komplett aus - eine leere Flaeche waere
|
||||
// schlechter als gar keine.
|
||||
|
||||
require_once __DIR__ . '/app/branding.php';
|
||||
|
||||
$headerlineBranding = app_branding_for_current_user(app_db_pdo());
|
||||
|
||||
if ($headerlineBranding['logo_url'] !== '') {
|
||||
echo '<div class="tenant-brand-line">'
|
||||
. '<img src="' . htmlspecialchars($headerlineBranding['logo_url'], ENT_QUOTES, 'UTF-8') . '"'
|
||||
. ' alt="' . htmlspecialchars($headerlineBranding['tenant_name'], ENT_QUOTES, 'UTF-8') . '"'
|
||||
. ' class="tenant-brand-logo">'
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ if ($appHost !== null && $requestHost !== strtolower($appHost)) {
|
||||
|
||||
include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
app_require_csrf();
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
@@ -106,6 +107,12 @@ function dashboard_record_self_entry(PDO $pdo, array $participant, array $settin
|
||||
return ['ok' => false, 'error' => 'Die eigene Stricherfassung ist aktuell deaktiviert.'];
|
||||
}
|
||||
|
||||
// Zweite, uebergeordnete Huerde: der Betreiber kann die Funktion fuer den
|
||||
// ganzen Mandanten sperren, unabhaengig von dessen eigener Einstellung.
|
||||
if (!app_feature_enabled($pdo, (int)$participant['tenant_id'], 'self_entry')) {
|
||||
return ['ok' => false, 'error' => 'Die eigene Stricherfassung ist für euren Zugang nicht freigeschaltet.'];
|
||||
}
|
||||
|
||||
$marks = filter_var($marksValue, FILTER_VALIDATE_INT);
|
||||
if ($marks !== 1 && $marks !== 2) {
|
||||
return ['ok' => false, 'error' => 'Bitte einen oder zwei Striche auswählen.'];
|
||||
@@ -223,7 +230,8 @@ if ($hasAccess && $participant !== null && $settings !== null) {
|
||||
$balanceCents = (int)$participant['balance_cents'];
|
||||
$balance = dashboard_money($balanceCents);
|
||||
$currentName = dashboard_current_name($saasUser, $participant);
|
||||
$selfEntryEnabled = (int)$settings['self_entry_enabled'] === 1;
|
||||
$selfEntryEnabled = (int)$settings['self_entry_enabled'] === 1
|
||||
&& app_feature_enabled($pdo, $tenantId, 'self_entry');
|
||||
$paypalEnabled = (int)$settings['paypal_enabled'] === 1
|
||||
&& trim((string)$settings['paypal_url_template']) !== '';
|
||||
$cashEnabled = (int)$settings['cash_enabled'] === 1;
|
||||
|
||||
+12
-1
@@ -4,6 +4,7 @@ include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/saas-mail.php";
|
||||
require_once __DIR__ . "/app/audit.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
app_require_csrf();
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
@@ -16,6 +17,14 @@ if ($saasUser !== null && saas_user_has_role(['owner', 'admin', 'treasurer'], $s
|
||||
$hasAccess = true;
|
||||
}
|
||||
|
||||
// Vom Betreiber gesperrte Funktion: $hasAccess faellt zurueck, damit auch die
|
||||
// POST-Verarbeitung weiter unten nicht mehr laeuft.
|
||||
$gesperrteFunktion = null;
|
||||
if ($hasAccess && !app_feature_enabled($pdo, $tenantId, 'year_closing')) {
|
||||
$gesperrteFunktion = 'year_closing';
|
||||
$hasAccess = false;
|
||||
}
|
||||
|
||||
$jahr = (int)date('Y');
|
||||
$fehler = null;
|
||||
$ergebnisse = null;
|
||||
@@ -181,7 +190,9 @@ include "nav.php";
|
||||
<section id="banner">
|
||||
<div class="content">
|
||||
|
||||
<?php if (!$hasAccess): ?>
|
||||
<?php if ($gesperrteFunktion !== null): ?>
|
||||
<?php echo app_feature_notice_html($gesperrteFunktion); ?>
|
||||
<?php elseif (!$hasAccess): ?>
|
||||
<h2>Kein Zugriff</h2>
|
||||
<?php else: ?>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$saasUser = saas_current_user($pdo);
|
||||
@@ -35,21 +36,25 @@ if ($hasAccess) {
|
||||
<h2>Aktive Mitarbeiter mit Gesamtstand</h2>
|
||||
<br>
|
||||
<ul class="actions">
|
||||
<?php if (app_feature_enabled($pdo, $tenantId, 'pdf_export')): ?>
|
||||
<li>
|
||||
<form action="exportKaffeeliste.php" method="get">
|
||||
<button type="submit">Kaffeeliste exportieren</button>
|
||||
</form>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<li>
|
||||
<form action="letzteneintraege.php" method="get">
|
||||
<button type="submit">Letzten Einträge</button>
|
||||
</form>
|
||||
</li>
|
||||
<?php if (app_feature_enabled($pdo, $tenantId, 'csv_import')): ?>
|
||||
<li>
|
||||
<form action="csvupload.php" method="get">
|
||||
<button type="submit">CSV Upload</button>
|
||||
</form>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
|
||||
<br>
|
||||
@@ -85,9 +90,11 @@ if ($hasAccess) {
|
||||
</table>
|
||||
<br><br>
|
||||
|
||||
<?php if (app_feature_enabled($pdo, $tenantId, 'mail_dispatch')): ?>
|
||||
<form action="mailversenden.php" method="get">
|
||||
<button type="submit">Info-Mail versenden</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
} else {
|
||||
echo "<h2>Kein Zugriff</h2>";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/features.php';
|
||||
|
||||
$user = saas_require_login();
|
||||
$devVerificationToken = null;
|
||||
@@ -124,7 +125,9 @@ include 'nav.php';
|
||||
<?php if (saas_can_manage_tenant_settings($user)): ?>
|
||||
<ul class="actions">
|
||||
<li><a href="mandant-einstellungen.php" class="button">Mandant-Einstellungen</a></li>
|
||||
<?php if (app_feature_enabled(app_db_pdo(), (int)$user['tenant_id'], 'data_export')): ?>
|
||||
<li><a href="datenexport.php" class="button">Datenexport</a></li>
|
||||
<?php endif; ?>
|
||||
<?php if (saas_user_has_role('owner', $user)): ?>
|
||||
<li><a href="mandant-loeschen.php" class="button">Mandant löschen</a></li>
|
||||
<?php endif; ?>
|
||||
|
||||
+12
-1
@@ -4,6 +4,7 @@ include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/saas-mail.php";
|
||||
require_once __DIR__ . "/app/audit.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
app_require_csrf();
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
@@ -16,6 +17,14 @@ if ($saasUser !== null && saas_user_has_role(['owner', 'admin', 'treasurer'], $s
|
||||
$hasAccess = true;
|
||||
}
|
||||
|
||||
// Vom Betreiber gesperrte Funktion: $hasAccess faellt zurueck, damit auch die
|
||||
// POST-Verarbeitung weiter unten nicht mehr laeuft.
|
||||
$gesperrteFunktion = null;
|
||||
if ($hasAccess && !app_feature_enabled($pdo, $tenantId, 'mail_dispatch')) {
|
||||
$gesperrteFunktion = 'mail_dispatch';
|
||||
$hasAccess = false;
|
||||
}
|
||||
|
||||
$ergebnisse = null;
|
||||
$dryRun = true;
|
||||
$liveFlash = false;
|
||||
@@ -117,7 +126,9 @@ include "nav.php";
|
||||
<section id="banner">
|
||||
<div class="content">
|
||||
|
||||
<?php if (!$hasAccess): ?>
|
||||
<?php if ($gesperrteFunktion !== null): ?>
|
||||
<?php echo app_feature_notice_html($gesperrteFunktion); ?>
|
||||
<?php elseif (!$hasAccess): ?>
|
||||
<h2>Kein Zugriff</h2>
|
||||
<?php else: ?>
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@ require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/audit.php';
|
||||
require_once __DIR__ . '/app/billing.php';
|
||||
require_once __DIR__ . '/app/tenant-logo.php';
|
||||
require_once __DIR__ . '/app/features.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$user = saas_require_login();
|
||||
$canManageSettings = saas_can_manage_tenant_settings($user);
|
||||
$brandingEnabled = app_feature_enabled($pdo, (int)$user['tenant_id'], 'branding');
|
||||
$errors = [];
|
||||
$saved = false;
|
||||
$settings = null;
|
||||
@@ -15,12 +17,39 @@ $settings = null;
|
||||
if ($canManageSettings) {
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
app_require_csrf();
|
||||
$result = saas_update_tenant_settings($pdo, (int)$user['tenant_id'], $_POST);
|
||||
$eingaben = $_POST;
|
||||
// Ohne freigeschaltetes Design fehlt das Farbfeld im Formular. Der
|
||||
// gespeicherte Wert wird dann durchgereicht statt geleert, damit eine
|
||||
// spaetere Freischaltung die alte Farbe wiederfindet.
|
||||
if (!$brandingEnabled) {
|
||||
$bestehend = saas_fetch_tenant_settings($pdo, (int)$user['tenant_id']);
|
||||
$eingaben['brand_color'] = (string)($bestehend['brand_color'] ?? '');
|
||||
}
|
||||
$result = saas_update_tenant_settings($pdo, (int)$user['tenant_id'], $eingaben);
|
||||
if ($result['ok']) {
|
||||
$saved = true;
|
||||
$settings = $result['settings'];
|
||||
app_audit_log($pdo, (int)$user['tenant_id'], (int)$user['user_id'], 'tenant_settings.updated', 'tenant', (int)$user['tenant_id']);
|
||||
$tenantIdLogo = (int)$user['tenant_id'];
|
||||
|
||||
// Marken-Logo der App-Oberflaeche: nur wenn der Betreiber das
|
||||
// eigene Design fuer diesen Mandanten freigeschaltet hat.
|
||||
if ($brandingEnabled) {
|
||||
if (!empty($_POST['brand_logo_remove'])) {
|
||||
tenant_logo_delete((string)($settings['brand_logo'] ?? ''));
|
||||
$pdo->prepare("UPDATE tenant_settings SET brand_logo = '' WHERE tenant_id = ?")->execute([$tenantIdLogo]);
|
||||
$settings['brand_logo'] = '';
|
||||
} elseif (isset($_FILES['brand_logo_file']) && ($_FILES['brand_logo_file']['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK) {
|
||||
$brandResult = tenant_logo_store($tenantIdLogo, $_FILES['brand_logo_file'], 'brand');
|
||||
if ($brandResult['ok']) {
|
||||
$pdo->prepare('UPDATE tenant_settings SET brand_logo = ? WHERE tenant_id = ?')->execute([$brandResult['filename'], $tenantIdLogo]);
|
||||
$settings['brand_logo'] = $brandResult['filename'];
|
||||
} else {
|
||||
$errors[] = $brandResult['error'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($_POST['pdf_watermark_logo_remove'])) {
|
||||
tenant_logo_delete((string)($settings['pdf_watermark_logo'] ?? ''));
|
||||
$pdo->prepare("UPDATE tenant_settings SET pdf_watermark_logo = '' WHERE tenant_id = ?")->execute([$tenantIdLogo]);
|
||||
@@ -59,6 +88,8 @@ if ($canManageSettings) {
|
||||
'pdf_footer_text' => $_POST['pdf_footer_text'] ?? '',
|
||||
'payment_reminder_enabled' => !empty($_POST['payment_reminder_enabled']) ? 1 : 0,
|
||||
'payment_reminder_interval_days' => $_POST['payment_reminder_interval_days'] ?? 7,
|
||||
'brand_color' => $_POST['brand_color'] ?? '',
|
||||
'brand_logo' => (string)(saas_fetch_tenant_settings($pdo, (int)$user['tenant_id'])['brand_logo'] ?? ''),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
@@ -200,6 +231,31 @@ include 'nav.php';
|
||||
<label for="pdf_footer_text">Hinweis in der Fußzeile des Ausdrucks (leer = keine Fußzeile)</label>
|
||||
<input type="text" name="pdf_footer_text" id="pdf_footer_text" maxlength="500" value="<?php echo saas_html($settings['pdf_footer_text']); ?>">
|
||||
</div>
|
||||
<?php if ($brandingEnabled): ?>
|
||||
<div class="col-12">
|
||||
<h3>Eigenes Design</h3>
|
||||
<p>Logo und Akzentfarbe für die Oberfläche eurer Kaffeeliste. Leere Felder =
|
||||
Standarddesign. Das Logo hier erscheint sichtbar in der Kopfzeile jeder Seite –
|
||||
das Wasserzeichen weiter oben betrifft nur den Ausdruck.</p>
|
||||
</div>
|
||||
<div class="col-6 col-12-small">
|
||||
<label for="brand_color">Akzentfarbe</label>
|
||||
<input type="color" name="brand_color" id="brand_color"
|
||||
value="<?php echo saas_html($settings['brand_color'] !== '' ? $settings['brand_color'] : '#38761d'); ?>">
|
||||
</div>
|
||||
<div class="col-6 col-12-small">
|
||||
<label for="brand_logo_file">Logo für die Kopfzeile (PNG/JPEG)</label>
|
||||
<input type="file" name="brand_logo_file" id="brand_logo_file" accept="image/png,image/jpeg">
|
||||
<?php if (trim((string)($settings['brand_logo'] ?? '')) !== ''): ?>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="brand_logo_remove" id="brand_logo_remove" value="1">
|
||||
<label class="form-check-label" for="brand_logo_remove">Hinterlegtes Logo entfernen</label>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<small>Aktuell kein Logo hinterlegt.</small>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="col-6 col-12-small">
|
||||
<input type="checkbox" id="payment_reminder_enabled" name="payment_reminder_enabled" value="1" <?php echo (int)$settings['payment_reminder_enabled'] === 1 ? 'checked' : ''; ?>>
|
||||
<label for="payment_reminder_enabled">Automatische Zahlungserinnerung ab Warnschwelle</label>
|
||||
|
||||
+12
-1
@@ -3,6 +3,7 @@
|
||||
include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/paypal-inbox.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
app_require_csrf();
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
@@ -15,6 +16,14 @@ if ($saasUser !== null && saas_user_has_role(['owner', 'admin', 'treasurer'], $s
|
||||
$hasAccess = true;
|
||||
}
|
||||
|
||||
// Vom Betreiber gesperrte Funktion: $hasAccess faellt zurueck, damit auch die
|
||||
// POST-Verarbeitung weiter unten nicht mehr laeuft.
|
||||
$gesperrteFunktion = null;
|
||||
if ($hasAccess && !app_feature_enabled($pdo, $tenantId, 'paypal_inbox')) {
|
||||
$gesperrteFunktion = 'paypal_inbox';
|
||||
$hasAccess = false;
|
||||
}
|
||||
|
||||
$flash = null;
|
||||
if ($hasAccess && isset($_SESSION['flash_paypal'])) {
|
||||
$flash = $_SESSION['flash_paypal'];
|
||||
@@ -121,7 +130,9 @@ include "nav.php";
|
||||
<section id="banner">
|
||||
<div class="content">
|
||||
|
||||
<?php if (!$hasAccess): ?>
|
||||
<?php if ($gesperrteFunktion !== null): ?>
|
||||
<?php echo app_feature_notice_html($gesperrteFunktion); ?>
|
||||
<?php elseif (!$hasAccess): ?>
|
||||
<h2>Kein Zugriff</h2>
|
||||
<?php else: ?>
|
||||
|
||||
|
||||
+16
-1
@@ -164,7 +164,20 @@ if ($_SERVER["REQUEST_METHOD"] == "POST") {
|
||||
}
|
||||
}
|
||||
|
||||
$mitarbeiter = stricheintragen_fetch_participants($pdo, $tenantId, $action);
|
||||
// Vorder-/Rueckseite nur anbieten, wenn der Ausdruck auch wirklich zwei Seiten
|
||||
// hat - bei kleinen Teams gibt es nur ein Blatt und die Aufteilung waere eine
|
||||
// Auswahl ohne Entsprechung auf dem Papier. Muss vor dem Laden der Liste
|
||||
// stehen, damit ein alter Link auf "vorderseite" nicht doch eine halbe Liste
|
||||
// zeigt.
|
||||
$alleAktiven = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]);
|
||||
$zweiseitig = ledger_uses_two_sided_sheet(count($alleAktiven));
|
||||
if (!$zweiseitig) {
|
||||
$action = 'alle';
|
||||
}
|
||||
|
||||
$mitarbeiter = $action === 'alle'
|
||||
? $alleAktiven
|
||||
: stricheintragen_fetch_participants($pdo, $tenantId, $action);
|
||||
$settings = saas_fetch_tenant_settings($pdo, $tenantId);
|
||||
$kostenproStrichAnzeige = $kostenproStrichEingabe !== null
|
||||
? $kostenproStrichEingabe
|
||||
@@ -192,6 +205,7 @@ $kostenproStrichAnzeige = $kostenproStrichEingabe !== null
|
||||
<?php endif; ?>
|
||||
|
||||
<ul class="actions">
|
||||
<?php if ($zweiseitig): ?>
|
||||
<li>
|
||||
<form action="stricheintragen.php" method="get">
|
||||
<input type="hidden" name="action" value="vorderseite">
|
||||
@@ -204,6 +218,7 @@ $kostenproStrichAnzeige = $kostenproStrichEingabe !== null
|
||||
<button type="submit">Rückseite</button>
|
||||
</form>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<li>
|
||||
<form action="stricheintragen.php" method="get">
|
||||
<input type="hidden" name="action" value="alle">
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Liefert das Marken-Logo des eigenen Mandanten aus.
|
||||
*
|
||||
* Die Logo-Dateien liegen in var/tenant_logos und sind per .htaccess bewusst
|
||||
* nicht direkt abrufbar - sonst waere jedes Kundenlogo unter einer ratbaren
|
||||
* URL oeffentlich. Deshalb dieser Umweg: ausgeliefert wird ausschliesslich das
|
||||
* Logo des Mandanten, an dem der angemeldete Nutzer selbst haengt.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/tenant-logo.php';
|
||||
require_once __DIR__ . '/app/features.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$user = saas_current_user($pdo);
|
||||
if ($user === null) {
|
||||
http_response_code(403);
|
||||
exit('Kein Zugriff.');
|
||||
}
|
||||
|
||||
$tenantId = (int)$user['tenant_id'];
|
||||
if (!app_feature_enabled($pdo, $tenantId, 'branding')) {
|
||||
http_response_code(404);
|
||||
exit('Kein Logo hinterlegt.');
|
||||
}
|
||||
|
||||
$settings = saas_fetch_tenant_settings($pdo, $tenantId);
|
||||
$path = tenant_logo_path((string)($settings['brand_logo'] ?? ''));
|
||||
if ($path === null) {
|
||||
http_response_code(404);
|
||||
exit('Kein Logo hinterlegt.');
|
||||
}
|
||||
|
||||
$info = @getimagesize($path);
|
||||
$mime = $info !== false ? (string)$info['mime'] : 'application/octet-stream';
|
||||
if (!in_array($mime, ['image/png', 'image/jpeg'], true)) {
|
||||
http_response_code(404);
|
||||
exit('Kein Logo hinterlegt.');
|
||||
}
|
||||
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Content-Length: ' . (string)filesize($path));
|
||||
// Privat cachen: das Logo gehoert genau einem Mandanten und darf nicht in
|
||||
// einem geteilten Proxy-Cache landen.
|
||||
header('Cache-Control: private, max-age=3600');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
readfile($path);
|
||||
Reference in New Issue
Block a user