Compare commits

..
2 Commits
Author SHA1 Message Date
clemensandClaude Opus 5 d24ee9bf91 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>
2026-08-17 22:52:55 +02:00
clemens a93e067e04 Verbessere Mitgliederfilter und Zahlungszuordnung 2026-08-13 15:40:43 +02:00
34 changed files with 1773 additions and 201 deletions
+81 -133
View File
@@ -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.&nbsp;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&nbsp;€ 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&nbsp;&amp;&nbsp;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.&nbsp;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>
+105
View File
@@ -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
View File
@@ -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 (?, ?, ?, ?)');
+167
View File
@@ -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>';
}
+189
View File
@@ -56,6 +56,99 @@ function imports_find_participant(PDO $pdo, int $tenantId, string $name): ?array
];
}
function imports_normalize_match_text(string $value): string
{
$value = trim($value);
$value = preg_replace('/\s+/', ' ', $value) ?? $value;
return function_exists('mb_strtolower')
? mb_strtolower($value, 'UTF-8')
: strtolower($value);
}
/**
* Liefert wahrscheinliche Teilnehmer fuer eine manuelle Zuordnung. Die
* automatische Buchung bleibt strenger; diese Vorschlaege sind nur eine Hilfe
* fuer Admins/Kassenwarte in CSV- und PayPal-Warteschlangen.
*
* @return list<array{participant_id:int, display_name:string, email:?string, paypal_name:?string, score:int}>
*/
function imports_suggest_participants(PDO $pdo, int $tenantId, string $rawName, ?string $note = null, int $limit = 5): array
{
$haystack = imports_normalize_match_text(trim($rawName . ' ' . (string)$note));
if ($haystack === '') {
return [];
}
$tokens = array_values(array_unique(array_filter(
preg_split('/\s+/', $haystack) ?: [],
static fn(string $token): bool => strlen($token) >= 3
)));
$stmt = $pdo->prepare(
'SELECT id, display_name, email, paypal_name
FROM participants
WHERE tenant_id = ? AND active = 1
ORDER BY display_name'
);
$stmt->execute([$tenantId]);
$candidates = [];
foreach ($stmt->fetchAll() as $row) {
$displayName = (string)$row['display_name'];
$paypalName = (string)($row['paypal_name'] ?? '');
$email = (string)($row['email'] ?? '');
$displayNorm = imports_normalize_match_text($displayName);
$paypalNorm = imports_normalize_match_text($paypalName);
$emailNorm = imports_normalize_match_text($email);
$score = 0;
if ($displayNorm !== '' && $displayNorm === imports_normalize_match_text($rawName)) {
$score += 90;
}
if ($paypalNorm !== '' && $paypalNorm === imports_normalize_match_text($rawName)) {
$score += 100;
}
if ($displayNorm !== '' && str_contains($haystack, $displayNorm)) {
$score += 45;
}
if ($paypalNorm !== '' && str_contains($haystack, $paypalNorm)) {
$score += 55;
}
foreach ($tokens as $token) {
if ($displayNorm !== '' && str_contains($displayNorm, $token)) {
$score += 12;
}
if ($paypalNorm !== '' && str_contains($paypalNorm, $token)) {
$score += 16;
}
if ($emailNorm !== '' && str_contains($emailNorm, $token)) {
$score += 6;
}
}
if ($score <= 0) {
continue;
}
$candidates[] = [
'participant_id' => (int)$row['id'],
'display_name' => $displayName,
'email' => $row['email'] !== null ? (string)$row['email'] : null,
'paypal_name' => $row['paypal_name'] !== null ? (string)$row['paypal_name'] : null,
'score' => $score,
];
}
usort($candidates, static function (array $a, array $b): int {
return $b['score'] <=> $a['score']
?: strcasecmp((string)$a['display_name'], (string)$b['display_name']);
});
return array_slice($candidates, 0, max(1, $limit));
}
/**
* A payment counts as a duplicate if the same participant already has a
* non-voided ledger payment of the same amount on the same day.
@@ -188,6 +281,102 @@ function imports_fetch_batch(PDO $pdo, int $tenantId, int $batchId): ?array
];
}
/**
* @return list<array{id:int, original_filename:string, status:string, created_at:string, committed_at:?string, total_rows:int, matched_rows:int, unresolved_rows:int, imported_rows:int, ignored_rows:int}>
*/
function imports_fetch_recent_batches(PDO $pdo, int $tenantId, int $limit = 10): array
{
$stmt = $pdo->prepare(
"SELECT b.id, b.original_filename, b.status, b.created_at, b.committed_at,
COUNT(r.id) AS total_rows,
SUM(CASE WHEN r.status = 'matched' THEN 1 ELSE 0 END) AS matched_rows,
SUM(CASE WHEN r.status IN ('unmatched', 'duplicate', 'invalid') THEN 1 ELSE 0 END) AS unresolved_rows,
SUM(CASE WHEN r.status = 'imported' THEN 1 ELSE 0 END) AS imported_rows,
SUM(CASE WHEN r.status = 'ignored' THEN 1 ELSE 0 END) AS ignored_rows
FROM payment_import_batches b
LEFT JOIN payment_import_rows r ON r.batch_id = b.id
WHERE b.tenant_id = ?
GROUP BY b.id, b.original_filename, b.status, b.created_at, b.committed_at
ORDER BY b.created_at DESC, b.id DESC
LIMIT " . max(1, min(50, $limit))
);
$stmt->execute([$tenantId]);
return array_map(static function (array $row): array {
return [
'id' => (int)$row['id'],
'original_filename' => (string)$row['original_filename'],
'status' => (string)$row['status'],
'created_at' => (string)$row['created_at'],
'committed_at' => $row['committed_at'] !== null ? (string)$row['committed_at'] : null,
'total_rows' => (int)$row['total_rows'],
'matched_rows' => (int)$row['matched_rows'],
'unresolved_rows' => (int)$row['unresolved_rows'],
'imported_rows' => (int)$row['imported_rows'],
'ignored_rows' => (int)$row['ignored_rows'],
];
}, $stmt->fetchAll());
}
/**
* @return array{ok:bool, error?:string}
*/
function imports_assign_row(PDO $pdo, int $tenantId, int $batchId, int $rowId, int $participantId): array
{
$batch = imports_fetch_batch($pdo, $tenantId, $batchId);
if ($batch === null || $batch['batch']['status'] !== 'previewed') {
return ['ok' => false, 'error' => 'Dieser Import kann nicht mehr bearbeitet werden.'];
}
$summaries = ledger_fetch_participant_summaries($pdo, $tenantId, ['participant_ids' => [$participantId]]);
$participant = $summaries[0] ?? null;
if ($participant === null || empty($participant['active'])) {
return ['ok' => false, 'error' => 'Das gewählte Mitglied wurde nicht gefunden.'];
}
$stmt = $pdo->prepare(
"UPDATE payment_import_rows r
JOIN payment_import_batches b ON b.id = r.batch_id
SET r.participant_id = ?, r.status = 'matched'
WHERE r.id = ?
AND r.batch_id = ?
AND b.tenant_id = ?
AND b.status = 'previewed'
AND r.status IN ('unmatched', 'duplicate')"
);
$stmt->execute([$participantId, $rowId, $batchId, $tenantId]);
if ($stmt->rowCount() !== 1) {
return ['ok' => false, 'error' => 'Diese Zeile kann nicht zugeordnet werden.'];
}
return ['ok' => true];
}
/**
* @return array{ok:bool, error?:string}
*/
function imports_ignore_row(PDO $pdo, int $tenantId, int $batchId, int $rowId): array
{
$stmt = $pdo->prepare(
"UPDATE payment_import_rows r
JOIN payment_import_batches b ON b.id = r.batch_id
SET r.participant_id = NULL, r.status = 'ignored'
WHERE r.id = ?
AND r.batch_id = ?
AND b.tenant_id = ?
AND b.status = 'previewed'
AND r.status IN ('unmatched', 'duplicate', 'invalid')"
);
$stmt->execute([$rowId, $batchId, $tenantId]);
if ($stmt->rowCount() !== 1) {
return ['ok' => false, 'error' => 'Diese Zeile kann nicht ignoriert werden.'];
}
return ['ok' => true];
}
/**
* Commits every 'matched' row of a previewed batch straight into the ledger.
* Already-committed batches are rejected.
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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.'];
}
+19
View File
@@ -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)
*
+142
View File
@@ -2293,3 +2293,145 @@ button,
border-left: 5px solid #4caf50;
color: #1b5e20;
}
.hint-box.error {
background: #fdeaea;
border-left: 5px solid #c0392b;
color: #641e16;
}
input[type="submit"].alt,
input[type="reset"].alt,
input[type="button"].alt,
button.alt,
.button.alt {
box-shadow: inset 0 0 0 2px #9fa3a6;
color: #5f676d !important;
}
.admin-summary-grid {
display: grid;
gap: 0.75em;
grid-template-columns: repeat(auto-fit, minmax(8.5em, 1fr));
margin: 1.25em 0 1.75em;
}
.admin-summary-grid > div {
border: solid 1px rgba(210, 215, 217, 0.85);
border-radius: 6px;
padding: 0.9em 1em;
}
.admin-summary-grid strong {
color: #38761d;
display: block;
font-family: "Roboto Slab", serif;
font-size: 1.45em;
line-height: 1.1;
}
.admin-summary-grid span {
color: #7f888f;
display: block;
font-size: 0.85em;
margin-top: 0.2em;
}
.admin-filter-bar {
align-items: flex-end;
display: flex;
flex-wrap: wrap;
gap: 0.75em;
margin: 1.25em 0;
}
.admin-filter-bar label {
font-size: 0.85em;
margin: 0;
}
.admin-filter-bar input[type="search"],
.admin-filter-bar select {
min-width: 11em;
}
.admin-table td,
.admin-table th {
vertical-align: top;
}
.status-badge {
border-radius: 6px;
display: inline-block;
font-size: 0.78em;
font-weight: 700;
line-height: 1.4;
margin: 0 0.25em 0.25em 0;
padding: 0.2em 0.55em;
white-space: nowrap;
}
.status-badge.success {
background: #e8f5e9;
color: #1b5e20;
}
.status-badge.warning {
background: #fff4e5;
color: #663c00;
}
.status-badge.error {
background: #fdeaea;
color: #641e16;
}
.status-badge.info {
background: #e8f4ff;
color: #0d3c61;
}
.status-badge.muted {
background: #edf0f2;
color: #5f676d;
}
.inline-admin-form {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.5em;
margin: 0 0 0.5em 0;
}
.inline-admin-form select {
min-width: 13em;
}
.compact-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5em;
margin: 0;
padding-left: 0;
}
.compact-actions li {
padding: 0;
}
@media screen and (max-width: 736px) {
.admin-filter-bar {
align-items: stretch;
flex-direction: column;
}
.admin-filter-bar input[type="search"],
.admin-filter-bar select,
.admin-filter-bar button,
.admin-filter-bar .button,
.inline-admin-form select,
.inline-admin-form button {
width: 100%;
}
}
+55 -1
View File
@@ -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>
+199 -5
View File
@@ -1,9 +1,12 @@
<?php
ob_start();
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";
@@ -34,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';
@@ -103,22 +112,88 @@ function csv_status_label(string $status): string
'unmatched' => 'Mitglied nicht gefunden',
'invalid' => 'Ungültige Zeile',
'imported' => 'Importiert',
'ignored' => 'Ignoriert',
default => $status,
};
}
function csv_status_class(string $status): string
{
return match ($status) {
'matched', 'imported' => 'success',
'duplicate' => 'warning',
'unmatched', 'invalid' => 'error',
'ignored' => 'muted',
default => 'muted',
};
}
function csv_redirect_to_batch(?int $batchId = null): void
{
$target = 'csvupload.php';
if ($batchId !== null && $batchId > 0) {
$target .= '?batch_id=' . urlencode((string)$batchId);
}
header('Location: ' . $target);
exit;
}
function csv_select_options(array $members, array $suggestions = [], int $selectedId = 0): string
{
$suggestedIds = [];
foreach ($suggestions as $suggestion) {
$suggestedIds[(int)$suggestion['participant_id']] = true;
}
$html = '<option value="">- Mitglied wählen -</option>';
if ($suggestions !== []) {
$html .= '<optgroup label="Vorschläge">';
foreach ($suggestions as $suggestion) {
$id = (int)$suggestion['participant_id'];
$html .= '<option value="' . $id . '"' . ($selectedId === $id ? ' selected' : '') . '>'
. saas_html((string)$suggestion['display_name'])
. '</option>';
}
$html .= '</optgroup>';
}
$html .= '<optgroup label="Alle aktiven Mitglieder">';
foreach ($members as $member) {
$id = (int)$member['participant_id'];
if (isset($suggestedIds[$id])) {
continue;
}
$html .= '<option value="' . $id . '"' . ($selectedId === $id ? ' selected' : '') . '>'
. saas_html((string)$member['display_name'])
. '</option>';
}
$html .= '</optgroup>';
return $html;
}
$meldung = null;
$fehler = null;
$vorschauBatchId = null;
$vorschauBatchId = isset($_GET['batch_id']) ? (int)$_GET['batch_id'] : null;
if (isset($_SESSION['flash_csvupload']) && is_array($_SESSION['flash_csvupload'])) {
$flash = $_SESSION['flash_csvupload'];
unset($_SESSION['flash_csvupload']);
$meldung = isset($flash['meldung']) ? (string)$flash['meldung'] : null;
$fehler = isset($flash['fehler']) ? (string)$flash['fehler'] : null;
}
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$aktion = $_POST['aktion'] ?? 'hochladen';
$vorschauBatchId = (int)($_POST['batch_id'] ?? 0);
if ($aktion === 'hochladen' && isset($_FILES["csv_file"])) {
[$csvFile, $uploadError] = csv_prepare_upload($_FILES["csv_file"]);
if ($uploadError !== null) {
$fehler = $uploadError;
$_SESSION['flash_csvupload'] = ['fehler' => $fehler];
csv_redirect_to_batch();
} else {
try {
$checksum = hash_file('sha256', $csvFile);
@@ -160,6 +235,7 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
$pdo->commit();
$vorschauBatchId = $batchId;
$meldung = 'CSV-Datei wurde eingelesen. Bitte die Vorschau prüfen.';
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
@@ -168,9 +244,43 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
} finally {
@unlink($csvFile);
}
$_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler];
csv_redirect_to_batch($vorschauBatchId);
}
} elseif ($aktion === 'zeile_zuordnen') {
$rowId = (int)($_POST['row_id'] ?? 0);
$participantId = (int)($_POST['participant_id'] ?? 0);
if ($vorschauBatchId <= 0 || $rowId <= 0 || $participantId <= 0) {
$fehler = 'Bitte eine Importzeile und ein Mitglied auswählen.';
} else {
$result = imports_assign_row($pdo, $tenantId, $vorschauBatchId, $rowId, $participantId);
if ($result['ok']) {
$meldung = 'Importzeile wurde zugeordnet.';
} else {
$fehler = $result['error'] ?? 'Die Importzeile konnte nicht zugeordnet werden.';
}
}
$_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler];
csv_redirect_to_batch($vorschauBatchId);
} elseif ($aktion === 'zeile_ignorieren') {
$rowId = (int)($_POST['row_id'] ?? 0);
if ($vorschauBatchId <= 0 || $rowId <= 0) {
$fehler = 'Bitte eine Importzeile auswählen.';
} else {
$result = imports_ignore_row($pdo, $tenantId, $vorschauBatchId, $rowId);
if ($result['ok']) {
$meldung = 'Importzeile wurde ignoriert.';
} else {
$fehler = $result['error'] ?? 'Die Importzeile konnte nicht ignoriert werden.';
}
}
$_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler];
csv_redirect_to_batch($vorschauBatchId);
} elseif ($aktion === 'importieren') {
$batchId = (int)($_POST['batch_id'] ?? 0);
$batchId = $vorschauBatchId;
try {
$ergebnis = imports_commit_batch($pdo, $tenantId, $batchId);
app_audit_log($pdo, $tenantId, $saasUser['user_id'] ?? null, 'csv_import.committed', 'payment_import_batch', $batchId, ['imported' => $ergebnis['imported']]);
@@ -179,10 +289,22 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
$fehler = $e->getMessage();
$vorschauBatchId = $batchId;
}
$_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler];
csv_redirect_to_batch($vorschauBatchId);
}
}
$vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vorschauBatchId) : null;
$letzteBatches = imports_fetch_recent_batches($pdo, $tenantId, 8);
$mitglieder = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]);
$vorschauStatusZaehler = [];
if ($vorschau !== null) {
foreach ($vorschau['rows'] as $row) {
$status = (string)$row['status'];
$vorschauStatusZaehler[$status] = ($vorschauStatusZaehler[$status] ?? 0) + 1;
}
}
?>
@@ -197,9 +319,15 @@ $vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vo
<?php if ($vorschau !== null): ?>
<h3>Vorschau: <?php echo saas_html($vorschau['batch']['original_filename']); ?></h3>
<p>Status: <?php echo saas_html($vorschau['batch']['status']); ?></p>
<p>Status: <?php echo saas_html($vorschau['batch']['status']); ?> ·
<?php echo (int)($vorschauStatusZaehler['matched'] ?? 0); ?> bereit ·
<?php echo (int)($vorschauStatusZaehler['unmatched'] ?? 0); ?> ohne Zuordnung ·
<?php echo (int)($vorschauStatusZaehler['duplicate'] ?? 0); ?> Duplikatverdacht ·
<?php echo (int)($vorschauStatusZaehler['invalid'] ?? 0); ?> ungültig
</p>
<table>
<div class="table-wrapper">
<table class="admin-table">
<tr>
<th>Zeile</th>
<th>Name (CSV)</th>
@@ -207,18 +335,58 @@ $vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vo
<th>Betrag</th>
<th>Datum</th>
<th>Status</th>
<th>Aktion</th>
</tr>
<?php foreach ($vorschau['rows'] as $row): ?>
<?php
$rowStatus = (string)$row['status'];
$suggestions = in_array($rowStatus, ['unmatched', 'duplicate'], true)
? imports_suggest_participants($pdo, $tenantId, (string)$row['raw_name'], null, 4)
: [];
$selectedId = count($suggestions) === 1 ? (int)$suggestions[0]['participant_id'] : 0;
?>
<tr>
<td><?php echo (int)$row['row_num']; ?></td>
<td><?php echo saas_html($row['raw_name']); ?></td>
<td><?php echo saas_html($row['display_name'] ?? '—'); ?></td>
<td><?php echo saas_html(number_format(((int)$row['amount_cents']) / 100, 2, ',', '.')); ?> €</td>
<td><?php echo saas_html($row['booked_at']); ?></td>
<td><?php echo saas_html(csv_status_label($row['status'])); ?></td>
<td><span class="status-badge <?php echo saas_html(csv_status_class($rowStatus)); ?>"><?php echo saas_html(csv_status_label($rowStatus)); ?></span></td>
<td>
<?php if ($vorschau['batch']['status'] === 'previewed' && in_array($rowStatus, ['unmatched', 'duplicate'], true)): ?>
<form method="post" action="csvupload.php" class="inline-admin-form"<?php echo $rowStatus === 'duplicate' ? ' onsubmit="return confirm(\'Diese Zeile wurde als Duplikat erkannt. Trotzdem als neue Einzahlung buchen?\');"' : ''; ?>>
<?php echo app_csrf_field(); ?>
<input type="hidden" name="aktion" value="zeile_zuordnen">
<input type="hidden" name="batch_id" value="<?php echo (int)$vorschau['batch']['id']; ?>">
<input type="hidden" name="row_id" value="<?php echo (int)$row['id']; ?>">
<select name="participant_id" required>
<?php echo csv_select_options($mitglieder, $suggestions, $selectedId); ?>
</select>
<button type="submit">Zuordnen</button>
</form>
<form method="post" action="csvupload.php" class="inline-admin-form" onsubmit="return confirm('Diese Importzeile ignorieren?');">
<?php echo app_csrf_field(); ?>
<input type="hidden" name="aktion" value="zeile_ignorieren">
<input type="hidden" name="batch_id" value="<?php echo (int)$vorschau['batch']['id']; ?>">
<input type="hidden" name="row_id" value="<?php echo (int)$row['id']; ?>">
<button type="submit" class="alt">Ignorieren</button>
</form>
<?php elseif ($vorschau['batch']['status'] === 'previewed' && $rowStatus === 'invalid'): ?>
<form method="post" action="csvupload.php" class="inline-admin-form" onsubmit="return confirm('Diese ungültige Importzeile ignorieren?');">
<?php echo app_csrf_field(); ?>
<input type="hidden" name="aktion" value="zeile_ignorieren">
<input type="hidden" name="batch_id" value="<?php echo (int)$vorschau['batch']['id']; ?>">
<input type="hidden" name="row_id" value="<?php echo (int)$row['id']; ?>">
<button type="submit" class="alt">Ignorieren</button>
</form>
<?php else: ?>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</table>
</div>
<?php if ($vorschau['batch']['status'] === 'previewed'): ?>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
@@ -231,6 +399,32 @@ $vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vo
<br>
<?php endif; ?>
<?php if ($letzteBatches !== []): ?>
<h3>Letzte Importvorschauen</h3>
<div class="table-wrapper">
<table class="admin-table">
<tr>
<th>Datei</th>
<th>Status</th>
<th>Zeilen</th>
<th>Bereit</th>
<th>Offen</th>
<th></th>
</tr>
<?php foreach ($letzteBatches as $batch): ?>
<tr>
<td><?php echo saas_html($batch['original_filename']); ?><br><small><?php echo saas_html($batch['created_at']); ?></small></td>
<td><?php echo saas_html($batch['status']); ?></td>
<td><?php echo (int)$batch['total_rows']; ?></td>
<td><?php echo (int)$batch['matched_rows'] + (int)$batch['imported_rows']; ?></td>
<td><?php echo (int)$batch['unresolved_rows']; ?></td>
<td><a class="button small" href="csvupload.php?batch_id=<?php echo (int)$batch['id']; ?>">Öffnen</a></td>
</tr>
<?php endforeach; ?>
</table>
</div>
<?php endif; ?>
<form action="csvupload.php" method="post" enctype="multipart/form-data">
<?php echo app_csrf_field(); ?>
<input type="hidden" name="aktion" value="hochladen">
@@ -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;
+6
View File
@@ -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();
+7 -2
View File
@@ -29,8 +29,13 @@ dort ist ab zwei Mitgliedschaften auch „Mandant wechseln" ergänzt.
- ✅ Adaptives Layout nach Mitgliederanzahl (Zeilenhöhe 1640px 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>';
}
+9 -1
View File
@@ -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
View File
@@ -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: ?>
+7
View File
@@ -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>";
+4 -1
View File
@@ -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>
<li><a href="datenexport.php" class="button">Datenexport</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
View File
@@ -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: ?>
+57 -1
View File
@@ -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>
+211 -12
View File
@@ -33,6 +33,66 @@ if ($saasUser !== null && saas_user_has_role(['owner', 'admin'], $saasUser)) {
$hasAccess = true;
}
function mitglieder_parse_filters(array $source): array
{
$status = (string)($source['status'] ?? 'alle');
if (!in_array($status, ['alle', 'aktiv', 'inaktiv'], true)) {
$status = 'alle';
}
$zugang = (string)($source['zugang'] ?? 'alle');
if (!in_array($zugang, ['alle', 'mit_zugang', 'ohne_zugang', 'entzogen'], true)) {
$zugang = 'alle';
}
$paypal = (string)($source['paypal'] ?? 'alle');
if (!in_array($paypal, ['alle', 'hinterlegt', 'fehlt', 'doppelt'], true)) {
$paypal = 'alle';
}
return [
'q' => trim((string)($source['q'] ?? '')),
'status' => $status,
'zugang' => $zugang,
'paypal' => $paypal,
];
}
function mitglieder_build_filter_query(array $filters): string
{
$params = [];
foreach (['q', 'status', 'zugang', 'paypal'] as $key) {
$value = (string)($filters[$key] ?? '');
if ($value !== '' && !in_array($value, ['alle'], true)) {
$params[$key] = $value;
}
}
return http_build_query($params);
}
function mitglieder_filter_fields(array $filters): string
{
return '<input type="hidden" name="filter_q" value="' . saas_html((string)$filters['q']) . '">'
. '<input type="hidden" name="filter_status" value="' . saas_html((string)$filters['status']) . '">'
. '<input type="hidden" name="filter_zugang" value="' . saas_html((string)$filters['zugang']) . '">'
. '<input type="hidden" name="filter_paypal" value="' . saas_html((string)$filters['paypal']) . '">';
}
function mitglieder_status_badge(bool $active): string
{
return '<span class="status-badge ' . ($active ? 'success' : 'muted') . '">' . ($active ? 'Aktiv' : 'Inaktiv') . '</span>';
}
function mitglieder_access_label(array $mitglied, array $rollenLabels): string
{
if ($mitglied['membership_status'] === 'active') {
return $rollenLabels[$mitglied['role']] ?? (string)$mitglied['role'];
}
return $mitglied['membership_status'] === 'revoked' ? 'Zugang entzogen' : 'Kein Zugang';
}
if($hasAccess){
$meldung = null;
@@ -40,6 +100,17 @@ if($hasAccess){
$einladungslink = null;
$bearbeitenId = null;
$actorUserId = $saasUser['user_id'] ?? null;
$filterSource = $_SERVER['REQUEST_METHOD'] === 'POST'
? [
'q' => $_POST['filter_q'] ?? '',
'status' => $_POST['filter_status'] ?? 'alle',
'zugang' => $_POST['filter_zugang'] ?? 'alle',
'paypal' => $_POST['filter_paypal'] ?? 'alle',
]
: $_GET;
$filter = mitglieder_parse_filters($filterSource);
$filterQuery = mitglieder_build_filter_query($filter);
$filterSuffix = $filterQuery !== '' ? '?' . $filterQuery : '';
if (isset($_SESSION['flash_mitglieder']) && is_array($_SESSION['flash_mitglieder'])) {
$flash = $_SESSION['flash_mitglieder'];
@@ -224,7 +295,7 @@ if($hasAccess){
'fehler' => $fehler,
'einladungslink' => $einladungslink,
];
header('Location: mitarbeiterverwalten.php');
header('Location: mitarbeiterverwalten.php' . $filterSuffix);
exit;
}
}
@@ -235,6 +306,77 @@ if($hasAccess){
$billingPlan = billing_plans()[$billingInfo['plan_code']] ?? null;
$aktiveMitgliederAnzahl = billing_count_active_participants($pdo, $tenantId);
$rollenLabels = ['owner' => 'Inhaber', 'admin' => 'Administrator', 'treasurer' => 'Kassenwart', 'member' => 'Mitglied', 'viewer' => 'Betrachter'];
$paypalNameCounts = [];
foreach ($mitglieder as $mitglied) {
$paypalNorm = imports_normalize_match_text((string)($mitglied['paypal_name'] ?? ''));
if ($paypalNorm !== '') {
$paypalNameCounts[$paypalNorm] = ($paypalNameCounts[$paypalNorm] ?? 0) + 1;
}
}
$mitgliederStats = [
'gesamt' => count($mitglieder),
'aktiv' => 0,
'inaktiv' => 0,
'mit_zugang' => 0,
'ohne_zugang' => 0,
'paypal_fehlt' => 0,
'paypal_doppelt' => 0,
];
$gefilterteMitglieder = [];
foreach ($mitglieder as $mitglied) {
$isActive = (bool)$mitglied['active'];
$hasAccessAccount = $mitglied['membership_status'] === 'active';
$paypalName = trim((string)($mitglied['paypal_name'] ?? ''));
$paypalNorm = imports_normalize_match_text($paypalName);
$paypalDuplicate = $paypalNorm !== '' && ($paypalNameCounts[$paypalNorm] ?? 0) > 1;
$mitgliederStats[$isActive ? 'aktiv' : 'inaktiv']++;
$mitgliederStats[$hasAccessAccount ? 'mit_zugang' : 'ohne_zugang']++;
if ($paypalName === '') {
$mitgliederStats['paypal_fehlt']++;
}
if ($paypalDuplicate) {
$mitgliederStats['paypal_doppelt']++;
}
$matches = true;
$q = imports_normalize_match_text((string)$filter['q']);
if ($q !== '') {
$searchText = imports_normalize_match_text(
(string)$mitglied['display_name'] . ' ' . (string)($mitglied['email'] ?? '') . ' ' . $paypalName
);
$matches = str_contains($searchText, $q);
}
if ($matches && $filter['status'] === 'aktiv') {
$matches = $isActive;
}
if ($matches && $filter['status'] === 'inaktiv') {
$matches = !$isActive;
}
if ($matches && $filter['zugang'] === 'mit_zugang') {
$matches = $hasAccessAccount;
}
if ($matches && $filter['zugang'] === 'ohne_zugang') {
$matches = $mitglied['membership_status'] === null || $mitglied['membership_status'] === 'pending';
}
if ($matches && $filter['zugang'] === 'entzogen') {
$matches = $mitglied['membership_status'] === 'revoked';
}
if ($matches && $filter['paypal'] === 'hinterlegt') {
$matches = $paypalName !== '';
}
if ($matches && $filter['paypal'] === 'fehlt') {
$matches = $paypalName === '';
}
if ($matches && $filter['paypal'] === 'doppelt') {
$matches = $paypalDuplicate;
}
if ($matches) {
$mitglied['paypal_duplicate'] = $paypalDuplicate;
$gefilterteMitglieder[] = $mitglied;
}
}
$bearbeitenMitglied = null;
if ($bearbeitenId !== null) {
foreach ($mitglieder as $m) {
@@ -263,12 +405,21 @@ if($hasAccess){
Limit erreicht für weitere aktive Mitglieder ist ein <a href="mandant-einstellungen.php">Upgrade</a> nötig.
<?php endif; ?></p>
<div class="admin-summary-grid">
<div><strong><?php echo (int)$mitgliederStats['gesamt']; ?></strong><span>Mitglieder</span></div>
<div><strong><?php echo (int)$mitgliederStats['aktiv']; ?></strong><span>aktiv</span></div>
<div><strong><?php echo (int)$mitgliederStats['mit_zugang']; ?></strong><span>mit Zugang</span></div>
<div><strong><?php echo (int)$mitgliederStats['paypal_fehlt']; ?></strong><span>ohne PayPal-Name</span></div>
<div><strong><?php echo (int)$mitgliederStats['paypal_doppelt']; ?></strong><span>PayPal-Konflikte</span></div>
</div>
<?php if ($bearbeitenMitglied !== null): ?>
<h3>Bearbeiten von <?php echo saas_html($bearbeitenMitglied['display_name']); ?></h3>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
<input type="hidden" name="aktion" value="bearbeitenspeichern">
<input type="hidden" name="mitgliedID" value="<?php echo $bearbeitenMitglied['participant_id']; ?>">
<?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<label for="name">Name:</label>
<input type="text" name="name" id="name" value="<?php echo saas_html($bearbeitenMitglied['display_name']); ?>" required>
@@ -292,6 +443,7 @@ if($hasAccess){
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
<input type="hidden" name="aktion" id="aktion" value="anlegen">
<?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<label for="new-name">Name:</label>
<input type="text" name="name" id="new-name" required>
@@ -316,6 +468,7 @@ if($hasAccess){
<p><a class="button" href="mitglieder-vorlage.php">Vorlage herunterladen</a></p>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" enctype="multipart/form-data">
<?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<input type="hidden" name="aktion" value="csv_import">
<label for="mitglieder_csv">CSV-Datei mit Mitgliedern:</label>
<input type="file" name="mitglieder_csv" id="mitglieder_csv" accept=".csv" required>
@@ -326,8 +479,38 @@ if($hasAccess){
<p>Name und E-Mail dienen der Kaffeeliste und Benachrichtigungen. Ein Login-Zugang
ist davon unabhängig und wird separat je Mitglied gewährt oder entzogen.</p>
<form method="get" action="mitarbeiterverwalten.php" class="admin-filter-bar">
<label for="mitglieder-q">Suche</label>
<input type="search" name="q" id="mitglieder-q" value="<?php echo saas_html((string)$filter['q']); ?>" placeholder="Name, E-Mail oder PayPal">
<label for="mitglieder-status">Status</label>
<select name="status" id="mitglieder-status">
<option value="alle" <?php echo $filter['status'] === 'alle' ? 'selected' : ''; ?>>Alle</option>
<option value="aktiv" <?php echo $filter['status'] === 'aktiv' ? 'selected' : ''; ?>>Aktiv</option>
<option value="inaktiv" <?php echo $filter['status'] === 'inaktiv' ? 'selected' : ''; ?>>Inaktiv</option>
</select>
<label for="mitglieder-zugang">Zugang</label>
<select name="zugang" id="mitglieder-zugang">
<option value="alle" <?php echo $filter['zugang'] === 'alle' ? 'selected' : ''; ?>>Alle</option>
<option value="mit_zugang" <?php echo $filter['zugang'] === 'mit_zugang' ? 'selected' : ''; ?>>Mit Zugang</option>
<option value="ohne_zugang" <?php echo $filter['zugang'] === 'ohne_zugang' ? 'selected' : ''; ?>>Ohne Zugang</option>
<option value="entzogen" <?php echo $filter['zugang'] === 'entzogen' ? 'selected' : ''; ?>>Entzogen</option>
</select>
<label for="mitglieder-paypal">PayPal</label>
<select name="paypal" id="mitglieder-paypal">
<option value="alle" <?php echo $filter['paypal'] === 'alle' ? 'selected' : ''; ?>>Alle</option>
<option value="hinterlegt" <?php echo $filter['paypal'] === 'hinterlegt' ? 'selected' : ''; ?>>Hinterlegt</option>
<option value="fehlt" <?php echo $filter['paypal'] === 'fehlt' ? 'selected' : ''; ?>>Fehlt</option>
<option value="doppelt" <?php echo $filter['paypal'] === 'doppelt' ? 'selected' : ''; ?>>Konflikte</option>
</select>
<button type="submit">Filtern</button>
<a class="button alt" href="mitarbeiterverwalten.php">Zurücksetzen</a>
</form>
<p><?php echo count($gefilterteMitglieder); ?> von <?php echo count($mitglieder); ?> Mitgliedern angezeigt.</p>
<!-- Tabelle zur Anzeige und Bearbeitung von Mitgliedern -->
<table class="table table-striped">
<div class="table-wrapper">
<table class="table table-striped admin-table">
<tr>
<th>Name</th>
<th>E-Mail</th>
@@ -336,22 +519,28 @@ if($hasAccess){
<th>Zugang</th>
<th>Aktionen</th>
</tr>
<?php foreach ($mitglieder as $mitglied): ?>
<?php foreach ($gefilterteMitglieder as $mitglied): ?>
<tr>
<td><?php echo saas_html($mitglied['display_name']); ?></td>
<td><?php echo saas_html($mitglied['email'] ?? ''); ?></td>
<td><?php echo saas_html($mitglied['paypal_name'] ?? ''); ?></td>
<td><?php echo $mitglied['active'] ? '1' : '0'; ?></td>
<td>
<?php echo saas_html($mitglied['paypal_name'] ?? ''); ?>
<?php if (!empty($mitglied['paypal_duplicate'])): ?>
<br><span class="status-badge warning">Konflikt</span>
<?php endif; ?>
</td>
<td><?php echo mitglieder_status_badge((bool)$mitglied['active']); ?></td>
<td>
<?php if ($mitglied['membership_status'] === 'active'): ?>
<?php if ($mitglied['role'] === 'owner'): ?>
Inhaber
<span class="status-badge info">Inhaber</span>
<?php else: ?>
<?php echo saas_html($rollenLabels[$mitglied['role']] ?? $mitglied['role']); ?>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" style="display:inline">
<span class="status-badge success"><?php echo saas_html($rollenLabels[$mitglied['role']] ?? $mitglied['role']); ?></span>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" class="inline-admin-form">
<input type="hidden" name="aktion" value="zugang_gewaehren">
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
<?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<select name="rolle">
<?php foreach (saas_grantable_roles() as $rolle): ?>
<option value="<?php echo saas_html($rolle); ?>" <?php echo $rolle === $mitglied['role'] ? 'selected' : ''; ?>><?php echo saas_html($rollenLabels[$rolle] ?? $rolle); ?></option>
@@ -359,20 +548,22 @@ if($hasAccess){
</select>
<button type="submit">Rolle ändern</button>
</form>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" style="display:inline">
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" class="inline-admin-form">
<input type="hidden" name="aktion" value="zugang_entziehen">
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
<?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<button type="submit">Zugang entziehen</button>
</form>
<?php endif; ?>
<?php else: ?>
<?php echo $mitglied['membership_status'] === 'revoked' ? 'Zugang entzogen' : 'Kein Zugang'; ?>
<span class="status-badge <?php echo $mitglied['membership_status'] === 'revoked' ? 'warning' : 'muted'; ?>"><?php echo saas_html(mitglieder_access_label($mitglied, $rollenLabels)); ?></span>
<?php if ($mitglied['email'] !== null && $mitglied['email'] !== ''): ?>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" style="display:inline">
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" class="inline-admin-form">
<input type="hidden" name="aktion" value="zugang_gewaehren">
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
<?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<select name="rolle">
<?php foreach (saas_grantable_roles() as $rolle): ?>
<option value="<?php echo saas_html($rolle); ?>"><?php echo saas_html($rollenLabels[$rolle] ?? $rolle); ?></option>
@@ -384,12 +575,13 @@ if($hasAccess){
<?php endif; ?>
</td>
<td>
<ul class="actions">
<ul class="actions compact-actions">
<li>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
<input type="hidden" name="aktion" value="bearbeiten">
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
<?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<button type="submit">Bearbeiten</button>
</form>
</li>
@@ -399,6 +591,7 @@ if($hasAccess){
<input type="hidden" name="aktion" value="deaktivieren">
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
<?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<button type="submit">Deaktivieren</button>
</form>
<?php else: ?>
@@ -406,6 +599,7 @@ if($hasAccess){
<input type="hidden" name="aktion" value="aktivieren">
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
<?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<button type="submit">Aktivieren</button>
</form>
<?php endif; ?>
@@ -416,6 +610,7 @@ if($hasAccess){
<input type="hidden" name="aktion" value="anonymisieren">
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
<?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<button type="submit">Anonymisieren</button>
</form>
</li>
@@ -424,7 +619,11 @@ if($hasAccess){
</td>
</tr>
<?php endforeach; ?>
<?php if ($gefilterteMitglieder === []): ?>
<tr><td colspan="6">Keine Mitglieder für diese Filter gefunden.</td></tr>
<?php endif; ?>
</table>
</div>
<?php
}else{
+106 -13
View File
@@ -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,12 +16,73 @@ 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'];
unset($_SESSION['flash_paypal']);
}
$suchbegriff = trim((string)($_SERVER['REQUEST_METHOD'] === 'POST' ? ($_POST['return_q'] ?? '') : ($_GET['q'] ?? '')));
$returnQuery = $suchbegriff !== '' ? '?q=' . urlencode($suchbegriff) : '';
function paypal_select_options(array $members, array $suggestions = [], int $selectedId = 0): string
{
$suggestedIds = [];
foreach ($suggestions as $suggestion) {
$suggestedIds[(int)$suggestion['participant_id']] = true;
}
$html = '<option value="">- Mitglied wählen -</option>';
if ($suggestions !== []) {
$html .= '<optgroup label="Vorschläge">';
foreach ($suggestions as $suggestion) {
$id = (int)$suggestion['participant_id'];
$html .= '<option value="' . $id . '"' . ($selectedId === $id ? ' selected' : '') . '>'
. saas_html((string)$suggestion['display_name'])
. '</option>';
}
$html .= '</optgroup>';
}
$html .= '<optgroup label="Alle aktiven Mitglieder">';
foreach ($members as $member) {
$id = (int)$member['participant_id'];
if (isset($suggestedIds[$id])) {
continue;
}
$html .= '<option value="' . $id . '"' . ($selectedId === $id ? ' selected' : '') . '>'
. saas_html((string)$member['display_name'])
. '</option>';
}
$html .= '</optgroup>';
return $html;
}
function paypal_payment_matches_query(array $payment, string $query): bool
{
$query = imports_normalize_match_text($query);
if ($query === '') {
return true;
}
$text = imports_normalize_match_text(
(string)$payment['payer_name'] . ' '
. (string)($payment['note'] ?? '') . ' '
. (string)($payment['transaction_code'] ?? '')
);
return str_contains($text, $query);
}
if ($hasAccess && $_SERVER['REQUEST_METHOD'] === 'POST') {
$aktion = (string)($_POST['aktion'] ?? '');
$paymentId = (int)($_POST['payment_id'] ?? 0);
@@ -45,7 +107,7 @@ if ($hasAccess && $_SERVER['REQUEST_METHOD'] === 'POST') {
// Post-Redirect-Get gegen Doppelbuchung per Refresh.
$_SESSION['flash_paypal'] = $flash;
header('Location: paypal-zuordnung.php');
header('Location: paypal-zuordnung.php' . $returnQuery);
exit;
}
@@ -58,6 +120,7 @@ if ($hasAccess) {
$offene = paypal_fetch_unmatched($pdo, $tenantId);
$mitglieder = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]);
}
$offeneGefiltert = array_values(array_filter($offene, static fn(array $payment): bool => paypal_payment_matches_query($payment, $suchbegriff)));
include "header.php";
include "headerline.php";
@@ -67,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: ?>
@@ -88,27 +153,53 @@ include "nav.php";
<?php endif; ?>
<h3>Offene Zahlungen (<?php echo count($offene); ?>)</h3>
<form method="get" action="paypal-zuordnung.php" class="admin-filter-bar">
<label for="paypal-q">Suche</label>
<input type="search" name="q" id="paypal-q" value="<?php echo saas_html($suchbegriff); ?>" placeholder="Zahler, Mitteilung oder Transaktion">
<button type="submit">Filtern</button>
<a class="button alt" href="paypal-zuordnung.php">Zurücksetzen</a>
</form>
<?php if ($offene === []): ?>
<p>Keine offenen Zahlungen. Alles automatisch zugeordnet. 🎉</p>
<p>Keine offenen Zahlungen. Alles automatisch zugeordnet.</p>
<?php elseif ($offeneGefiltert === []): ?>
<p>Keine offenen Zahlungen für diesen Filter gefunden.</p>
<?php else: ?>
<table>
<tr><th>Eingang</th><th>Zahler (PayPal)</th><th>Mitteilung</th><th>Betrag</th><th>Zuordnen</th><th></th></tr>
<?php foreach ($offene as $z): ?>
<p><?php echo count($offeneGefiltert); ?> von <?php echo count($offene); ?> offenen Zahlungen angezeigt.</p>
<div class="table-wrapper">
<table class="admin-table">
<tr><th>Eingang</th><th>Zahlung</th><th>Betrag</th><th>Vorschläge</th><th>Zuordnen</th><th></th></tr>
<?php foreach ($offeneGefiltert as $z): ?>
<?php
$suggestions = imports_suggest_participants($pdo, $tenantId, (string)$z['payer_name'], (string)($z['note'] ?? ''), 4);
$selectedId = count($suggestions) === 1 ? (int)$suggestions[0]['participant_id'] : 0;
?>
<tr>
<td><?php echo saas_html((string)($z['paid_at'] ?? $z['created_at'])); ?></td>
<td><?php echo saas_html($z['payer_name']); ?></td>
<td><?php echo saas_html((string)($z['note'] ?? '')); ?></td>
<td>
<strong><?php echo saas_html($z['payer_name']); ?></strong>
<?php if (($z['note'] ?? '') !== null && trim((string)$z['note']) !== ''): ?>
<br><small><?php echo saas_html((string)$z['note']); ?></small>
<?php endif; ?>
<br><small>Transaktion: <?php echo saas_html((string)$z['transaction_code']); ?></small>
</td>
<td><?php echo saas_html(saas_format_money_cents((int)$z['net_cents'])); ?> €</td>
<td>
<form method="post" action="paypal-zuordnung.php" style="display:flex;gap:0.5em;align-items:center;margin:0">
<?php if ($suggestions === []): ?>
<span class="status-badge muted">Kein Vorschlag</span>
<?php else: ?>
<?php foreach ($suggestions as $suggestion): ?>
<span class="status-badge info"><?php echo saas_html((string)$suggestion['display_name']); ?></span>
<?php endforeach; ?>
<?php endif; ?>
</td>
<td>
<form method="post" action="paypal-zuordnung.php" class="inline-admin-form">
<?php echo app_csrf_field(); ?>
<input type="hidden" name="aktion" value="zuordnen">
<input type="hidden" name="payment_id" value="<?php echo (int)$z['id']; ?>">
<input type="hidden" name="return_q" value="<?php echo saas_html($suchbegriff); ?>">
<select name="participant_id" required>
<option value=""> Mitglied wählen </option>
<?php foreach ($mitglieder as $m): ?>
<option value="<?php echo (int)$m['participant_id']; ?>"><?php echo saas_html($m['display_name']); ?></option>
<?php endforeach; ?>
<?php echo paypal_select_options($mitglieder, $suggestions, $selectedId); ?>
</select>
<button type="submit">Buchen</button>
</form>
@@ -118,12 +209,14 @@ include "nav.php";
<?php echo app_csrf_field(); ?>
<input type="hidden" name="aktion" value="ignorieren">
<input type="hidden" name="payment_id" value="<?php echo (int)$z['id']; ?>">
<input type="hidden" name="return_q" value="<?php echo saas_html($suchbegriff); ?>">
<button type="submit" class="alt">Ignorieren</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</table>
</div>
<?php endif; ?>
<?php endif; ?>
+64
View File
@@ -43,6 +43,12 @@ function payment_matching_insert_participant(PDO $pdo, int $tenantId, string $di
function payment_matching_cleanup(PDO $pdo, array $tenantIds): void
{
foreach ($tenantIds as $tenantId) {
$pdo->prepare(
'DELETE r FROM payment_import_rows r
JOIN payment_import_batches b ON b.id = r.batch_id
WHERE b.tenant_id = ?'
)->execute([$tenantId]);
$pdo->prepare('DELETE FROM payment_import_batches WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM participants WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]);
}
@@ -139,6 +145,64 @@ try {
$failures,
$passes
);
$suggestions = imports_suggest_participants($pdo, $tenantId, "paypal unique {$suffix}", null, 3);
payment_matching_assert(
'Vorschlaege enthalten den passenden Teilnehmer',
$suggestions !== [] && $suggestions[0]['participant_id'] === $uniqueId,
$failures,
$passes
);
$batchId = imports_create_batch($pdo, $tenantId, 'matching-test.csv', hash('sha256', $suffix), null);
$unmatchedRowId = imports_store_row(
$pdo,
$batchId,
1,
null,
"PayPal Unique {$suffix}",
500,
date('Y-m-d H:i:s'),
'unmatched',
['row' => 1]
);
$invalidRowId = imports_store_row(
$pdo,
$batchId,
2,
null,
"Kaputt {$suffix}",
0,
date('Y-m-d H:i:s'),
'invalid',
['row' => 2]
);
$assignResult = imports_assign_row($pdo, $tenantId, $batchId, $unmatchedRowId, $uniqueId);
$stmt = $pdo->prepare('SELECT participant_id, status FROM payment_import_rows WHERE id = ?');
$stmt->execute([$unmatchedRowId]);
$assignedRow = $stmt->fetch();
payment_matching_assert(
'unmatched CSV-Zeile kann manuell zugeordnet werden',
$assignResult['ok'] === true
&& (int)$assignedRow['participant_id'] === $uniqueId
&& (string)$assignedRow['status'] === 'matched',
$failures,
$passes
);
$ignoreResult = imports_ignore_row($pdo, $tenantId, $batchId, $invalidRowId);
$stmt = $pdo->prepare('SELECT participant_id, status FROM payment_import_rows WHERE id = ?');
$stmt->execute([$invalidRowId]);
$ignoredRow = $stmt->fetch();
payment_matching_assert(
'ungueltige CSV-Zeile kann ignoriert werden',
$ignoreResult['ok'] === true
&& $ignoredRow['participant_id'] === null
&& (string)$ignoredRow['status'] === 'ignored',
$failures,
$passes
);
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
+16 -1
View File
@@ -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">
+51
View File
@@ -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);