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"; include "functions.php";
require_once __DIR__ . "/app/ledger.php"; require_once __DIR__ . "/app/ledger.php";
require_once __DIR__ . "/app/features.php";
$pdo = app_db_pdo(); $pdo = app_db_pdo();
$saasUser = saas_current_user($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. // Einstellungen fuer die Hinweise zu den aktivierten Zahlungswegen.
$settings = $saasUser !== null ? saas_fetch_tenant_settings($pdo, (int)$saasUser['tenant_id']) : null; $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 "header.php";
include "headerline.php"; include "headerline.php";
include "nav.php"; include "nav.php";
@@ -25,8 +33,8 @@ include "nav.php";
<div class="content"> <div class="content">
<h2>Anleitung</h2> <h2>Anleitung</h2>
<p>Diese Seite erklärt die wichtigsten Funktionen der Kaffeeliste. Oben stehen <p>Kurzüberblick über die Funktionen. Oben steht, was jedes Mitglied betrifft,
die Punkte, die jedes Mitglied betreffen, darunter die Verwaltungsfunktionen.</p> darunter die Verwaltung.</p>
<?php if ($saasUser === null): ?> <?php if ($saasUser === null): ?>
<div class="hint-box"> <div class="hint-box">
@@ -40,34 +48,20 @@ include "nav.php";
<h2>Für alle Mitglieder</h2> <h2>Für alle Mitglieder</h2>
<h3>Meine Kaffeeliste</h3> <h3>Meine Kaffeeliste</h3>
<p>Das ist deine Startseite. Sie zeigt deinen <b>aktuellen Stand</b>, deine <p>Deine Startseite zeigt den aktuellen Stand, die Jahres- und Monatsübersicht sowie
Jahresübersicht sowie die letzten Einzahlungen und Striche.</p> die letzten Buchungen. Positiver Betrag = Guthaben, negativer Betrag = offener Betrag.</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>
<?php if ($kann('self_entry')): ?>
<h3>Striche selbst eintragen</h3> <h3>Striche selbst eintragen</h3>
<p>Wenn die Verwaltung das freigeschaltet hat, kannst du deinen Kaffee direkt auf <p>Wenn die Verwaltung das freigeschaltet hat, trägst du deinen Kaffee direkt auf der
der Startseite eintragen ein oder zwei Striche pro Vorgang.</p> Startseite ein. Vertippt? Mit „Letzten Strich stornieren" nimmst du deinen jüngsten
<div class="hint-box"> <b>selbst</b> eingetragenen Strich zurück vom Kassenwart erfasste Striche nicht.</p>
<p><b>Vertippt?</b> Direkt darunter kannst du deinen <b>zuletzt selbst <?php endif; ?>
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>
<h3>Bezahlen</h3> <h3>Bezahlen</h3>
<p>Welche Wege zur Verfügung stehen, legt die Verwaltung fest. Möglich sind:</p> <p>Welche Wege offen stehen, legt die Verwaltung fest: Barzahlung, PayPal oder Überweisung.</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>
<?php if ($settings !== null): ?> <?php if ($settings !== null): ?>
<p><small>In eurem Mandanten sind aktuell aktiv: <p><small>Bei euch aktiv:
<?php <?php
$wege = []; $wege = [];
if ((int)$settings['cash_enabled'] === 1) { $wege[] = 'Barzahlung'; } if ((int)$settings['cash_enabled'] === 1) { $wege[] = 'Barzahlung'; }
@@ -77,148 +71,103 @@ include "nav.php";
?>.</small></p> ?>.</small></p>
<?php endif; ?> <?php endif; ?>
<div class="hint-box"> <div class="hint-box">
<p><b>Wichtig bei PayPal:</b> Nutzt die Funktion „Geld an Freunde senden“. Bei <p><b>Wichtig bei PayPal:</b> „Geld an Freunde senden" nutzen. Bei „Waren und
Zahlungen für „Waren und Dienstleistungen zieht PayPal Gebühren ab gutgeschrieben Dienstleistungen" zieht PayPal Gebühren ab gutgeschrieben wird nur, was ankommt.</p>
wird dann nur der Betrag, der tatsächlich angekommen ist.</p>
</div> </div>
<h3>Anzeigenamen ändern</h3> <h3>Eigene Angaben</h3>
<p>Unter <b>Namensanpassung</b> kannst du festlegen, wie dein Name auf der <p>Unter <b>Namensanpassung</b> legst du fest, wie dein Name in der Liste und im Ausdruck
Kaffeeliste und im Ausdruck erscheint (maximal 20 Zeichen).</p> erscheint (max. 20 Zeichen). Unter <b>Kundenkonto</b> änderst du Passwort und E-Mail.</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>
<?php if ($istKassenwart): ?> <?php if ($istKassenwart): ?>
<hr> <hr>
<h2>Für Kassenwart und Verwaltung</h2> <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> <h3>Buchen</h3>
<p>Trage hier ein, wer wie viel eingezahlt hat. Du kannst mehrere Mitglieder in
einem Durchgang erfassen; leere Felder werden übersprungen.</p>
<ul> <ul>
<li><b>Bemerkung</b>: optionale Notiz, damit später nachvollziehbar bleibt, worum es ging.</li> <li><b>Einzahlung eintragen</b>: mehrere Mitglieder in einem Durchgang; leere Felder
<li><b>Negativer Betrag</b> = Abzug (z.&nbsp;B. Auszahlung bei Austritt oder eine werden übersprungen. Negativer Betrag = Abzug, dann ist eine Bemerkung Pflicht.</li>
Erstattung). Dabei ist eine Bemerkung <b>Pflicht</b>.</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> </ul>
<div class="hint-box"> <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 <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> Zeile fehl, wird <b>nichts</b> gebucht und deine Eingaben bleiben stehen.</p>
</div> </div>
<h3>Striche eintragen</h3> <?php if ($kann('paypal_inbox')): ?>
<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>
<h3>PayPal-Zahlungen automatisch verbuchen</h3> <h3>PayPal-Zahlungen automatisch verbuchen</h3>
<p>Die Kaffeeliste kann PayPal-Zahlungen selbstständig gutschreiben:</p> <p>Auf der Seite <b>PayPal-Zahlungen</b> steht eure Eingangsadresse. Leitet die
<ol> PayPal-Benachrichtigungen aus eurem Postfach automatisch dorthin weiter, dann werden
<li>Auf der Seite <b>PayPal-Zahlungen</b> steht eure persönliche Eingangsadresse.</li> erkannte Zahlungen selbstständig gutgeschrieben immer der tatsächlich angekommene
<li>Richtet in eurem Mailprogramm eine <b>automatische Weiterleitung</b> der Betrag. Nicht eindeutig zuordenbare Zahlungen landen in der Warteschlange „Offene
PayPal-Benachrichtigungen an diese Adresse ein.</li> Zahlungen". Ein hinterlegter <b>PayPal-Name</b> beim Mitglied verbessert die Trefferquote.</p>
<li>Erkannte Zahlungen werden dem passenden Mitglied automatisch gutgeschrieben.</li> <?php endif; ?>
</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>
<h3>CSV-Import</h3> <h3>Auswertung und Ausdruck</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>
<ul> <ul>
<li><b>Kaffeeliste anzeigen</b>: Gesamtübersicht aller aktiven Mitglieder. Ein Klick <li><b>Kaffeeliste anzeigen</b>: Gesamtübersicht; ein Klick auf den Namen öffnet die
auf den Namen öffnet die Einzelauswertung.</li> Einzelauswertung.</li>
<li><b>PDF-Export</b>: die Liste zum Ausdrucken und Aufhängen.</li> <?php if ($kann('pdf_export')): ?>
<li><b>Journal-Vorschau</b>: alle Buchungen mit Herkunft nützlich zur Kontrolle.</li> <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> </ul>
<h3>Info-Mails versenden</h3> <?php if ($kann('mail_dispatch') || $kann('year_closing')): ?>
<p>Unter <b>Mailversand</b> lassen sich Mitglieder über ihren Stand informieren. Der <h3>Mails und Jahresabschluss</h3>
<b>Dry-Run</b> zeigt zuerst, wer eine Mail bekäme, ohne etwas zu verschicken. Das <ul>
Versandlog hält fest, was tatsächlich rausging.</p> <?php if ($kann('mail_dispatch')): ?>
<li><b>Info-Mail versenden</b>: informiert Mitglieder über ihren Stand. Der Dry-Run
<h3>Jahresabschluss</h3> zeigt vorher, wer eine Mail bekäme.</li>
<p>Verteilt einen frei wählbaren Gesamtbetrag als Guthaben auf alle Mitglieder <?php endif; ?>
anteilig zu ihren Strichen im laufenden Jahr. Auch hier gilt: erst <b>Dry-Run</b> <?php if ($kann('year_closing')): ?>
zur Vorschau, dann buchen. Beim echten Lauf werden die Mitglieder per Mail informiert.</p> <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 endif; ?>
<?php if ($istAdmin): ?> <?php if ($istAdmin): ?>
<hr> <hr>
<h2>Für Administratoren</h2> <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> <h3>Mitglieder verwalten</h3>
<ul> <ul>
<li><b>Anlegen und bearbeiten</b> von Mitgliedern (Name, E-Mail, PayPal-Name).</li> <li><b>Deaktivieren</b> statt löschen: das Mitglied verschwindet aus den Listen, die
<li><b>PayPal-Name</b>: entscheidend für die automatische Zuordnung von Zahlungen.</li> Buchungshistorie bleibt.</li>
<li><b>Deaktivieren</b> statt löschen: Das Mitglied verschwindet aus den Listen, <li><b>Anonymisieren</b>: ersetzt Name und E-Mail durch einen Platzhalter, die Buchungen
die Buchungshistorie bleibt erhalten.</li> der Kasse bleiben bestehen.</li>
<li><b>Anonymisieren</b>: ersetzt Name und E-Mail durch einen Platzhalter (DSGVO-Auskunft), <li><b>Zugang gewähren / entziehen</b> steuert, ob sich jemand anmelden kann.</li>
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>
</ul> </ul>
<h3>Hinweise verwalten</h3> <h3>Hinweise</h3>
<p>Ein Hinweis erscheint als gelber Balken oben auf jeder Seite z.&nbsp;B. „Kaffeekasse <p>Ein Hinweis erscheint als gelber Balken oben auf jeder Seite und verschwindet nach dem
ist leer“. Jeder Hinweis hat ein <b>Gültig bis</b>-Datum und verschwindet danach <b>Gültig bis</b>-Datum automatisch.</p>
automatisch.</p>
<h3>Mandant-Einstellungen</h3> <h3>Mandant-Einstellungen</h3>
<p>Hier stellst du das Verhalten der Kaffeeliste ein:</p> <p>Preis pro Strich, Listenfenster, Warnschwelle, Bezahloptionen, Web-Striche, Gestaltung
<ul> des Ausdrucks<?php echo $kann('branding') ? ', eigenes Design (Logo und Akzentfarbe)' : ''; ?>
<li><b>Preis pro Strich</b> Grundlage jeder Verbrauchsbuchung.</li> und Zahlungserinnerungen. Unten steht das Protokoll aller Admin-Änderungen.</p>
<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>
<h3>Konto, Abo und Daten</h3> <h3>Konto, Abo und Daten</h3>
<ul> <ul>
<li><b>Kundenkonto</b>: Tarif, Rechnungen und Zahlungsdaten.</li> <li><b>Kundenkonto</b>: Tarif, Rechnungen und Zahlungsdaten.</li>
<li><b>Datenexport</b>: alle Daten des Mandanten als Datei für ein Backup oder <?php if ($kann('data_export')): ?>
einen Umzug.</li> <li><b>Datenexport</b>: alle Daten des Mandanten als Datei.</li>
<li><b>Mandant löschen</b>: entfernt den Mandanten mit allen Daten endgültig.</li> <?php endif; ?>
<li><b>Mandant löschen</b>: entfernt alles endgültig<?php echo $kann('data_export') ? ' vorher Datenexport ziehen' : ''; ?>.</li>
</ul> </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> <h3>Rollen im Überblick</h3>
<table> <table>
@@ -231,11 +180,10 @@ include "nav.php";
</table> </table>
<?php endif; ?> <?php endif; ?>
<?php if ($kann('faq')): ?>
<hr> <hr>
<p>Weitere Fragen beantwortet eure Verwaltung unter <a href="faq.php">FAQ Kaffeeliste</a>.</p>
<h3>Noch Fragen?</h3> <?php endif; ?>
<p>Häufige Fragen und die Antworten eurer Verwaltung stehen unter
<a href="faq.php">FAQ Kaffeeliste</a>.</p>
</div> </div>
</section> </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 * Seeds two example questions for a brand-new tenant, damit die FAQ-Seite
* excerpt already shown on the public landing page), so new customers do * nicht voellig leer startet und sofort erkennbar ist, wie ein Eintrag
* not start with a completely empty FAQ. They can edit or delete every * aussieht. Bewusst nur zwei Beispiele statt eines fertigen Katalogs: die
* entry afterwards. * 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 function faq_seed_default_entries(PDO $pdo, int $tenantId): void
{ {
$starters = [ $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.'], ['Wie starten wir mit unserer Kaffeeliste?', 'Beispielantwort bitte anpassen: Mitglieder unter „Mitglieder verwalten" anlegen, danach direkt Striche und Einzahlungen erfassen.'],
['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.'], ['Wie erfahre ich meinen aktuellen Stand?', 'Beispielantwort bitte anpassen: Unter „Meine Kaffeeliste" ist der aktuelle Stand jederzeit sichtbar.'],
['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.'],
]; ];
$stmt = $pdo->prepare('INSERT INTO faq_entries (tenant_id, question, answer, sort_order) VALUES (?, ?, ?, ?)'); $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 * 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. * 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. * Commits every 'matched' row of a previewed batch straight into the ledger.
* Already-committed batches are rejected. * 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(); 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 * "100-Tage-Liste" Vorder-/Rueckseiten-Aufteilung: aktive Teilnehmer, deren
* summierte Striche im nachlaufenden Fenster die Zehner-Schwelle erreichen * 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.cash_contact,
ts.bank_transfer_enabled, ts.bank_transfer_enabled,
ts.bank_account_holder, ts.bank_account_holder,
ts.bank_iban ts.bank_iban,
ts.brand_logo,
ts.brand_color
FROM tenants t FROM tenants t
LEFT JOIN tenant_settings ts ON ts.tenant_id = t.id LEFT JOIN tenant_settings ts ON ts.tenant_id = t.id
WHERE 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_transfer_enabled' => (int)$settings['bank_transfer_enabled'],
'bank_account_holder' => (string)$settings['bank_account_holder'], 'bank_account_holder' => (string)$settings['bank_account_holder'],
'bank_iban' => (string)$settings['bank_iban'], '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'] ?? '')); $pdfWatermarkText = trim((string)($input['pdf_watermark_text'] ?? ''));
$pdfFooterText = trim((string)($input['pdf_footer_text'] ?? '')); $pdfFooterText = trim((string)($input['pdf_footer_text'] ?? ''));
$paymentReminderIntervalDays = filter_var($input['payment_reminder_interval_days'] ?? null, FILTER_VALIDATE_INT); $paymentReminderIntervalDays = filter_var($input['payment_reminder_interval_days'] ?? null, FILTER_VALIDATE_INT);
$brandColor = strtolower(trim((string)($input['brand_color'] ?? '')));
$errors = []; $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) { if (strlen($tenantName) < 3 || strlen($tenantName) > 255) {
$errors[] = 'Der Kundenname muss zwischen 3 und 255 Zeichen lang sein.'; $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, pdf_watermark_text, pdf_footer_text,
payment_reminder_enabled, payment_reminder_interval_days, payment_reminder_enabled, payment_reminder_interval_days,
cash_enabled, cash_contact, bank_transfer_enabled, cash_enabled, cash_contact, bank_transfer_enabled,
bank_account_holder, bank_iban) bank_account_holder, bank_iban, brand_color)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
mark_price_cents = VALUES(mark_price_cents), mark_price_cents = VALUES(mark_price_cents),
self_entry_enabled = VALUES(self_entry_enabled), 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), cash_contact = VALUES(cash_contact),
bank_transfer_enabled = VALUES(bank_transfer_enabled), bank_transfer_enabled = VALUES(bank_transfer_enabled),
bank_account_holder = VALUES(bank_account_holder), bank_account_holder = VALUES(bank_account_holder),
bank_iban = VALUES(bank_iban)' bank_iban = VALUES(bank_iban),
brand_color = VALUES(brand_color)'
); );
$stmt->execute([ $stmt->execute([
$tenantId, $tenantId,
@@ -475,6 +486,7 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
$bankTransferEnabled, $bankTransferEnabled,
$bankAccountHolder, $bankAccountHolder,
$bankIban, $bankIban,
$brandColor,
]); ]);
$pdo->commit(); $pdo->commit();
+16 -4
View File
@@ -28,14 +28,25 @@ function tenant_logo_path(string $filename): ?string
return is_file($path) ? $path : null; 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 * 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 array $file Eintrag aus $_FILES
* @param string $kind 'watermark' (Ausdruck) oder 'brand' (App-Oberflaeche)
* @return array{ok: bool, filename?: string, error?: string} * @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) { if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return ['ok' => false, 'error' => 'Das Logo konnte nicht hochgeladen werden.']; 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. // Alte Varianten (andere Endung) desselben Mandanten entfernen.
$prefix = tenant_logo_prefix($kind);
foreach (['png', 'jpg'] as $altExt) { foreach (['png', 'jpg'] as $altExt) {
$old = $dir . '/logo_' . $tenantId . '.' . $altExt; $old = $dir . '/' . $prefix . $tenantId . '.' . $altExt;
if (is_file($old)) { if (is_file($old)) {
@unlink($old); @unlink($old);
} }
} }
$filename = 'logo_' . $tenantId . '.' . $ext; $filename = $prefix . $tenantId . '.' . $ext;
if (!move_uploaded_file($tmp, $dir . '/' . $filename)) { if (!move_uploaded_file($tmp, $dir . '/' . $filename)) {
return ['ok' => false, 'error' => 'Das Logo konnte nicht gespeichert werden.']; return ['ok' => false, 'error' => 'Das Logo konnte nicht gespeichert werden.'];
} }
+19
View File
@@ -129,6 +129,25 @@ div.hint-box {
flex-direction: column; 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) * Hervorgehobene Tabellenzeile (aktuell gebuchter Tarif)
* *
+142
View File
@@ -2293,3 +2293,145 @@ button,
border-left: 5px solid #4caf50; border-left: 5px solid #4caf50;
color: #1b5e20; 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__ . '/functions.php';
require_once __DIR__ . '/app/platform-admin.php'; require_once __DIR__ . '/app/platform-admin.php';
require_once __DIR__ . '/app/features.php';
$pdo = app_db_pdo(); $pdo = app_db_pdo();
$user = app_require_platform_admin($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) { if ($tenantId === null || $tenantId === false || $tenantId <= 0) {
http_response_code(400); http_response_code(400);
exit('Ungültige Mandanten-ID.'); 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); $detail = app_backoffice_fetch_tenant_detail($pdo, $tenantId);
if ($detail === null) { if ($detail === null) {
http_response_code(404); http_response_code(404);
@@ -23,6 +42,8 @@ if ($detail === null) {
// nachvollziehbar bleibt (siehe AVV-Transparenzpflicht). // nachvollziehbar bleibt (siehe AVV-Transparenzpflicht).
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'backoffice.tenant_viewed', 'tenant', $tenantId); 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']; $rollenLabels = ['owner' => 'Inhaber', 'admin' => 'Administrator', 'treasurer' => 'Kassenwart', 'member' => 'Mitglied', 'viewer' => 'Betrachter'];
include 'header.php'; include 'header.php';
@@ -51,6 +72,39 @@ include 'nav.php';
<button type="submit">Datenexport dieses Mandanten herunterladen</button> <button type="submit">Datenexport dieses Mandanten herunterladen</button>
</form> </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> <h3>Mitglieder mit Zugang</h3>
<table> <table>
<tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th>Status</th></tr> <tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th>Status</th></tr>
+199 -5
View File
@@ -1,9 +1,12 @@
<?php <?php
ob_start();
include "functions.php"; include "functions.php";
require_once __DIR__ . "/app/ledger.php"; require_once __DIR__ . "/app/ledger.php";
require_once __DIR__ . "/app/imports.php"; require_once __DIR__ . "/app/imports.php";
require_once __DIR__ . "/app/audit.php"; require_once __DIR__ . "/app/audit.php";
require_once __DIR__ . "/app/features.php";
app_require_csrf(); app_require_csrf();
include "header.php"; include "header.php";
include "headerline.php"; include "headerline.php";
@@ -34,6 +37,12 @@ if (!$hasAccess) {
exit; 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 function csv_upload_dir(): string
{ {
return APP_ROOT . '/var/uploads'; return APP_ROOT . '/var/uploads';
@@ -103,22 +112,88 @@ function csv_status_label(string $status): string
'unmatched' => 'Mitglied nicht gefunden', 'unmatched' => 'Mitglied nicht gefunden',
'invalid' => 'Ungültige Zeile', 'invalid' => 'Ungültige Zeile',
'imported' => 'Importiert', 'imported' => 'Importiert',
'ignored' => 'Ignoriert',
default => $status, 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; $meldung = null;
$fehler = 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") { if ($_SERVER["REQUEST_METHOD"] === "POST") {
$aktion = $_POST['aktion'] ?? 'hochladen'; $aktion = $_POST['aktion'] ?? 'hochladen';
$vorschauBatchId = (int)($_POST['batch_id'] ?? 0);
if ($aktion === 'hochladen' && isset($_FILES["csv_file"])) { if ($aktion === 'hochladen' && isset($_FILES["csv_file"])) {
[$csvFile, $uploadError] = csv_prepare_upload($_FILES["csv_file"]); [$csvFile, $uploadError] = csv_prepare_upload($_FILES["csv_file"]);
if ($uploadError !== null) { if ($uploadError !== null) {
$fehler = $uploadError; $fehler = $uploadError;
$_SESSION['flash_csvupload'] = ['fehler' => $fehler];
csv_redirect_to_batch();
} else { } else {
try { try {
$checksum = hash_file('sha256', $csvFile); $checksum = hash_file('sha256', $csvFile);
@@ -160,6 +235,7 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
$pdo->commit(); $pdo->commit();
$vorschauBatchId = $batchId; $vorschauBatchId = $batchId;
$meldung = 'CSV-Datei wurde eingelesen. Bitte die Vorschau prüfen.';
} catch (Throwable $e) { } catch (Throwable $e) {
if ($pdo->inTransaction()) { if ($pdo->inTransaction()) {
$pdo->rollBack(); $pdo->rollBack();
@@ -168,9 +244,43 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
} finally { } finally {
@unlink($csvFile); @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') { } elseif ($aktion === 'importieren') {
$batchId = (int)($_POST['batch_id'] ?? 0); $batchId = $vorschauBatchId;
try { try {
$ergebnis = imports_commit_batch($pdo, $tenantId, $batchId); $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']]); 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(); $fehler = $e->getMessage();
$vorschauBatchId = $batchId; $vorschauBatchId = $batchId;
} }
$_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler];
csv_redirect_to_batch($vorschauBatchId);
} }
} }
$vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vorschauBatchId) : null; $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): ?> <?php if ($vorschau !== null): ?>
<h3>Vorschau: <?php echo saas_html($vorschau['batch']['original_filename']); ?></h3> <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> <tr>
<th>Zeile</th> <th>Zeile</th>
<th>Name (CSV)</th> <th>Name (CSV)</th>
@@ -207,18 +335,58 @@ $vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vo
<th>Betrag</th> <th>Betrag</th>
<th>Datum</th> <th>Datum</th>
<th>Status</th> <th>Status</th>
<th>Aktion</th>
</tr> </tr>
<?php foreach ($vorschau['rows'] as $row): ?> <?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> <tr>
<td><?php echo (int)$row['row_num']; ?></td> <td><?php echo (int)$row['row_num']; ?></td>
<td><?php echo saas_html($row['raw_name']); ?></td> <td><?php echo saas_html($row['raw_name']); ?></td>
<td><?php echo saas_html($row['display_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(number_format(((int)$row['amount_cents']) / 100, 2, ',', '.')); ?> €</td>
<td><?php echo saas_html($row['booked_at']); ?></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> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</table> </table>
</div>
<?php if ($vorschau['batch']['status'] === 'previewed'): ?> <?php if ($vorschau['batch']['status'] === 'previewed'): ?>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>"> <form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
@@ -231,6 +399,32 @@ $vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vo
<br> <br>
<?php endif; ?> <?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"> <form action="csvupload.php" method="post" enctype="multipart/form-data">
<?php echo app_csrf_field(); ?> <?php echo app_csrf_field(); ?>
<input type="hidden" name="aktion" value="hochladen"> <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__ . '/functions.php';
require_once __DIR__ . '/app/data-export.php'; require_once __DIR__ . '/app/data-export.php';
require_once __DIR__ . '/app/audit.php'; require_once __DIR__ . '/app/audit.php';
require_once __DIR__ . '/app/features.php';
$pdo = app_db_pdo(); $pdo = app_db_pdo();
$user = saas_require_login(); $user = saas_require_login();
@@ -12,6 +13,11 @@ if (!saas_can_manage_tenant_settings($user)) {
exit('Kein Zugriff.'); 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') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
app_require_csrf(); 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 - ✅ Adaptives Layout nach Mitgliederanzahl (Zeilenhöhe 1640px je nach
Mitgliederzahl). Mitgliederzahl).
- ✅ Vorder-/Rückseite bzw. mehrere Seiten erst ab 50 Mitgliedern - ✅ Vorder-/Rückseite bzw. mehrere Seiten erst bei **mehr als** 50
(`EXPORT_MULTI_PAGE_THRESHOLD`), darunter eine einseitige Liste. 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" - ✅ Admin-Einstellung „Freie Zeilen für neue Mitglieder"
(`tenant_settings.pdf_show_empty_rows`, Formular in (`tenant_settings.pdf_show_empty_rows`, Formular in
`mandant-einstellungen.php`). `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, - `backoffice.php`: Übersicht aller Mandanten mit Kürzel, Status,
Erstelldatum, Teilnehmerzahl (aktiv/gesamt) und Saldensumme. Erstelldatum, Teilnehmerzahl (aktiv/gesamt) und Saldensumme.
- `backoffice-mandant.php?tenant_id=X`: reine Leseansicht eines einzelnen - `backoffice-mandant.php?tenant_id=X`: Ansicht eines einzelnen Mandanten
Mandanten Einstellungen, Mitglieder mit Rolle, letzte 20 Buchungen, Einstellungen, Mitglieder mit Rolle, letzte 20 Buchungen, letzte 20
letzte 20 Admin-Aktionen. Absichtlich **kein** Schreibzugriff auf Admin-Aktionen, dazu die Funktions-Schalter (siehe unten). Absichtlich
Mandantendaten von hier aus, um das Risiko einer versehentlichen **kein** Schreibzugriff auf die eigentlichen Mandantendaten (Buchungen,
Fremdänderung auszuschließen. 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()`- - `backoffice-export.php`: nutzt dieselbe `app_export_tenant_data()`-
Funktion wie der Selbstbedienungs-Export, aber ausgelöst durch den Funktion wie der Selbstbedienungs-Export, aber ausgelöst durch den
Platform-Admin für einen beliebigen Mandanten. 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`) ## Verhältnis zum Selbstbedienungs-Export (`datenexport.php`)
Bewusste Entscheidung: Der bestehende Selbstbedienungs-Export für Bewusste Entscheidung: Der bestehende Selbstbedienungs-Export für
+16 -1
View File
@@ -4,6 +4,7 @@ ob_start();
include "functions.php"; include "functions.php";
require_once __DIR__ . "/app/ledger.php"; require_once __DIR__ . "/app/ledger.php";
require_once __DIR__ . "/app/features.php";
app_require_csrf(); app_require_csrf();
include "header.php"; include "header.php";
@@ -193,7 +194,17 @@ if ($_SERVER["REQUEST_METHOD"] == "POST" ) {
$action = (string)($_GET['action'] ?? 'alle'); $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> </div>
<ul class="actions"> <ul class="actions">
<?php if ($zweiseitig): ?>
<li> <li>
<form action="einzahlung.php" method="get"> <form action="einzahlung.php" method="get">
<input type="hidden" name="action" value="vorderseite"> <input type="hidden" name="action" value="vorderseite">
@@ -235,6 +247,7 @@ $mitarbeiter = einzahlung_fetch_participants($pdo, $tenantId, $action);
<button type="submit">Rückseite</button> <button type="submit">Rückseite</button>
</form> </form>
</li> </li>
<?php endif; ?>
<li> <li>
<form action="einzahlung.php" method="get"> <form action="einzahlung.php" method="get">
<input type="hidden" name="action" value="alle"> <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> <button type="submit">Letzten Einträge</button>
</form> </form>
</li> </li>
<?php if (app_feature_enabled($pdo, $tenantId, 'csv_import')): ?>
<li> <li>
<form action="csvupload.php" method="get"> <form action="csvupload.php" method="get">
<button type="submit">CSV Upload</button> <button type="submit">CSV Upload</button>
</form> </form>
</li> </li>
<?php endif; ?>
</ul> </ul>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>"> <form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
<?php echo app_csrf_field(); ?> <?php echo app_csrf_field(); ?>
+10 -4
View File
@@ -3,6 +3,7 @@
include "functions.php"; include "functions.php";
require_once __DIR__ . "/app/ledger.php"; require_once __DIR__ . "/app/ledger.php";
require_once __DIR__ . "/app/tenant-logo.php"; require_once __DIR__ . "/app/tenant-logo.php";
require_once __DIR__ . "/app/features.php";
$pdo = app_db_pdo(); $pdo = app_db_pdo();
$saasUser = saas_current_user($pdo); $saasUser = saas_current_user($pdo);
@@ -19,6 +20,11 @@ if (!$hasAccess) {
exit('Kein Zugriff.'); 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); $settings = saas_fetch_tenant_settings($pdo, $tenantId);
$windowDays = $settings['sheet_window_days'] ?? 100; $windowDays = $settings['sheet_window_days'] ?? 100;
$markPrice = number_format(($settings['mark_price_cents'] ?? 20) / 100, 2, ',', '.'); $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'] ?? '')); $footerText = trim((string)($settings['pdf_footer_text'] ?? ''));
$watermarkLogoPath = tenant_logo_path((string)($settings['pdf_watermark_logo'] ?? '')) ?? ''; $watermarkLogoPath = tenant_logo_path((string)($settings['pdf_watermark_logo'] ?? '')) ?? '';
// Ab dieser Mitgliederzahl wird auf zwei Seiten (Vorder-/Rueckseite) statt // Vorder-/Rueckseite erst oberhalb der gemeinsamen Schwelle aus app/ledger.php
// einer einzelnen Seite gedruckt. // (LEDGER_SINGLE_PAGE_MAX_MEMBERS): bis einschliesslich 50 Personen bleibt der
define('EXPORT_MULTI_PAGE_THRESHOLD', 50); // Ausdruck bewusst einseitig.
// TCPDF's writeHTML() ignoriert CSS-Zeilenhoehen/-innenabstaende auf // TCPDF's writeHTML() ignoriert CSS-Zeilenhoehen/-innenabstaende auf
// Tabellenzeilen vollstaendig (getestet: weder "height" auf <tr> noch // Tabellenzeilen vollstaendig (getestet: weder "height" auf <tr> noch
// "padding" auf <td> haben irgendeinen Einfluss auf die gerenderte Zeilen- // "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]); $allActive = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]);
$memberCount = count($allActive); $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 * 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/ledger.php";
require_once __DIR__ . "/app/faq.php"; require_once __DIR__ . "/app/faq.php";
require_once __DIR__ . "/app/audit.php"; require_once __DIR__ . "/app/audit.php";
require_once __DIR__ . "/app/features.php";
app_require_csrf(); app_require_csrf();
include "header.php"; include "header.php";
include "headerline.php"; include "headerline.php";
@@ -37,6 +38,12 @@ if (!$hasAccess) {
exit; exit;
} }
if (!app_feature_enabled($pdo, $tenantId, 'faq')) {
echo app_feature_notice_html('faq');
include "footer.php";
exit;
}
$meldung = null; $meldung = null;
$fehler = null; $fehler = null;
$bearbeitenId = null; $bearbeitenId = null;
+39
View File
@@ -30,6 +30,31 @@ if ($saasNavUser !== null && function_exists('saas_list_user_memberships')) {
$saasHasMultipleTenants = false; $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> <ul>
<li> <li>
@@ -37,7 +62,9 @@ if ($saasNavUser !== null && function_exists('saas_list_user_memberships')) {
<ul class="menu-group-items"> <ul class="menu-group-items">
<li><a href="index.php">Meine Kaffeeliste</a></li> <li><a href="index.php">Meine Kaffeeliste</a></li>
<li><a href="anleitung.php">Anleitung</a></li> <li><a href="anleitung.php">Anleitung</a></li>
<?php if ($saasNavFeature('faq')): ?>
<li><a href="faq.php">FAQ Kaffeeliste</a></li> <li><a href="faq.php">FAQ Kaffeeliste</a></li>
<?php endif; ?>
</ul> </ul>
</li> </li>
<?php if ($canUseVerwaltungGroup): ?> <?php if ($canUseVerwaltungGroup): ?>
@@ -45,11 +72,15 @@ if ($saasNavUser !== null && function_exists('saas_list_user_memberships')) {
<span class="menu-group-label">Verwaltung</span> <span class="menu-group-label">Verwaltung</span>
<ul class="menu-group-items"> <ul class="menu-group-items">
<li><a href="einzahlung.php">Einzahlung eintragen</a></li> <li><a href="einzahlung.php">Einzahlung eintragen</a></li>
<?php if ($saasNavFeature('paypal_inbox')): ?>
<li><a href="paypal-zuordnung.php">PayPal-Zahlungen</a></li> <li><a href="paypal-zuordnung.php">PayPal-Zahlungen</a></li>
<?php endif; ?>
<li><a href="stricheintragen.php">Striche eintragen</a></li> <li><a href="stricheintragen.php">Striche eintragen</a></li>
<li><a href="kaffeeliste.php">Kaffeeliste anzeigen</a></li> <li><a href="kaffeeliste.php">Kaffeeliste anzeigen</a></li>
<li><a href="ledger-preview.php">Journal-Vorschau</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> <li><a href="jahresauswertung.php">Jahresabschluss</a></li>
<?php endif; ?>
<?php if ($saasCanManageMembersNav): ?> <?php if ($saasCanManageMembersNav): ?>
<li><a href="mitarbeiterverwalten.php">Mitglieder verwalten</a></li> <li><a href="mitarbeiterverwalten.php">Mitglieder verwalten</a></li>
<li><a href="hinweise.php">Hinweise 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> <li><a href="logout.php">Logout</a></li>
</ul> </ul>
</li> </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; ?> <?php endif; ?>
</ul> </ul>
</nav> </nav>
+17 -2
View File
@@ -11,12 +11,27 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="assets/css/main.css" /> <link rel="stylesheet" href="assets/css/main.css" />
<link rel="stylesheet" href="assets/css/app.css" /> <link rel="stylesheet" href="assets/css/app.css" />
</head>
<body class="is-preload">
<?php <?php
require_once __DIR__ . '/app/ledger.php'; require_once __DIR__ . '/app/ledger.php';
require_once __DIR__ . '/app/notices.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 // Aktuellen Hinweis fuer den passenden Mandanten abrufen: eingeloggte
// SaaS-Nutzer sehen den Hinweis ihres Mandanten, der Legacy-/Dev-Fallback // SaaS-Nutzer sehen den Hinweis ihres Mandanten, der Legacy-/Dev-Fallback
+17 -1
View File
@@ -1,2 +1,18 @@
<?php <?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"; include "functions.php";
require_once __DIR__ . "/app/ledger.php"; require_once __DIR__ . "/app/ledger.php";
require_once __DIR__ . "/app/features.php";
app_require_csrf(); app_require_csrf();
$pdo = app_db_pdo(); $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.']; 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); $marks = filter_var($marksValue, FILTER_VALIDATE_INT);
if ($marks !== 1 && $marks !== 2) { if ($marks !== 1 && $marks !== 2) {
return ['ok' => false, 'error' => 'Bitte einen oder zwei Striche auswählen.']; 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']; $balanceCents = (int)$participant['balance_cents'];
$balance = dashboard_money($balanceCents); $balance = dashboard_money($balanceCents);
$currentName = dashboard_current_name($saasUser, $participant); $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 $paypalEnabled = (int)$settings['paypal_enabled'] === 1
&& trim((string)$settings['paypal_url_template']) !== ''; && trim((string)$settings['paypal_url_template']) !== '';
$cashEnabled = (int)$settings['cash_enabled'] === 1; $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/ledger.php";
require_once __DIR__ . "/app/saas-mail.php"; require_once __DIR__ . "/app/saas-mail.php";
require_once __DIR__ . "/app/audit.php"; require_once __DIR__ . "/app/audit.php";
require_once __DIR__ . "/app/features.php";
app_require_csrf(); app_require_csrf();
$pdo = app_db_pdo(); $pdo = app_db_pdo();
@@ -16,6 +17,14 @@ if ($saasUser !== null && saas_user_has_role(['owner', 'admin', 'treasurer'], $s
$hasAccess = true; $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'); $jahr = (int)date('Y');
$fehler = null; $fehler = null;
$ergebnisse = null; $ergebnisse = null;
@@ -181,7 +190,9 @@ include "nav.php";
<section id="banner"> <section id="banner">
<div class="content"> <div class="content">
<?php if (!$hasAccess): ?> <?php if ($gesperrteFunktion !== null): ?>
<?php echo app_feature_notice_html($gesperrteFunktion); ?>
<?php elseif (!$hasAccess): ?>
<h2>Kein Zugriff</h2> <h2>Kein Zugriff</h2>
<?php else: ?> <?php else: ?>
+7
View File
@@ -2,6 +2,7 @@
include "functions.php"; include "functions.php";
require_once __DIR__ . "/app/ledger.php"; require_once __DIR__ . "/app/ledger.php";
require_once __DIR__ . "/app/features.php";
$pdo = app_db_pdo(); $pdo = app_db_pdo();
$saasUser = saas_current_user($pdo); $saasUser = saas_current_user($pdo);
@@ -35,21 +36,25 @@ if ($hasAccess) {
<h2>Aktive Mitarbeiter mit Gesamtstand</h2> <h2>Aktive Mitarbeiter mit Gesamtstand</h2>
<br> <br>
<ul class="actions"> <ul class="actions">
<?php if (app_feature_enabled($pdo, $tenantId, 'pdf_export')): ?>
<li> <li>
<form action="exportKaffeeliste.php" method="get"> <form action="exportKaffeeliste.php" method="get">
<button type="submit">Kaffeeliste exportieren</button> <button type="submit">Kaffeeliste exportieren</button>
</form> </form>
</li> </li>
<?php endif; ?>
<li> <li>
<form action="letzteneintraege.php" method="get"> <form action="letzteneintraege.php" method="get">
<button type="submit">Letzten Einträge</button> <button type="submit">Letzten Einträge</button>
</form> </form>
</li> </li>
<?php if (app_feature_enabled($pdo, $tenantId, 'csv_import')): ?>
<li> <li>
<form action="csvupload.php" method="get"> <form action="csvupload.php" method="get">
<button type="submit">CSV Upload</button> <button type="submit">CSV Upload</button>
</form> </form>
</li> </li>
<?php endif; ?>
</ul> </ul>
<br> <br>
@@ -85,9 +90,11 @@ if ($hasAccess) {
</table> </table>
<br><br> <br><br>
<?php if (app_feature_enabled($pdo, $tenantId, 'mail_dispatch')): ?>
<form action="mailversenden.php" method="get"> <form action="mailversenden.php" method="get">
<button type="submit">Info-Mail versenden</button> <button type="submit">Info-Mail versenden</button>
</form> </form>
<?php endif; ?>
<?php <?php
} else { } else {
echo "<h2>Kein Zugriff</h2>"; echo "<h2>Kein Zugriff</h2>";
+3
View File
@@ -1,6 +1,7 @@
<?php <?php
require_once __DIR__ . '/functions.php'; require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/app/features.php';
$user = saas_require_login(); $user = saas_require_login();
$devVerificationToken = null; $devVerificationToken = null;
@@ -124,7 +125,9 @@ include 'nav.php';
<?php if (saas_can_manage_tenant_settings($user)): ?> <?php if (saas_can_manage_tenant_settings($user)): ?>
<ul class="actions"> <ul class="actions">
<li><a href="mandant-einstellungen.php" class="button">Mandant-Einstellungen</a></li> <li><a href="mandant-einstellungen.php" class="button">Mandant-Einstellungen</a></li>
<?php if (app_feature_enabled(app_db_pdo(), (int)$user['tenant_id'], 'data_export')): ?>
<li><a href="datenexport.php" class="button">Datenexport</a></li> <li><a href="datenexport.php" class="button">Datenexport</a></li>
<?php endif; ?>
<?php if (saas_user_has_role('owner', $user)): ?> <?php if (saas_user_has_role('owner', $user)): ?>
<li><a href="mandant-loeschen.php" class="button">Mandant löschen</a></li> <li><a href="mandant-loeschen.php" class="button">Mandant löschen</a></li>
<?php endif; ?> <?php endif; ?>
+12 -1
View File
@@ -4,6 +4,7 @@ include "functions.php";
require_once __DIR__ . "/app/ledger.php"; require_once __DIR__ . "/app/ledger.php";
require_once __DIR__ . "/app/saas-mail.php"; require_once __DIR__ . "/app/saas-mail.php";
require_once __DIR__ . "/app/audit.php"; require_once __DIR__ . "/app/audit.php";
require_once __DIR__ . "/app/features.php";
app_require_csrf(); app_require_csrf();
$pdo = app_db_pdo(); $pdo = app_db_pdo();
@@ -16,6 +17,14 @@ if ($saasUser !== null && saas_user_has_role(['owner', 'admin', 'treasurer'], $s
$hasAccess = true; $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; $ergebnisse = null;
$dryRun = true; $dryRun = true;
$liveFlash = false; $liveFlash = false;
@@ -117,7 +126,9 @@ include "nav.php";
<section id="banner"> <section id="banner">
<div class="content"> <div class="content">
<?php if (!$hasAccess): ?> <?php if ($gesperrteFunktion !== null): ?>
<?php echo app_feature_notice_html($gesperrteFunktion); ?>
<?php elseif (!$hasAccess): ?>
<h2>Kein Zugriff</h2> <h2>Kein Zugriff</h2>
<?php else: ?> <?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/audit.php';
require_once __DIR__ . '/app/billing.php'; require_once __DIR__ . '/app/billing.php';
require_once __DIR__ . '/app/tenant-logo.php'; require_once __DIR__ . '/app/tenant-logo.php';
require_once __DIR__ . '/app/features.php';
$pdo = app_db_pdo(); $pdo = app_db_pdo();
$user = saas_require_login(); $user = saas_require_login();
$canManageSettings = saas_can_manage_tenant_settings($user); $canManageSettings = saas_can_manage_tenant_settings($user);
$brandingEnabled = app_feature_enabled($pdo, (int)$user['tenant_id'], 'branding');
$errors = []; $errors = [];
$saved = false; $saved = false;
$settings = null; $settings = null;
@@ -15,12 +17,39 @@ $settings = null;
if ($canManageSettings) { if ($canManageSettings) {
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
app_require_csrf(); 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']) { if ($result['ok']) {
$saved = true; $saved = true;
$settings = $result['settings']; $settings = $result['settings'];
app_audit_log($pdo, (int)$user['tenant_id'], (int)$user['user_id'], 'tenant_settings.updated', 'tenant', (int)$user['tenant_id']); 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']; $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'])) { if (!empty($_POST['pdf_watermark_logo_remove'])) {
tenant_logo_delete((string)($settings['pdf_watermark_logo'] ?? '')); tenant_logo_delete((string)($settings['pdf_watermark_logo'] ?? ''));
$pdo->prepare("UPDATE tenant_settings SET pdf_watermark_logo = '' WHERE tenant_id = ?")->execute([$tenantIdLogo]); $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'] ?? '', 'pdf_footer_text' => $_POST['pdf_footer_text'] ?? '',
'payment_reminder_enabled' => !empty($_POST['payment_reminder_enabled']) ? 1 : 0, 'payment_reminder_enabled' => !empty($_POST['payment_reminder_enabled']) ? 1 : 0,
'payment_reminder_interval_days' => $_POST['payment_reminder_interval_days'] ?? 7, '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 { } 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> <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']); ?>"> <input type="text" name="pdf_footer_text" id="pdf_footer_text" maxlength="500" value="<?php echo saas_html($settings['pdf_footer_text']); ?>">
</div> </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"> <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' : ''; ?>> <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> <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; $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){ if($hasAccess){
$meldung = null; $meldung = null;
@@ -40,6 +100,17 @@ if($hasAccess){
$einladungslink = null; $einladungslink = null;
$bearbeitenId = null; $bearbeitenId = null;
$actorUserId = $saasUser['user_id'] ?? 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'])) { if (isset($_SESSION['flash_mitglieder']) && is_array($_SESSION['flash_mitglieder'])) {
$flash = $_SESSION['flash_mitglieder']; $flash = $_SESSION['flash_mitglieder'];
@@ -224,7 +295,7 @@ if($hasAccess){
'fehler' => $fehler, 'fehler' => $fehler,
'einladungslink' => $einladungslink, 'einladungslink' => $einladungslink,
]; ];
header('Location: mitarbeiterverwalten.php'); header('Location: mitarbeiterverwalten.php' . $filterSuffix);
exit; exit;
} }
} }
@@ -235,6 +306,77 @@ if($hasAccess){
$billingPlan = billing_plans()[$billingInfo['plan_code']] ?? null; $billingPlan = billing_plans()[$billingInfo['plan_code']] ?? null;
$aktiveMitgliederAnzahl = billing_count_active_participants($pdo, $tenantId); $aktiveMitgliederAnzahl = billing_count_active_participants($pdo, $tenantId);
$rollenLabels = ['owner' => 'Inhaber', 'admin' => 'Administrator', 'treasurer' => 'Kassenwart', 'member' => 'Mitglied', 'viewer' => 'Betrachter']; $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; $bearbeitenMitglied = null;
if ($bearbeitenId !== null) { if ($bearbeitenId !== null) {
foreach ($mitglieder as $m) { 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. Limit erreicht für weitere aktive Mitglieder ist ein <a href="mandant-einstellungen.php">Upgrade</a> nötig.
<?php endif; ?></p> <?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): ?> <?php if ($bearbeitenMitglied !== null): ?>
<h3>Bearbeiten von <?php echo saas_html($bearbeitenMitglied['display_name']); ?></h3> <h3>Bearbeiten von <?php echo saas_html($bearbeitenMitglied['display_name']); ?></h3>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>"> <form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
<input type="hidden" name="aktion" value="bearbeitenspeichern"> <input type="hidden" name="aktion" value="bearbeitenspeichern">
<input type="hidden" name="mitgliedID" value="<?php echo $bearbeitenMitglied['participant_id']; ?>"> <input type="hidden" name="mitgliedID" value="<?php echo $bearbeitenMitglied['participant_id']; ?>">
<?php echo app_csrf_field(); ?> <?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<label for="name">Name:</label> <label for="name">Name:</label>
<input type="text" name="name" id="name" value="<?php echo saas_html($bearbeitenMitglied['display_name']); ?>" required> <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"]); ?>"> <form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
<input type="hidden" name="aktion" id="aktion" value="anlegen"> <input type="hidden" name="aktion" id="aktion" value="anlegen">
<?php echo app_csrf_field(); ?> <?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<label for="new-name">Name:</label> <label for="new-name">Name:</label>
<input type="text" name="name" id="new-name" required> <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> <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"> <form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" enctype="multipart/form-data">
<?php echo app_csrf_field(); ?> <?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<input type="hidden" name="aktion" value="csv_import"> <input type="hidden" name="aktion" value="csv_import">
<label for="mitglieder_csv">CSV-Datei mit Mitgliedern:</label> <label for="mitglieder_csv">CSV-Datei mit Mitgliedern:</label>
<input type="file" name="mitglieder_csv" id="mitglieder_csv" accept=".csv" required> <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 <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> 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 --> <!-- Tabelle zur Anzeige und Bearbeitung von Mitgliedern -->
<table class="table table-striped"> <div class="table-wrapper">
<table class="table table-striped admin-table">
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>E-Mail</th> <th>E-Mail</th>
@@ -336,22 +519,28 @@ if($hasAccess){
<th>Zugang</th> <th>Zugang</th>
<th>Aktionen</th> <th>Aktionen</th>
</tr> </tr>
<?php foreach ($mitglieder as $mitglied): ?> <?php foreach ($gefilterteMitglieder as $mitglied): ?>
<tr> <tr>
<td><?php echo saas_html($mitglied['display_name']); ?></td> <td><?php echo saas_html($mitglied['display_name']); ?></td>
<td><?php echo saas_html($mitglied['email'] ?? ''); ?></td> <td><?php echo saas_html($mitglied['email'] ?? ''); ?></td>
<td><?php echo saas_html($mitglied['paypal_name'] ?? ''); ?></td> <td>
<td><?php echo $mitglied['active'] ? '1' : '0'; ?></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> <td>
<?php if ($mitglied['membership_status'] === 'active'): ?> <?php if ($mitglied['membership_status'] === 'active'): ?>
<?php if ($mitglied['role'] === 'owner'): ?> <?php if ($mitglied['role'] === 'owner'): ?>
Inhaber <span class="status-badge info">Inhaber</span>
<?php else: ?> <?php else: ?>
<?php echo saas_html($rollenLabels[$mitglied['role']] ?? $mitglied['role']); ?> <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"]); ?>" 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="aktion" value="zugang_gewaehren">
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>"> <input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
<?php echo app_csrf_field(); ?> <?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<select name="rolle"> <select name="rolle">
<?php foreach (saas_grantable_roles() as $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> <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> </select>
<button type="submit">Rolle ändern</button> <button type="submit">Rolle ändern</button>
</form> </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="aktion" value="zugang_entziehen">
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>"> <input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
<?php echo app_csrf_field(); ?> <?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<button type="submit">Zugang entziehen</button> <button type="submit">Zugang entziehen</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<?php else: ?> <?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'] !== ''): ?> <?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="aktion" value="zugang_gewaehren">
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>"> <input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
<?php echo app_csrf_field(); ?> <?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<select name="rolle"> <select name="rolle">
<?php foreach (saas_grantable_roles() as $rolle): ?> <?php foreach (saas_grantable_roles() as $rolle): ?>
<option value="<?php echo saas_html($rolle); ?>"><?php echo saas_html($rollenLabels[$rolle] ?? $rolle); ?></option> <option value="<?php echo saas_html($rolle); ?>"><?php echo saas_html($rollenLabels[$rolle] ?? $rolle); ?></option>
@@ -384,12 +575,13 @@ if($hasAccess){
<?php endif; ?> <?php endif; ?>
</td> </td>
<td> <td>
<ul class="actions"> <ul class="actions compact-actions">
<li> <li>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>"> <form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
<input type="hidden" name="aktion" value="bearbeiten"> <input type="hidden" name="aktion" value="bearbeiten">
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>"> <input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
<?php echo app_csrf_field(); ?> <?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<button type="submit">Bearbeiten</button> <button type="submit">Bearbeiten</button>
</form> </form>
</li> </li>
@@ -399,6 +591,7 @@ if($hasAccess){
<input type="hidden" name="aktion" value="deaktivieren"> <input type="hidden" name="aktion" value="deaktivieren">
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>"> <input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
<?php echo app_csrf_field(); ?> <?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<button type="submit">Deaktivieren</button> <button type="submit">Deaktivieren</button>
</form> </form>
<?php else: ?> <?php else: ?>
@@ -406,6 +599,7 @@ if($hasAccess){
<input type="hidden" name="aktion" value="aktivieren"> <input type="hidden" name="aktion" value="aktivieren">
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>"> <input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
<?php echo app_csrf_field(); ?> <?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<button type="submit">Aktivieren</button> <button type="submit">Aktivieren</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
@@ -416,6 +610,7 @@ if($hasAccess){
<input type="hidden" name="aktion" value="anonymisieren"> <input type="hidden" name="aktion" value="anonymisieren">
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>"> <input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
<?php echo app_csrf_field(); ?> <?php echo app_csrf_field(); ?>
<?php echo mitglieder_filter_fields($filter); ?>
<button type="submit">Anonymisieren</button> <button type="submit">Anonymisieren</button>
</form> </form>
</li> </li>
@@ -424,7 +619,11 @@ if($hasAccess){
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if ($gefilterteMitglieder === []): ?>
<tr><td colspan="6">Keine Mitglieder für diese Filter gefunden.</td></tr>
<?php endif; ?>
</table> </table>
</div>
<?php <?php
}else{ }else{
+106 -13
View File
@@ -3,6 +3,7 @@
include "functions.php"; include "functions.php";
require_once __DIR__ . "/app/ledger.php"; require_once __DIR__ . "/app/ledger.php";
require_once __DIR__ . "/app/paypal-inbox.php"; require_once __DIR__ . "/app/paypal-inbox.php";
require_once __DIR__ . "/app/features.php";
app_require_csrf(); app_require_csrf();
$pdo = app_db_pdo(); $pdo = app_db_pdo();
@@ -15,12 +16,73 @@ if ($saasUser !== null && saas_user_has_role(['owner', 'admin', 'treasurer'], $s
$hasAccess = true; $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; $flash = null;
if ($hasAccess && isset($_SESSION['flash_paypal'])) { if ($hasAccess && isset($_SESSION['flash_paypal'])) {
$flash = $_SESSION['flash_paypal']; $flash = $_SESSION['flash_paypal'];
unset($_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') { if ($hasAccess && $_SERVER['REQUEST_METHOD'] === 'POST') {
$aktion = (string)($_POST['aktion'] ?? ''); $aktion = (string)($_POST['aktion'] ?? '');
$paymentId = (int)($_POST['payment_id'] ?? 0); $paymentId = (int)($_POST['payment_id'] ?? 0);
@@ -45,7 +107,7 @@ if ($hasAccess && $_SERVER['REQUEST_METHOD'] === 'POST') {
// Post-Redirect-Get gegen Doppelbuchung per Refresh. // Post-Redirect-Get gegen Doppelbuchung per Refresh.
$_SESSION['flash_paypal'] = $flash; $_SESSION['flash_paypal'] = $flash;
header('Location: paypal-zuordnung.php'); header('Location: paypal-zuordnung.php' . $returnQuery);
exit; exit;
} }
@@ -58,6 +120,7 @@ if ($hasAccess) {
$offene = paypal_fetch_unmatched($pdo, $tenantId); $offene = paypal_fetch_unmatched($pdo, $tenantId);
$mitglieder = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]); $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 "header.php";
include "headerline.php"; include "headerline.php";
@@ -67,7 +130,9 @@ include "nav.php";
<section id="banner"> <section id="banner">
<div class="content"> <div class="content">
<?php if (!$hasAccess): ?> <?php if ($gesperrteFunktion !== null): ?>
<?php echo app_feature_notice_html($gesperrteFunktion); ?>
<?php elseif (!$hasAccess): ?>
<h2>Kein Zugriff</h2> <h2>Kein Zugriff</h2>
<?php else: ?> <?php else: ?>
@@ -88,27 +153,53 @@ include "nav.php";
<?php endif; ?> <?php endif; ?>
<h3>Offene Zahlungen (<?php echo count($offene); ?>)</h3> <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 === []): ?> <?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: ?> <?php else: ?>
<table> <p><?php echo count($offeneGefiltert); ?> von <?php echo count($offene); ?> offenen Zahlungen angezeigt.</p>
<tr><th>Eingang</th><th>Zahler (PayPal)</th><th>Mitteilung</th><th>Betrag</th><th>Zuordnen</th><th></th></tr> <div class="table-wrapper">
<?php foreach ($offene as $z): ?> <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> <tr>
<td><?php echo saas_html((string)($z['paid_at'] ?? $z['created_at'])); ?></td> <td><?php echo saas_html((string)($z['paid_at'] ?? $z['created_at'])); ?></td>
<td><?php echo saas_html($z['payer_name']); ?></td> <td>
<td><?php echo saas_html((string)($z['note'] ?? '')); ?></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><?php echo saas_html(saas_format_money_cents((int)$z['net_cents'])); ?> €</td>
<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(); ?> <?php echo app_csrf_field(); ?>
<input type="hidden" name="aktion" value="zuordnen"> <input type="hidden" name="aktion" value="zuordnen">
<input type="hidden" name="payment_id" value="<?php echo (int)$z['id']; ?>"> <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> <select name="participant_id" required>
<option value=""> Mitglied wählen </option> <?php echo paypal_select_options($mitglieder, $suggestions, $selectedId); ?>
<?php foreach ($mitglieder as $m): ?>
<option value="<?php echo (int)$m['participant_id']; ?>"><?php echo saas_html($m['display_name']); ?></option>
<?php endforeach; ?>
</select> </select>
<button type="submit">Buchen</button> <button type="submit">Buchen</button>
</form> </form>
@@ -118,12 +209,14 @@ include "nav.php";
<?php echo app_csrf_field(); ?> <?php echo app_csrf_field(); ?>
<input type="hidden" name="aktion" value="ignorieren"> <input type="hidden" name="aktion" value="ignorieren">
<input type="hidden" name="payment_id" value="<?php echo (int)$z['id']; ?>"> <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> <button type="submit" class="alt">Ignorieren</button>
</form> </form>
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</table> </table>
</div>
<?php endif; ?> <?php endif; ?>
<?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 function payment_matching_cleanup(PDO $pdo, array $tenantIds): void
{ {
foreach ($tenantIds as $tenantId) { 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 participants WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]); $pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]);
} }
@@ -139,6 +145,64 @@ try {
$failures, $failures,
$passes $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) { } catch (Throwable $e) {
if ($pdo->inTransaction()) { if ($pdo->inTransaction()) {
$pdo->rollBack(); $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); $settings = saas_fetch_tenant_settings($pdo, $tenantId);
$kostenproStrichAnzeige = $kostenproStrichEingabe !== null $kostenproStrichAnzeige = $kostenproStrichEingabe !== null
? $kostenproStrichEingabe ? $kostenproStrichEingabe
@@ -192,6 +205,7 @@ $kostenproStrichAnzeige = $kostenproStrichEingabe !== null
<?php endif; ?> <?php endif; ?>
<ul class="actions"> <ul class="actions">
<?php if ($zweiseitig): ?>
<li> <li>
<form action="stricheintragen.php" method="get"> <form action="stricheintragen.php" method="get">
<input type="hidden" name="action" value="vorderseite"> <input type="hidden" name="action" value="vorderseite">
@@ -204,6 +218,7 @@ $kostenproStrichAnzeige = $kostenproStrichEingabe !== null
<button type="submit">Rückseite</button> <button type="submit">Rückseite</button>
</form> </form>
</li> </li>
<?php endif; ?>
<li> <li>
<form action="stricheintragen.php" method="get"> <form action="stricheintragen.php" method="get">
<input type="hidden" name="action" value="alle"> <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);