Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d24ee9bf91 | ||
|
|
a93e067e04 |
+81
-133
@@ -2,6 +2,7 @@
|
||||
|
||||
include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$saasUser = saas_current_user($pdo);
|
||||
@@ -16,6 +17,13 @@ $istAdmin = $saasUser !== null && saas_user_has_role(['owner', 'admin'], $saasUs
|
||||
// Einstellungen fuer die Hinweise zu den aktivierten Zahlungswegen.
|
||||
$settings = $saasUser !== null ? saas_fetch_tenant_settings($pdo, (int)$saasUser['tenant_id']) : null;
|
||||
|
||||
// Gleiche Logik wie im Menue: was der Betreiber fuer diesen Mandanten
|
||||
// gesperrt hat, wird hier auch nicht erklaert.
|
||||
$anleitungTenantId = $saasUser !== null ? (int)$saasUser['tenant_id'] : 0;
|
||||
$kann = static function (string $feature) use ($pdo, $anleitungTenantId): bool {
|
||||
return $anleitungTenantId <= 0 || app_feature_enabled($pdo, $anleitungTenantId, $feature);
|
||||
};
|
||||
|
||||
include "header.php";
|
||||
include "headerline.php";
|
||||
include "nav.php";
|
||||
@@ -25,8 +33,8 @@ include "nav.php";
|
||||
<div class="content">
|
||||
|
||||
<h2>Anleitung</h2>
|
||||
<p>Diese Seite erklärt die wichtigsten Funktionen der Kaffeeliste. Oben stehen
|
||||
die Punkte, die jedes Mitglied betreffen, darunter die Verwaltungsfunktionen.</p>
|
||||
<p>Kurzüberblick über die Funktionen. Oben steht, was jedes Mitglied betrifft,
|
||||
darunter die Verwaltung.</p>
|
||||
|
||||
<?php if ($saasUser === null): ?>
|
||||
<div class="hint-box">
|
||||
@@ -40,34 +48,20 @@ include "nav.php";
|
||||
<h2>Für alle Mitglieder</h2>
|
||||
|
||||
<h3>Meine Kaffeeliste</h3>
|
||||
<p>Das ist deine Startseite. Sie zeigt deinen <b>aktuellen Stand</b>, deine
|
||||
Jahresübersicht sowie die letzten Einzahlungen und Striche.</p>
|
||||
<ul>
|
||||
<li><b>Guthaben</b> (positiver Betrag): Du hast mehr eingezahlt, als du verbraucht hast.</li>
|
||||
<li><b>Schulden</b> (negativer Betrag): Es ist Zeit für eine Einzahlung.</li>
|
||||
</ul>
|
||||
<p>In der <b>Monatsübersicht</b> siehst du, wie viele Striche in welchem Monat
|
||||
zusammengekommen sind.</p>
|
||||
<p>Deine Startseite zeigt den aktuellen Stand, die Jahres- und Monatsübersicht sowie
|
||||
die letzten Buchungen. Positiver Betrag = Guthaben, negativer Betrag = offener Betrag.</p>
|
||||
|
||||
<?php if ($kann('self_entry')): ?>
|
||||
<h3>Striche selbst eintragen</h3>
|
||||
<p>Wenn die Verwaltung das freigeschaltet hat, kannst du deinen Kaffee direkt auf
|
||||
der Startseite eintragen – ein oder zwei Striche pro Vorgang.</p>
|
||||
<div class="hint-box">
|
||||
<p><b>Vertippt?</b> Direkt darunter kannst du deinen <b>zuletzt selbst
|
||||
eingetragenen</b> Strich wieder stornieren. Striche, die der Kassenwart über die
|
||||
Sammelerfassung eingetragen hat, kannst du nicht selbst stornieren – wende dich
|
||||
dafür an die Verwaltung.</p>
|
||||
</div>
|
||||
<p>Wenn die Verwaltung das freigeschaltet hat, trägst du deinen Kaffee direkt auf der
|
||||
Startseite ein. Vertippt? Mit „Letzten Strich stornieren" nimmst du deinen jüngsten
|
||||
<b>selbst</b> eingetragenen Strich zurück – vom Kassenwart erfasste Striche nicht.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3>Bezahlen</h3>
|
||||
<p>Welche Wege zur Verfügung stehen, legt die Verwaltung fest. Möglich sind:</p>
|
||||
<ul>
|
||||
<li><b>Barzahlung</b> – beim angegebenen Ansprechpartner.</li>
|
||||
<li><b>PayPal</b> – über die Schaltflächen auf der Startseite.</li>
|
||||
<li><b>Überweisung</b> – auf die angegebene IBAN.</li>
|
||||
</ul>
|
||||
<p>Welche Wege offen stehen, legt die Verwaltung fest: Barzahlung, PayPal oder Überweisung.</p>
|
||||
<?php if ($settings !== null): ?>
|
||||
<p><small>In eurem Mandanten sind aktuell aktiv:
|
||||
<p><small>Bei euch aktiv:
|
||||
<?php
|
||||
$wege = [];
|
||||
if ((int)$settings['cash_enabled'] === 1) { $wege[] = 'Barzahlung'; }
|
||||
@@ -77,148 +71,103 @@ include "nav.php";
|
||||
?>.</small></p>
|
||||
<?php endif; ?>
|
||||
<div class="hint-box">
|
||||
<p><b>Wichtig bei PayPal:</b> Nutzt die Funktion „Geld an Freunde senden“. Bei
|
||||
Zahlungen für „Waren und Dienstleistungen“ zieht PayPal Gebühren ab – gutgeschrieben
|
||||
wird dann nur der Betrag, der tatsächlich angekommen ist.</p>
|
||||
<p><b>Wichtig bei PayPal:</b> „Geld an Freunde senden" nutzen. Bei „Waren und
|
||||
Dienstleistungen" zieht PayPal Gebühren ab – gutgeschrieben wird nur, was ankommt.</p>
|
||||
</div>
|
||||
|
||||
<h3>Anzeigenamen ändern</h3>
|
||||
<p>Unter <b>Namensanpassung</b> kannst du festlegen, wie dein Name auf der
|
||||
Kaffeeliste und im Ausdruck erscheint (maximal 20 Zeichen).</p>
|
||||
|
||||
<h3>Kundenkonto</h3>
|
||||
<p>Unter <b>Kundenkonto</b> findest du deine Zugangsdaten, kannst deine E-Mail-Adresse
|
||||
bestätigen und dein Passwort ändern.</p>
|
||||
<h3>Eigene Angaben</h3>
|
||||
<p>Unter <b>Namensanpassung</b> legst du fest, wie dein Name in der Liste und im Ausdruck
|
||||
erscheint (max. 20 Zeichen). Unter <b>Kundenkonto</b> änderst du Passwort und E-Mail.</p>
|
||||
|
||||
<?php if ($istKassenwart): ?>
|
||||
<hr>
|
||||
|
||||
<h2>Für Kassenwart und Verwaltung</h2>
|
||||
<p><small>Sichtbar für die Rollen <b>Kassenwart</b>, <b>Administrator</b> und
|
||||
<b>Inhaber</b>.</small></p>
|
||||
|
||||
<h3>Einzahlung eintragen</h3>
|
||||
<p>Trage hier ein, wer wie viel eingezahlt hat. Du kannst mehrere Mitglieder in
|
||||
einem Durchgang erfassen; leere Felder werden übersprungen.</p>
|
||||
<h3>Buchen</h3>
|
||||
<ul>
|
||||
<li><b>Bemerkung</b>: optionale Notiz, damit später nachvollziehbar bleibt, worum es ging.</li>
|
||||
<li><b>Negativer Betrag</b> = Abzug (z. B. Auszahlung bei Austritt oder eine
|
||||
Erstattung). Dabei ist eine Bemerkung <b>Pflicht</b>.</li>
|
||||
<li><b>Einzahlung eintragen</b>: mehrere Mitglieder in einem Durchgang; leere Felder
|
||||
werden übersprungen. Negativer Betrag = Abzug, dann ist eine Bemerkung Pflicht.</li>
|
||||
<li><b>Striche eintragen</b>: Sammelerfassung für die Papierliste. Der Preis pro Strich
|
||||
kommt aus den Einstellungen und ist pro Vorgang überschreibbar.</li>
|
||||
<li><b>Letzte Einträge</b>: falsche Buchungen hier <b>stornieren</b>, nicht per
|
||||
Gegenbuchung ausgleichen – sonst zählen beide als Umsatz in der Auswertung.</li>
|
||||
</ul>
|
||||
<div class="hint-box">
|
||||
<p><b>Falsch eingetragen ist kein Abzug.</b> Wenn eine Buchung schlicht falsch war,
|
||||
storniere sie unter <b>Letzte Einträge</b>. Ein Gegen-Abzug würde die Auswertung
|
||||
verfälschen, weil dann beide Buchungen als Umsatz zählen.</p>
|
||||
<p>Zum Schutz vor Vertippern werden Beträge über 1.000 € abgelehnt. Schlägt eine
|
||||
Zeile fehl, wird <b>nichts</b> gebucht und deine Eingaben bleiben stehen.</p>
|
||||
</div>
|
||||
|
||||
<h3>Striche eintragen</h3>
|
||||
<p>Sammelerfassung für die Papierliste. Über die Schaltflächen wählst du:</p>
|
||||
<ul>
|
||||
<li><b>Vorderseite</b>: Vieltrinker – Mitglieder mit mindestens 10 Strichen im Listenfenster.</li>
|
||||
<li><b>Rückseite</b>: alle übrigen aktiven Mitglieder.</li>
|
||||
<li><b>Alle</b>: die komplette Liste.</li>
|
||||
</ul>
|
||||
<p>Der <b>Preis pro Strich</b> wird aus den Mandant-Einstellungen vorbelegt und kann
|
||||
für den einzelnen Vorgang überschrieben werden.</p>
|
||||
|
||||
<h3>Letzte Einträge – stornieren</h3>
|
||||
<p>Zeigt die letzten 100 Einzahlungen und Striche. Fehlerhafte Buchungen werden
|
||||
<b>storniert</b>, nicht gelöscht: Sie verschwinden aus der Auswertung, bleiben aber
|
||||
im Journal nachvollziehbar.</p>
|
||||
|
||||
<?php if ($kann('paypal_inbox')): ?>
|
||||
<h3>PayPal-Zahlungen automatisch verbuchen</h3>
|
||||
<p>Die Kaffeeliste kann PayPal-Zahlungen selbstständig gutschreiben:</p>
|
||||
<ol>
|
||||
<li>Auf der Seite <b>PayPal-Zahlungen</b> steht eure persönliche Eingangsadresse.</li>
|
||||
<li>Richtet in eurem Mailprogramm eine <b>automatische Weiterleitung</b> der
|
||||
PayPal-Benachrichtigungen an diese Adresse ein.</li>
|
||||
<li>Erkannte Zahlungen werden dem passenden Mitglied automatisch gutgeschrieben.</li>
|
||||
</ol>
|
||||
<p>Gutgeschrieben wird immer der Betrag, der <b>tatsächlich angekommen</b> ist – bei
|
||||
Waren & Dienstleistungen also nach Abzug der PayPal-Gebühr.</p>
|
||||
<div class="hint-box">
|
||||
<p>Zahlungen, die sich nicht eindeutig zuordnen lassen, landen in der Warteschlange
|
||||
„Offene Zahlungen“ und können dort von Hand einem Mitglied zugeordnet oder ignoriert
|
||||
werden. Damit die automatische Zuordnung möglichst oft greift, hinterlege den
|
||||
<b>PayPal-Namen</b> beim Mitglied.</p>
|
||||
</div>
|
||||
<p>Auf der Seite <b>PayPal-Zahlungen</b> steht eure Eingangsadresse. Leitet die
|
||||
PayPal-Benachrichtigungen aus eurem Postfach automatisch dorthin weiter, dann werden
|
||||
erkannte Zahlungen selbstständig gutgeschrieben – immer der tatsächlich angekommene
|
||||
Betrag. Nicht eindeutig zuordenbare Zahlungen landen in der Warteschlange „Offene
|
||||
Zahlungen". Ein hinterlegter <b>PayPal-Name</b> beim Mitglied verbessert die Trefferquote.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3>CSV-Import</h3>
|
||||
<p>Über <b>CSV Upload</b> lassen sich Einzahlungen aus einer Datei einlesen. Der Import
|
||||
zeigt zuerst eine <b>Vorschau</b>; erst danach wird gebucht. Zeilen ohne eindeutige
|
||||
Zuordnung werden markiert und nicht verbucht.</p>
|
||||
|
||||
<h3>Kaffeeliste, Ausdruck und Auswertung</h3>
|
||||
<h3>Auswertung und Ausdruck</h3>
|
||||
<ul>
|
||||
<li><b>Kaffeeliste anzeigen</b>: Gesamtübersicht aller aktiven Mitglieder. Ein Klick
|
||||
auf den Namen öffnet die Einzelauswertung.</li>
|
||||
<li><b>PDF-Export</b>: die Liste zum Ausdrucken und Aufhängen.</li>
|
||||
<li><b>Journal-Vorschau</b>: alle Buchungen mit Herkunft – nützlich zur Kontrolle.</li>
|
||||
<li><b>Kaffeeliste anzeigen</b>: Gesamtübersicht; ein Klick auf den Namen öffnet die
|
||||
Einzelauswertung.</li>
|
||||
<?php if ($kann('pdf_export')): ?>
|
||||
<li><b>PDF-Export</b>: die Liste zum Aufhängen. Bis 50 Mitglieder eine Seite, darüber
|
||||
Vorder- und Rückseite.</li>
|
||||
<?php endif; ?>
|
||||
<li><b>Journal-Vorschau</b>: alle Buchungen mit Herkunft.</li>
|
||||
<?php if ($kann('csv_import')): ?>
|
||||
<li><b>CSV Upload</b>: Einzahlungen aus einer Datei einlesen – erst Vorschau, dann
|
||||
buchen. Zeilen ohne eindeutige Zuordnung werden nicht verbucht.</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
|
||||
<h3>Info-Mails versenden</h3>
|
||||
<p>Unter <b>Mailversand</b> lassen sich Mitglieder über ihren Stand informieren. Der
|
||||
<b>Dry-Run</b> zeigt zuerst, wer eine Mail bekäme, ohne etwas zu verschicken. Das
|
||||
Versandlog hält fest, was tatsächlich rausging.</p>
|
||||
|
||||
<h3>Jahresabschluss</h3>
|
||||
<p>Verteilt einen frei wählbaren Gesamtbetrag als Guthaben auf alle Mitglieder –
|
||||
anteilig zu ihren Strichen im laufenden Jahr. Auch hier gilt: erst <b>Dry-Run</b>
|
||||
zur Vorschau, dann buchen. Beim echten Lauf werden die Mitglieder per Mail informiert.</p>
|
||||
<?php if ($kann('mail_dispatch') || $kann('year_closing')): ?>
|
||||
<h3>Mails und Jahresabschluss</h3>
|
||||
<ul>
|
||||
<?php if ($kann('mail_dispatch')): ?>
|
||||
<li><b>Info-Mail versenden</b>: informiert Mitglieder über ihren Stand. Der Dry-Run
|
||||
zeigt vorher, wer eine Mail bekäme.</li>
|
||||
<?php endif; ?>
|
||||
<?php if ($kann('year_closing')): ?>
|
||||
<li><b>Jahresabschluss</b>: verteilt einen frei wählbaren Betrag anteilig zu den
|
||||
Strichen des Jahres als Guthaben. Auch hier erst Dry-Run, dann buchen.</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($istAdmin): ?>
|
||||
<hr>
|
||||
|
||||
<h2>Für Administratoren</h2>
|
||||
<p><small>Sichtbar für die Rollen <b>Administrator</b> und <b>Inhaber</b>.</small></p>
|
||||
|
||||
<h3>Mitglieder verwalten</h3>
|
||||
<ul>
|
||||
<li><b>Anlegen und bearbeiten</b> von Mitgliedern (Name, E-Mail, PayPal-Name).</li>
|
||||
<li><b>PayPal-Name</b>: entscheidend für die automatische Zuordnung von Zahlungen.</li>
|
||||
<li><b>Deaktivieren</b> statt löschen: Das Mitglied verschwindet aus den Listen,
|
||||
die Buchungshistorie bleibt erhalten.</li>
|
||||
<li><b>Anonymisieren</b>: ersetzt Name und E-Mail durch einen Platzhalter (DSGVO-Auskunft),
|
||||
die Buchungen der Kasse bleiben für die Abrechnung bestehen.</li>
|
||||
<li><b>Zugang gewähren / entziehen</b>: steuert, ob ein Mitglied sich anmelden kann.</li>
|
||||
<li><b>CSV-Import</b> für viele Mitglieder auf einmal – eine passende Vorlage gibt es
|
||||
zum Herunterladen.</li>
|
||||
<li><b>Deaktivieren</b> statt löschen: das Mitglied verschwindet aus den Listen, die
|
||||
Buchungshistorie bleibt.</li>
|
||||
<li><b>Anonymisieren</b>: ersetzt Name und E-Mail durch einen Platzhalter, die Buchungen
|
||||
der Kasse bleiben bestehen.</li>
|
||||
<li><b>Zugang gewähren / entziehen</b> steuert, ob sich jemand anmelden kann.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Hinweise verwalten</h3>
|
||||
<p>Ein Hinweis erscheint als gelber Balken oben auf jeder Seite – z. B. „Kaffeekasse
|
||||
ist leer“. Jeder Hinweis hat ein <b>Gültig bis</b>-Datum und verschwindet danach
|
||||
automatisch.</p>
|
||||
<h3>Hinweise</h3>
|
||||
<p>Ein Hinweis erscheint als gelber Balken oben auf jeder Seite und verschwindet nach dem
|
||||
<b>Gültig bis</b>-Datum automatisch.</p>
|
||||
|
||||
<h3>Mandant-Einstellungen</h3>
|
||||
<p>Hier stellst du das Verhalten der Kaffeeliste ein:</p>
|
||||
<ul>
|
||||
<li><b>Preis pro Strich</b> – Grundlage jeder Verbrauchsbuchung.</li>
|
||||
<li><b>Listenfenster in Tagen</b> – Zeitraum für die Vorder-/Rückseiten-Aufteilung.</li>
|
||||
<li><b>Schulden-Warnschwelle</b> – ab welchem Minus gewarnt bzw. erinnert wird.</li>
|
||||
<li><b>Bezahloptionen</b> – Barzahlung, PayPal und Überweisung samt Kontaktdaten.
|
||||
Nur aktivierte Wege erscheinen bei den Mitgliedern.</li>
|
||||
<li><b>Web-Striche erlauben</b> – ob Mitglieder selbst eintragen dürfen.</li>
|
||||
<li><b>Ausdruck</b> – Sortierung, Zeilenhöhe, leere Zeilen, Wasserzeichen und Fußzeile.</li>
|
||||
<li><b>Zahlungserinnerungen</b> – automatische Mails an Mitglieder unterhalb der
|
||||
Warnschwelle, samt Mindestabstand zwischen zwei Erinnerungen.</li>
|
||||
</ul>
|
||||
<p>Im <b>Protokoll</b> unten auf der Seite steht, wer wann welche Änderung vorgenommen hat.</p>
|
||||
<p>Preis pro Strich, Listenfenster, Warnschwelle, Bezahloptionen, Web-Striche, Gestaltung
|
||||
des Ausdrucks<?php echo $kann('branding') ? ', eigenes Design (Logo und Akzentfarbe)' : ''; ?>
|
||||
und Zahlungserinnerungen. Unten steht das Protokoll aller Admin-Änderungen.</p>
|
||||
|
||||
<h3>Konto, Abo und Daten</h3>
|
||||
<ul>
|
||||
<li><b>Kundenkonto</b>: Tarif, Rechnungen und Zahlungsdaten.</li>
|
||||
<li><b>Datenexport</b>: alle Daten des Mandanten als Datei – für ein Backup oder
|
||||
einen Umzug.</li>
|
||||
<li><b>Mandant löschen</b>: entfernt den Mandanten mit allen Daten endgültig.</li>
|
||||
<?php if ($kann('data_export')): ?>
|
||||
<li><b>Datenexport</b>: alle Daten des Mandanten als Datei.</li>
|
||||
<?php endif; ?>
|
||||
<li><b>Mandant löschen</b>: entfernt alles endgültig<?php echo $kann('data_export') ? ' – vorher Datenexport ziehen' : ''; ?>.</li>
|
||||
</ul>
|
||||
<div class="hint-box error">
|
||||
<p><b>Das Löschen eines Mandanten kann nicht rückgängig gemacht werden.</b> Lade dir
|
||||
vorher über den Datenexport eine Kopie herunter.</p>
|
||||
</div>
|
||||
|
||||
<h3>Rollen im Überblick</h3>
|
||||
<table>
|
||||
@@ -231,11 +180,10 @@ include "nav.php";
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($kann('faq')): ?>
|
||||
<hr>
|
||||
|
||||
<h3>Noch Fragen?</h3>
|
||||
<p>Häufige Fragen und die Antworten eurer Verwaltung stehen unter
|
||||
<a href="faq.php">FAQ Kaffeeliste</a>.</p>
|
||||
<p>Weitere Fragen beantwortet eure Verwaltung unter <a href="faq.php">FAQ Kaffeeliste</a>.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/saas-auth.php';
|
||||
require_once __DIR__ . '/features.php';
|
||||
|
||||
/**
|
||||
* Logo und Akzentfarbe des Mandanten, an dem der angemeldete Nutzer haengt.
|
||||
* Ohne Anmeldung, ohne freigeschaltete Funktion „Eigenes Design" oder ohne
|
||||
* hinterlegte Werte kommt ueberall ein leerer String zurueck - der Aufrufer
|
||||
* bleibt dann beim Standarddesign.
|
||||
*
|
||||
* Das Ergebnis wird pro Request gecached: header.php laeuft auf jeder Seite
|
||||
* und soll dafuer nicht mehrfach die Einstellungen laden.
|
||||
*
|
||||
* @return array{color: string, logo_url: string, tenant_name: string}
|
||||
*/
|
||||
function app_branding_for_current_user(PDO $pdo): array
|
||||
{
|
||||
static $cached = null;
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$leer = ['color' => '', 'logo_url' => '', 'tenant_name' => ''];
|
||||
|
||||
try {
|
||||
$user = saas_current_user($pdo);
|
||||
if ($user === null) {
|
||||
return $cached = $leer;
|
||||
}
|
||||
|
||||
$tenantId = (int)$user['tenant_id'];
|
||||
if (!app_feature_enabled($pdo, $tenantId, 'branding')) {
|
||||
return $cached = $leer;
|
||||
}
|
||||
|
||||
$settings = saas_fetch_tenant_settings($pdo, $tenantId);
|
||||
if ($settings === null) {
|
||||
return $cached = $leer;
|
||||
}
|
||||
|
||||
$color = (string)($settings['brand_color'] ?? '');
|
||||
// Zweite Pruefung neben der Formularvalidierung: was hier nicht wie
|
||||
// ein Hex-Wert aussieht, wird nie in ein Stylesheet geschrieben.
|
||||
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
|
||||
$color = '';
|
||||
}
|
||||
|
||||
return $cached = [
|
||||
'color' => strtolower($color),
|
||||
'logo_url' => trim((string)($settings['brand_logo'] ?? '')) !== '' ? 'tenant-logo-anzeigen.php' : '',
|
||||
'tenant_name' => (string)($settings['name'] ?? ''),
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
// Branding ist Beiwerk: faellt es aus (z. B. Migration noch nicht
|
||||
// eingespielt), laedt die Seite trotzdem im Standarddesign.
|
||||
return $cached = $leer;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt die Farbueberschreibungen fuer das Template-Stylesheet. main.css
|
||||
* verwendet durchgaengig genau einen Akzentton (#38761d) - hier werden die
|
||||
* sichtbaren Stellen davon auf die Mandantenfarbe umgestellt.
|
||||
*/
|
||||
function app_branding_css(string $color): string
|
||||
{
|
||||
$c = preg_match('/^#[0-9a-f]{6}$/', strtolower($color)) ? strtolower($color) : '#38761d';
|
||||
|
||||
return <<<CSS
|
||||
:root { --brand-color: {$c}; }
|
||||
a { color: var(--brand-color); }
|
||||
a:hover { border-bottom-color: var(--brand-color); color: var(--brand-color) !important; }
|
||||
header.major > :last-child { border-bottom-color: var(--brand-color); }
|
||||
input[type="text"]:focus, input[type="password"]:focus, input[type="email"]:focus,
|
||||
input[type="tel"]:focus, input[type="search"]:focus, input[type="url"]:focus,
|
||||
input[type="number"]:focus, input[type="date"]:focus, input[type="datetime-local"]:focus,
|
||||
select:focus, textarea:focus,
|
||||
input[type="checkbox"]:focus + label:before, input[type="radio"]:focus + label:before {
|
||||
border-color: var(--brand-color);
|
||||
box-shadow: 0 0 0 1px var(--brand-color);
|
||||
}
|
||||
input[type="checkbox"]:checked + label:before, input[type="radio"]:checked + label:before {
|
||||
background: var(--brand-color);
|
||||
border-color: var(--brand-color);
|
||||
}
|
||||
input[type="submit"], input[type="reset"], input[type="button"], button, .button {
|
||||
box-shadow: inset 0 0 0 2px var(--brand-color);
|
||||
color: var(--brand-color) !important;
|
||||
}
|
||||
input[type="submit"].primary, input[type="reset"].primary, input[type="button"].primary,
|
||||
button.primary, .button.primary {
|
||||
background-color: var(--brand-color);
|
||||
box-shadow: none;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
#header { border-bottom-color: var(--brand-color); }
|
||||
#menu ul a:hover, #menu ul span:hover { color: var(--brand-color); }
|
||||
.tenant-brand-line { border-bottom: solid 1px rgba(0, 0, 0, 0.08); }
|
||||
|
||||
CSS;
|
||||
}
|
||||
+7
-8
@@ -65,18 +65,17 @@ function faq_soft_delete(PDO $pdo, int $tenantId, int $id): bool
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds a small, generic starter FAQ for a brand-new tenant (mirrors the
|
||||
* excerpt already shown on the public landing page), so new customers do
|
||||
* not start with a completely empty FAQ. They can edit or delete every
|
||||
* entry afterwards.
|
||||
* Seeds two example questions for a brand-new tenant, damit die FAQ-Seite
|
||||
* nicht voellig leer startet und sofort erkennbar ist, wie ein Eintrag
|
||||
* aussieht. Bewusst nur zwei Beispiele statt eines fertigen Katalogs: die
|
||||
* Antworten sind Platzhalter, die jeder Mandant ohnehin auf seine eigene
|
||||
* Teekueche umschreibt. Beide Eintraege sind frei editier- und loeschbar.
|
||||
*/
|
||||
function faq_seed_default_entries(PDO $pdo, int $tenantId): void
|
||||
{
|
||||
$starters = [
|
||||
['Wie starten wir mit unserer Kaffeeliste?', 'Mitglieder unter „Mitglieder verwalten" anlegen, danach direkt Striche und Einzahlungen erfassen. Es ist keine weitere Einrichtung nötig.'],
|
||||
['Wer kann Mitglieder verwalten oder Einstellungen ändern?', 'Das hängt von der zugewiesenen Rolle ab: Administrator und Kassenwart können Buchungen vornehmen, nur Administrator und Inhaber können Einstellungen und Zugänge verwalten.'],
|
||||
['Können wir per PayPal bezahlen?', 'Ja, wenn PayPal in den Mandant-Einstellungen aktiviert ist. Einzahlungen lassen sich zusätzlich jederzeit manuell erfassen.'],
|
||||
['Wie erfahre ich meinen aktuellen Stand?', 'Im eigenen Dashboard unter „Meine Kaffeeliste" ist der aktuelle Stand jederzeit sichtbar.'],
|
||||
['Wie starten wir mit unserer Kaffeeliste?', 'Beispielantwort – bitte anpassen: Mitglieder unter „Mitglieder verwalten" anlegen, danach direkt Striche und Einzahlungen erfassen.'],
|
||||
['Wie erfahre ich meinen aktuellen Stand?', 'Beispielantwort – bitte anpassen: Unter „Meine Kaffeeliste" ist der aktuelle Stand jederzeit sichtbar.'],
|
||||
];
|
||||
|
||||
$stmt = $pdo->prepare('INSERT INTO faq_entries (tenant_id, question, answer, sort_order) VALUES (?, ?, ?, ?)');
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
/**
|
||||
* Funktions-Schalter je Mandant, gepflegt vom Betreiber im Back-Office
|
||||
* (backoffice-mandant.php). Sie liegen eine Ebene ueber den
|
||||
* Mandant-Einstellungen: der Kunde stellt dort ein, *wie* eine Funktion
|
||||
* arbeitet - hier entscheidet der Betreiber, ob sie ihm ueberhaupt zur
|
||||
* Verfuegung steht.
|
||||
*
|
||||
* Bewusst kein Bestandteil von saas_user_has_role(): Rollen regeln, wer
|
||||
* innerhalb eines Mandanten etwas darf, Features regeln den Funktionsumfang
|
||||
* des Mandanten als Ganzes. Beide Pruefungen stehen deshalb nebeneinander.
|
||||
*
|
||||
* @return array<string, array{label: string, description: string}>
|
||||
*/
|
||||
function app_feature_catalog(): array
|
||||
{
|
||||
return [
|
||||
'faq' => [
|
||||
'label' => 'FAQ-Seite',
|
||||
'description' => 'Eigene Fragen und Antworten für die Mitglieder des Mandanten.',
|
||||
],
|
||||
'self_entry' => [
|
||||
'label' => 'Striche selbst eintragen',
|
||||
'description' => 'Mitglieder tragen ihre Striche im eigenen Dashboard ein (zusätzlich zur Mandant-Einstellung „Web-Striche erlauben").',
|
||||
],
|
||||
'pdf_export' => [
|
||||
'label' => 'Kaffeeliste als PDF',
|
||||
'description' => 'Ausdruck der Strichliste zum Aufhängen.',
|
||||
],
|
||||
'paypal_inbox' => [
|
||||
'label' => 'PayPal-Zahlungseingang',
|
||||
'description' => 'Automatisches Verbuchen weitergeleiteter PayPal-Mails samt Zuordnungs-Warteschlange.',
|
||||
],
|
||||
'csv_import' => [
|
||||
'label' => 'CSV-Import',
|
||||
'description' => 'Einlesen von Einzahlungen und Mitgliedern aus einer Datei.',
|
||||
],
|
||||
'mail_dispatch' => [
|
||||
'label' => 'Info-Mails und Erinnerungen',
|
||||
'description' => 'Sammelmails an Mitglieder und automatische Zahlungserinnerungen.',
|
||||
],
|
||||
'year_closing' => [
|
||||
'label' => 'Jahresabschluss',
|
||||
'description' => 'Verteilen eines Gesamtbetrags als Guthaben auf alle Mitglieder.',
|
||||
],
|
||||
'data_export' => [
|
||||
'label' => 'Datenexport',
|
||||
'description' => 'Herunterladen aller Mandantendaten durch den Kunden selbst.',
|
||||
],
|
||||
'branding' => [
|
||||
'label' => 'Eigenes Design',
|
||||
'description' => 'Eigenes Logo und eigene Akzentfarbe in der App-Oberfläche des Mandanten.',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard fuer jede Funktion, wenn der Betreiber nichts abweichend gesetzt
|
||||
* hat. Alles ist freigeschaltet - ein neuer Kunde soll den vollen
|
||||
* Funktionsumfang bekommen, ohne dass der Betreiber erst etwas einschalten
|
||||
* muss.
|
||||
*/
|
||||
function app_feature_default(string $featureKey): bool
|
||||
{
|
||||
return array_key_exists($featureKey, app_feature_catalog());
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktueller Stand aller bekannten Funktionen fuer einen Mandanten.
|
||||
*
|
||||
* @return array<string, bool>
|
||||
*/
|
||||
function app_features_for_tenant(PDO $pdo, int $tenantId): array
|
||||
{
|
||||
$state = [];
|
||||
foreach (array_keys(app_feature_catalog()) as $key) {
|
||||
$state[$key] = app_feature_default($key);
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare('SELECT feature_key, enabled FROM tenant_features WHERE tenant_id = ?');
|
||||
$stmt->execute([$tenantId]);
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$key = (string)$row['feature_key'];
|
||||
if (array_key_exists($key, $state)) {
|
||||
$state[$key] = (int)$row['enabled'] === 1;
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Fehlt die Tabelle (Migration noch nicht eingespielt), bleibt es bei
|
||||
// den Standardwerten - eine nicht ausgerollte Migration darf keine
|
||||
// Funktion sperren.
|
||||
return $state;
|
||||
}
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Einzelne Funktion pruefen. Ergebnis wird pro Request zwischengespeichert,
|
||||
* damit Seiten mit mehreren Pruefungen (Navigation!) nicht je Aufruf erneut
|
||||
* die Datenbank befragen.
|
||||
*/
|
||||
function app_feature_enabled(PDO $pdo, int $tenantId, string $featureKey): bool
|
||||
{
|
||||
static $cache = [];
|
||||
|
||||
if ($tenantId <= 0) {
|
||||
return app_feature_default($featureKey);
|
||||
}
|
||||
|
||||
if (!array_key_exists($tenantId, $cache)) {
|
||||
$cache[$tenantId] = app_features_for_tenant($pdo, $tenantId);
|
||||
}
|
||||
|
||||
return $cache[$tenantId][$featureKey] ?? app_feature_default($featureKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Schalter eines Mandanten neu. Erwartet die Formulardaten des
|
||||
* Back-Office-Formulars: jede angehakte Funktion steht als Schluessel in
|
||||
* $input['features'].
|
||||
*
|
||||
* @param array<string, mixed> $submittedFeatures
|
||||
* @return list<string> Liste der geaenderten Funktionsschluessel
|
||||
*/
|
||||
function app_features_update(PDO $pdo, int $tenantId, array $submittedFeatures): array
|
||||
{
|
||||
$before = app_features_for_tenant($pdo, $tenantId);
|
||||
$changed = [];
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO tenant_features (tenant_id, feature_key, enabled)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE enabled = VALUES(enabled)'
|
||||
);
|
||||
|
||||
foreach (array_keys(app_feature_catalog()) as $key) {
|
||||
$enabled = !empty($submittedFeatures[$key]);
|
||||
$stmt->execute([$tenantId, $key, $enabled ? 1 : 0]);
|
||||
if (($before[$key] ?? true) !== $enabled) {
|
||||
$changed[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seitenwaechter: bricht mit einer verstaendlichen Meldung ab, wenn der
|
||||
* Betreiber die Funktion fuer diesen Mandanten abgeschaltet hat. Die Meldung
|
||||
* wird bewusst in die bereits geoeffnete Seite geschrieben, damit sie im
|
||||
* gewohnten Layout erscheint statt als nackter Text.
|
||||
*/
|
||||
function app_feature_notice_html(string $featureKey): string
|
||||
{
|
||||
$label = app_feature_catalog()[$featureKey]['label'] ?? $featureKey;
|
||||
|
||||
return '<div class="hint-box error"><p><b>' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8')
|
||||
. ' ist für euren Zugang nicht freigeschaltet.</b><br>'
|
||||
. 'Wende dich an den Betreiber der Kaffeeliste, wenn ihr diese Funktion nutzen möchtet.</p></div>';
|
||||
}
|
||||
+189
@@ -56,6 +56,99 @@ function imports_find_participant(PDO $pdo, int $tenantId, string $name): ?array
|
||||
];
|
||||
}
|
||||
|
||||
function imports_normalize_match_text(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
$value = preg_replace('/\s+/', ' ', $value) ?? $value;
|
||||
|
||||
return function_exists('mb_strtolower')
|
||||
? mb_strtolower($value, 'UTF-8')
|
||||
: strtolower($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert wahrscheinliche Teilnehmer fuer eine manuelle Zuordnung. Die
|
||||
* automatische Buchung bleibt strenger; diese Vorschlaege sind nur eine Hilfe
|
||||
* fuer Admins/Kassenwarte in CSV- und PayPal-Warteschlangen.
|
||||
*
|
||||
* @return list<array{participant_id:int, display_name:string, email:?string, paypal_name:?string, score:int}>
|
||||
*/
|
||||
function imports_suggest_participants(PDO $pdo, int $tenantId, string $rawName, ?string $note = null, int $limit = 5): array
|
||||
{
|
||||
$haystack = imports_normalize_match_text(trim($rawName . ' ' . (string)$note));
|
||||
if ($haystack === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tokens = array_values(array_unique(array_filter(
|
||||
preg_split('/\s+/', $haystack) ?: [],
|
||||
static fn(string $token): bool => strlen($token) >= 3
|
||||
)));
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id, display_name, email, paypal_name
|
||||
FROM participants
|
||||
WHERE tenant_id = ? AND active = 1
|
||||
ORDER BY display_name'
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
|
||||
$candidates = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$displayName = (string)$row['display_name'];
|
||||
$paypalName = (string)($row['paypal_name'] ?? '');
|
||||
$email = (string)($row['email'] ?? '');
|
||||
$displayNorm = imports_normalize_match_text($displayName);
|
||||
$paypalNorm = imports_normalize_match_text($paypalName);
|
||||
$emailNorm = imports_normalize_match_text($email);
|
||||
$score = 0;
|
||||
|
||||
if ($displayNorm !== '' && $displayNorm === imports_normalize_match_text($rawName)) {
|
||||
$score += 90;
|
||||
}
|
||||
if ($paypalNorm !== '' && $paypalNorm === imports_normalize_match_text($rawName)) {
|
||||
$score += 100;
|
||||
}
|
||||
if ($displayNorm !== '' && str_contains($haystack, $displayNorm)) {
|
||||
$score += 45;
|
||||
}
|
||||
if ($paypalNorm !== '' && str_contains($haystack, $paypalNorm)) {
|
||||
$score += 55;
|
||||
}
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if ($displayNorm !== '' && str_contains($displayNorm, $token)) {
|
||||
$score += 12;
|
||||
}
|
||||
if ($paypalNorm !== '' && str_contains($paypalNorm, $token)) {
|
||||
$score += 16;
|
||||
}
|
||||
if ($emailNorm !== '' && str_contains($emailNorm, $token)) {
|
||||
$score += 6;
|
||||
}
|
||||
}
|
||||
|
||||
if ($score <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidates[] = [
|
||||
'participant_id' => (int)$row['id'],
|
||||
'display_name' => $displayName,
|
||||
'email' => $row['email'] !== null ? (string)$row['email'] : null,
|
||||
'paypal_name' => $row['paypal_name'] !== null ? (string)$row['paypal_name'] : null,
|
||||
'score' => $score,
|
||||
];
|
||||
}
|
||||
|
||||
usort($candidates, static function (array $a, array $b): int {
|
||||
return $b['score'] <=> $a['score']
|
||||
?: strcasecmp((string)$a['display_name'], (string)$b['display_name']);
|
||||
});
|
||||
|
||||
return array_slice($candidates, 0, max(1, $limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* A payment counts as a duplicate if the same participant already has a
|
||||
* non-voided ledger payment of the same amount on the same day.
|
||||
@@ -188,6 +281,102 @@ function imports_fetch_batch(PDO $pdo, int $tenantId, int $batchId): ?array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id:int, original_filename:string, status:string, created_at:string, committed_at:?string, total_rows:int, matched_rows:int, unresolved_rows:int, imported_rows:int, ignored_rows:int}>
|
||||
*/
|
||||
function imports_fetch_recent_batches(PDO $pdo, int $tenantId, int $limit = 10): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT b.id, b.original_filename, b.status, b.created_at, b.committed_at,
|
||||
COUNT(r.id) AS total_rows,
|
||||
SUM(CASE WHEN r.status = 'matched' THEN 1 ELSE 0 END) AS matched_rows,
|
||||
SUM(CASE WHEN r.status IN ('unmatched', 'duplicate', 'invalid') THEN 1 ELSE 0 END) AS unresolved_rows,
|
||||
SUM(CASE WHEN r.status = 'imported' THEN 1 ELSE 0 END) AS imported_rows,
|
||||
SUM(CASE WHEN r.status = 'ignored' THEN 1 ELSE 0 END) AS ignored_rows
|
||||
FROM payment_import_batches b
|
||||
LEFT JOIN payment_import_rows r ON r.batch_id = b.id
|
||||
WHERE b.tenant_id = ?
|
||||
GROUP BY b.id, b.original_filename, b.status, b.created_at, b.committed_at
|
||||
ORDER BY b.created_at DESC, b.id DESC
|
||||
LIMIT " . max(1, min(50, $limit))
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
|
||||
return array_map(static function (array $row): array {
|
||||
return [
|
||||
'id' => (int)$row['id'],
|
||||
'original_filename' => (string)$row['original_filename'],
|
||||
'status' => (string)$row['status'],
|
||||
'created_at' => (string)$row['created_at'],
|
||||
'committed_at' => $row['committed_at'] !== null ? (string)$row['committed_at'] : null,
|
||||
'total_rows' => (int)$row['total_rows'],
|
||||
'matched_rows' => (int)$row['matched_rows'],
|
||||
'unresolved_rows' => (int)$row['unresolved_rows'],
|
||||
'imported_rows' => (int)$row['imported_rows'],
|
||||
'ignored_rows' => (int)$row['ignored_rows'],
|
||||
];
|
||||
}, $stmt->fetchAll());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok:bool, error?:string}
|
||||
*/
|
||||
function imports_assign_row(PDO $pdo, int $tenantId, int $batchId, int $rowId, int $participantId): array
|
||||
{
|
||||
$batch = imports_fetch_batch($pdo, $tenantId, $batchId);
|
||||
if ($batch === null || $batch['batch']['status'] !== 'previewed') {
|
||||
return ['ok' => false, 'error' => 'Dieser Import kann nicht mehr bearbeitet werden.'];
|
||||
}
|
||||
|
||||
$summaries = ledger_fetch_participant_summaries($pdo, $tenantId, ['participant_ids' => [$participantId]]);
|
||||
$participant = $summaries[0] ?? null;
|
||||
if ($participant === null || empty($participant['active'])) {
|
||||
return ['ok' => false, 'error' => 'Das gewählte Mitglied wurde nicht gefunden.'];
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"UPDATE payment_import_rows r
|
||||
JOIN payment_import_batches b ON b.id = r.batch_id
|
||||
SET r.participant_id = ?, r.status = 'matched'
|
||||
WHERE r.id = ?
|
||||
AND r.batch_id = ?
|
||||
AND b.tenant_id = ?
|
||||
AND b.status = 'previewed'
|
||||
AND r.status IN ('unmatched', 'duplicate')"
|
||||
);
|
||||
$stmt->execute([$participantId, $rowId, $batchId, $tenantId]);
|
||||
|
||||
if ($stmt->rowCount() !== 1) {
|
||||
return ['ok' => false, 'error' => 'Diese Zeile kann nicht zugeordnet werden.'];
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok:bool, error?:string}
|
||||
*/
|
||||
function imports_ignore_row(PDO $pdo, int $tenantId, int $batchId, int $rowId): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"UPDATE payment_import_rows r
|
||||
JOIN payment_import_batches b ON b.id = r.batch_id
|
||||
SET r.participant_id = NULL, r.status = 'ignored'
|
||||
WHERE r.id = ?
|
||||
AND r.batch_id = ?
|
||||
AND b.tenant_id = ?
|
||||
AND b.status = 'previewed'
|
||||
AND r.status IN ('unmatched', 'duplicate', 'invalid')"
|
||||
);
|
||||
$stmt->execute([$rowId, $batchId, $tenantId]);
|
||||
|
||||
if ($stmt->rowCount() !== 1) {
|
||||
return ['ok' => false, 'error' => 'Diese Zeile kann nicht ignoriert werden.'];
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits every 'matched' row of a previewed batch straight into the ledger.
|
||||
* Already-committed batches are rejected.
|
||||
|
||||
@@ -523,6 +523,21 @@ function ledger_record_payment(PDO $pdo, int $tenantId, int $participantId, int
|
||||
return (int)$pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bis zu dieser Mitgliederzahl passt die Strichliste auf ein einzelnes Blatt.
|
||||
* Erst darueber wird sie auf Vorder- und Rueckseite aufgeteilt - und nur dann
|
||||
* ergeben auch die Vorder-/Rueckseiten-Ansichten beim Erfassen einen Sinn.
|
||||
*/
|
||||
const LEDGER_SINGLE_PAGE_MAX_MEMBERS = 50;
|
||||
|
||||
/**
|
||||
* Braucht dieser Mandant eine zweiseitige Liste (Vorder- und Rueckseite)?
|
||||
*/
|
||||
function ledger_uses_two_sided_sheet(int $activeMemberCount): bool
|
||||
{
|
||||
return $activeMemberCount > LEDGER_SINGLE_PAGE_MAX_MEMBERS;
|
||||
}
|
||||
|
||||
/**
|
||||
* "100-Tage-Liste" Vorder-/Rueckseiten-Aufteilung: aktive Teilnehmer, deren
|
||||
* summierte Striche im nachlaufenden Fenster die Zehner-Schwelle erreichen
|
||||
|
||||
+16
-4
@@ -286,7 +286,9 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
|
||||
ts.cash_contact,
|
||||
ts.bank_transfer_enabled,
|
||||
ts.bank_account_holder,
|
||||
ts.bank_iban
|
||||
ts.bank_iban,
|
||||
ts.brand_logo,
|
||||
ts.brand_color
|
||||
FROM tenants t
|
||||
LEFT JOIN tenant_settings ts ON ts.tenant_id = t.id
|
||||
WHERE t.id = ?
|
||||
@@ -332,6 +334,8 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
|
||||
'bank_transfer_enabled' => (int)$settings['bank_transfer_enabled'],
|
||||
'bank_account_holder' => (string)$settings['bank_account_holder'],
|
||||
'bank_iban' => (string)$settings['bank_iban'],
|
||||
'brand_logo' => (string)$settings['brand_logo'],
|
||||
'brand_color' => (string)$settings['brand_color'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -353,8 +357,14 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
||||
$pdfWatermarkText = trim((string)($input['pdf_watermark_text'] ?? ''));
|
||||
$pdfFooterText = trim((string)($input['pdf_footer_text'] ?? ''));
|
||||
$paymentReminderIntervalDays = filter_var($input['payment_reminder_interval_days'] ?? null, FILTER_VALIDATE_INT);
|
||||
$brandColor = strtolower(trim((string)($input['brand_color'] ?? '')));
|
||||
|
||||
$errors = [];
|
||||
// Leer = Standarddesign. Sonst ein vollstaendiger Hex-Wert, der ohne
|
||||
// weitere Aufbereitung in das erzeugte Stylesheet geschrieben werden kann.
|
||||
if ($brandColor !== '' && !preg_match('/^#[0-9a-f]{6}$/', $brandColor)) {
|
||||
$errors[] = 'Die Akzentfarbe muss ein Hex-Wert wie #38761d sein.';
|
||||
}
|
||||
if (strlen($tenantName) < 3 || strlen($tenantName) > 255) {
|
||||
$errors[] = 'Der Kundenname muss zwischen 3 und 255 Zeichen lang sein.';
|
||||
}
|
||||
@@ -433,8 +443,8 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
||||
pdf_watermark_text, pdf_footer_text,
|
||||
payment_reminder_enabled, payment_reminder_interval_days,
|
||||
cash_enabled, cash_contact, bank_transfer_enabled,
|
||||
bank_account_holder, bank_iban)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
bank_account_holder, bank_iban, brand_color)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
mark_price_cents = VALUES(mark_price_cents),
|
||||
self_entry_enabled = VALUES(self_entry_enabled),
|
||||
@@ -453,7 +463,8 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
||||
cash_contact = VALUES(cash_contact),
|
||||
bank_transfer_enabled = VALUES(bank_transfer_enabled),
|
||||
bank_account_holder = VALUES(bank_account_holder),
|
||||
bank_iban = VALUES(bank_iban)'
|
||||
bank_iban = VALUES(bank_iban),
|
||||
brand_color = VALUES(brand_color)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$tenantId,
|
||||
@@ -475,6 +486,7 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
||||
$bankTransferEnabled,
|
||||
$bankAccountHolder,
|
||||
$bankIban,
|
||||
$brandColor,
|
||||
]);
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
+16
-4
@@ -28,14 +28,25 @@ function tenant_logo_path(string $filename): ?string
|
||||
return is_file($path) ? $path : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dateiname-Praefix je Verwendungszweck. Das Wasserzeichen behaelt bewusst
|
||||
* seinen historischen Praefix "logo_", damit bereits hochgeladene Dateien
|
||||
* weiter gefunden werden.
|
||||
*/
|
||||
function tenant_logo_prefix(string $kind): string
|
||||
{
|
||||
return $kind === 'brand' ? 'brand_' : 'logo_';
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt ein hochgeladenes Logo (PNG oder JPEG) entgegen, prueft und speichert
|
||||
* es unter einem festen Namen pro Mandant.
|
||||
* es unter einem festen Namen pro Mandant und Verwendungszweck.
|
||||
*
|
||||
* @param array $file Eintrag aus $_FILES
|
||||
* @param string $kind 'watermark' (Ausdruck) oder 'brand' (App-Oberflaeche)
|
||||
* @return array{ok: bool, filename?: string, error?: string}
|
||||
*/
|
||||
function tenant_logo_store(int $tenantId, array $file): array
|
||||
function tenant_logo_store(int $tenantId, array $file, string $kind = 'watermark'): array
|
||||
{
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
return ['ok' => false, 'error' => 'Das Logo konnte nicht hochgeladen werden.'];
|
||||
@@ -63,14 +74,15 @@ function tenant_logo_store(int $tenantId, array $file): array
|
||||
}
|
||||
|
||||
// Alte Varianten (andere Endung) desselben Mandanten entfernen.
|
||||
$prefix = tenant_logo_prefix($kind);
|
||||
foreach (['png', 'jpg'] as $altExt) {
|
||||
$old = $dir . '/logo_' . $tenantId . '.' . $altExt;
|
||||
$old = $dir . '/' . $prefix . $tenantId . '.' . $altExt;
|
||||
if (is_file($old)) {
|
||||
@unlink($old);
|
||||
}
|
||||
}
|
||||
|
||||
$filename = 'logo_' . $tenantId . '.' . $ext;
|
||||
$filename = $prefix . $tenantId . '.' . $ext;
|
||||
if (!move_uploaded_file($tmp, $dir . '/' . $filename)) {
|
||||
return ['ok' => false, 'error' => 'Das Logo konnte nicht gespeichert werden.'];
|
||||
}
|
||||
|
||||
@@ -129,6 +129,25 @@ div.hint-box {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Kopfzeile mit Mandanten-Logo (headerline.php)
|
||||
*
|
||||
* Das Logo soll erkennbar, aber kein Blickfang sein - die Kaffeeliste bleibt
|
||||
* eine Arbeitsoberflaeche. Feste Maximalhoehe, damit ein hochkantes oder sehr
|
||||
* grosses Logo nicht die halbe Seite einnimmt.
|
||||
* ------------------------------------------------------------------------ */
|
||||
.tenant-brand-line {
|
||||
margin-bottom: 1.5em;
|
||||
padding-bottom: 1em;
|
||||
}
|
||||
|
||||
.tenant-brand-logo {
|
||||
display: block;
|
||||
max-height: 60px;
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Hervorgehobene Tabellenzeile (aktuell gebuchter Tarif)
|
||||
*
|
||||
|
||||
@@ -2293,3 +2293,145 @@ button,
|
||||
border-left: 5px solid #4caf50;
|
||||
color: #1b5e20;
|
||||
}
|
||||
|
||||
.hint-box.error {
|
||||
background: #fdeaea;
|
||||
border-left: 5px solid #c0392b;
|
||||
color: #641e16;
|
||||
}
|
||||
|
||||
input[type="submit"].alt,
|
||||
input[type="reset"].alt,
|
||||
input[type="button"].alt,
|
||||
button.alt,
|
||||
.button.alt {
|
||||
box-shadow: inset 0 0 0 2px #9fa3a6;
|
||||
color: #5f676d !important;
|
||||
}
|
||||
|
||||
.admin-summary-grid {
|
||||
display: grid;
|
||||
gap: 0.75em;
|
||||
grid-template-columns: repeat(auto-fit, minmax(8.5em, 1fr));
|
||||
margin: 1.25em 0 1.75em;
|
||||
}
|
||||
|
||||
.admin-summary-grid > div {
|
||||
border: solid 1px rgba(210, 215, 217, 0.85);
|
||||
border-radius: 6px;
|
||||
padding: 0.9em 1em;
|
||||
}
|
||||
|
||||
.admin-summary-grid strong {
|
||||
color: #38761d;
|
||||
display: block;
|
||||
font-family: "Roboto Slab", serif;
|
||||
font-size: 1.45em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.admin-summary-grid span {
|
||||
color: #7f888f;
|
||||
display: block;
|
||||
font-size: 0.85em;
|
||||
margin-top: 0.2em;
|
||||
}
|
||||
|
||||
.admin-filter-bar {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75em;
|
||||
margin: 1.25em 0;
|
||||
}
|
||||
|
||||
.admin-filter-bar label {
|
||||
font-size: 0.85em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-filter-bar input[type="search"],
|
||||
.admin-filter-bar select {
|
||||
min-width: 11em;
|
||||
}
|
||||
|
||||
.admin-table td,
|
||||
.admin-table th {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
font-size: 0.78em;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
margin: 0 0.25em 0.25em 0;
|
||||
padding: 0.2em 0.55em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-badge.success {
|
||||
background: #e8f5e9;
|
||||
color: #1b5e20;
|
||||
}
|
||||
|
||||
.status-badge.warning {
|
||||
background: #fff4e5;
|
||||
color: #663c00;
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background: #fdeaea;
|
||||
color: #641e16;
|
||||
}
|
||||
|
||||
.status-badge.info {
|
||||
background: #e8f4ff;
|
||||
color: #0d3c61;
|
||||
}
|
||||
|
||||
.status-badge.muted {
|
||||
background: #edf0f2;
|
||||
color: #5f676d;
|
||||
}
|
||||
|
||||
.inline-admin-form {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5em;
|
||||
margin: 0 0 0.5em 0;
|
||||
}
|
||||
|
||||
.inline-admin-form select {
|
||||
min-width: 13em;
|
||||
}
|
||||
|
||||
.compact-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5em;
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.compact-actions li {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 736px) {
|
||||
.admin-filter-bar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-filter-bar input[type="search"],
|
||||
.admin-filter-bar select,
|
||||
.admin-filter-bar button,
|
||||
.admin-filter-bar .button,
|
||||
.inline-admin-form select,
|
||||
.inline-admin-form button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
+55
-1
@@ -2,16 +2,35 @@
|
||||
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/platform-admin.php';
|
||||
require_once __DIR__ . '/app/features.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$user = app_require_platform_admin($pdo);
|
||||
|
||||
$tenantId = filter_input(INPUT_GET, 'tenant_id', FILTER_VALIDATE_INT);
|
||||
// Die Mandanten-ID kommt beim Speichern der Funktions-Schalter aus dem
|
||||
// Formular, sonst aus der URL.
|
||||
$tenantId = filter_input(INPUT_POST, 'tenant_id', FILTER_VALIDATE_INT)
|
||||
?: filter_input(INPUT_GET, 'tenant_id', FILTER_VALIDATE_INT);
|
||||
if ($tenantId === null || $tenantId === false || $tenantId <= 0) {
|
||||
http_response_code(400);
|
||||
exit('Ungültige Mandanten-ID.');
|
||||
}
|
||||
|
||||
$featureMeldung = null;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['aktion'] ?? '') === 'features_speichern') {
|
||||
app_require_csrf();
|
||||
|
||||
$geaendert = app_features_update($pdo, $tenantId, (array)($_POST['features'] ?? []));
|
||||
if ($geaendert !== []) {
|
||||
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'backoffice.features_updated', 'tenant', $tenantId, [
|
||||
'changed' => $geaendert,
|
||||
]);
|
||||
}
|
||||
$featureMeldung = $geaendert === []
|
||||
? 'Keine Änderung an den Funktionen.'
|
||||
: 'Die Funktionen wurden gespeichert.';
|
||||
}
|
||||
|
||||
$detail = app_backoffice_fetch_tenant_detail($pdo, $tenantId);
|
||||
if ($detail === null) {
|
||||
http_response_code(404);
|
||||
@@ -23,6 +42,8 @@ if ($detail === null) {
|
||||
// nachvollziehbar bleibt (siehe AVV-Transparenzpflicht).
|
||||
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'backoffice.tenant_viewed', 'tenant', $tenantId);
|
||||
|
||||
$tenantFeatures = app_features_for_tenant($pdo, $tenantId);
|
||||
|
||||
$rollenLabels = ['owner' => 'Inhaber', 'admin' => 'Administrator', 'treasurer' => 'Kassenwart', 'member' => 'Mitglied', 'viewer' => 'Betrachter'];
|
||||
|
||||
include 'header.php';
|
||||
@@ -51,6 +72,39 @@ include 'nav.php';
|
||||
<button type="submit">Datenexport dieses Mandanten herunterladen</button>
|
||||
</form>
|
||||
|
||||
<h3>Freigeschaltete Funktionen</h3>
|
||||
<p>Zentral vom Betreiber gesteuert: abgehakte Funktionen sind für diesen Mandanten
|
||||
gesperrt und verschwinden dort aus Menü und Seiten. Die Einstellungen des Kunden
|
||||
bleiben dabei erhalten.</p>
|
||||
|
||||
<?php if ($featureMeldung !== null): ?>
|
||||
<div class="hint-box success"><p><?php echo saas_html($featureMeldung); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="backoffice-mandant.php?tenant_id=<?php echo (int)$tenantId; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="features_speichern">
|
||||
<input type="hidden" name="tenant_id" value="<?php echo (int)$tenantId; ?>">
|
||||
<div class="row">
|
||||
<?php foreach (app_feature_catalog() as $featureKey => $feature): ?>
|
||||
<div class="col-6 col-12-small">
|
||||
<input type="checkbox"
|
||||
id="feature_<?php echo saas_html($featureKey); ?>"
|
||||
name="features[<?php echo saas_html($featureKey); ?>]"
|
||||
value="1"
|
||||
<?php echo ($tenantFeatures[$featureKey] ?? true) ? 'checked' : ''; ?>>
|
||||
<label for="feature_<?php echo saas_html($featureKey); ?>">
|
||||
<?php echo saas_html($feature['label']); ?>
|
||||
</label>
|
||||
<small><?php echo saas_html($feature['description']); ?></small>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<ul class="actions">
|
||||
<li><button type="submit">Funktionen speichern</button></li>
|
||||
</ul>
|
||||
</form>
|
||||
|
||||
<h3>Mitglieder mit Zugang</h3>
|
||||
<table>
|
||||
<tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th>Status</th></tr>
|
||||
|
||||
+199
-5
@@ -1,9 +1,12 @@
|
||||
<?php
|
||||
|
||||
ob_start();
|
||||
|
||||
include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/imports.php";
|
||||
require_once __DIR__ . "/app/audit.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
app_require_csrf();
|
||||
include "header.php";
|
||||
include "headerline.php";
|
||||
@@ -34,6 +37,12 @@ if (!$hasAccess) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!app_feature_enabled($pdo, $tenantId, 'csv_import')) {
|
||||
echo app_feature_notice_html('csv_import');
|
||||
include "footer.php";
|
||||
exit;
|
||||
}
|
||||
|
||||
function csv_upload_dir(): string
|
||||
{
|
||||
return APP_ROOT . '/var/uploads';
|
||||
@@ -103,22 +112,88 @@ function csv_status_label(string $status): string
|
||||
'unmatched' => 'Mitglied nicht gefunden',
|
||||
'invalid' => 'Ungültige Zeile',
|
||||
'imported' => 'Importiert',
|
||||
'ignored' => 'Ignoriert',
|
||||
default => $status,
|
||||
};
|
||||
}
|
||||
|
||||
function csv_status_class(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'matched', 'imported' => 'success',
|
||||
'duplicate' => 'warning',
|
||||
'unmatched', 'invalid' => 'error',
|
||||
'ignored' => 'muted',
|
||||
default => 'muted',
|
||||
};
|
||||
}
|
||||
|
||||
function csv_redirect_to_batch(?int $batchId = null): void
|
||||
{
|
||||
$target = 'csvupload.php';
|
||||
if ($batchId !== null && $batchId > 0) {
|
||||
$target .= '?batch_id=' . urlencode((string)$batchId);
|
||||
}
|
||||
header('Location: ' . $target);
|
||||
exit;
|
||||
}
|
||||
|
||||
function csv_select_options(array $members, array $suggestions = [], int $selectedId = 0): string
|
||||
{
|
||||
$suggestedIds = [];
|
||||
foreach ($suggestions as $suggestion) {
|
||||
$suggestedIds[(int)$suggestion['participant_id']] = true;
|
||||
}
|
||||
|
||||
$html = '<option value="">- Mitglied wählen -</option>';
|
||||
if ($suggestions !== []) {
|
||||
$html .= '<optgroup label="Vorschläge">';
|
||||
foreach ($suggestions as $suggestion) {
|
||||
$id = (int)$suggestion['participant_id'];
|
||||
$html .= '<option value="' . $id . '"' . ($selectedId === $id ? ' selected' : '') . '>'
|
||||
. saas_html((string)$suggestion['display_name'])
|
||||
. '</option>';
|
||||
}
|
||||
$html .= '</optgroup>';
|
||||
}
|
||||
|
||||
$html .= '<optgroup label="Alle aktiven Mitglieder">';
|
||||
foreach ($members as $member) {
|
||||
$id = (int)$member['participant_id'];
|
||||
if (isset($suggestedIds[$id])) {
|
||||
continue;
|
||||
}
|
||||
$html .= '<option value="' . $id . '"' . ($selectedId === $id ? ' selected' : '') . '>'
|
||||
. saas_html((string)$member['display_name'])
|
||||
. '</option>';
|
||||
}
|
||||
$html .= '</optgroup>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
$meldung = null;
|
||||
$fehler = null;
|
||||
$vorschauBatchId = null;
|
||||
$vorschauBatchId = isset($_GET['batch_id']) ? (int)$_GET['batch_id'] : null;
|
||||
|
||||
if (isset($_SESSION['flash_csvupload']) && is_array($_SESSION['flash_csvupload'])) {
|
||||
$flash = $_SESSION['flash_csvupload'];
|
||||
unset($_SESSION['flash_csvupload']);
|
||||
$meldung = isset($flash['meldung']) ? (string)$flash['meldung'] : null;
|
||||
$fehler = isset($flash['fehler']) ? (string)$flash['fehler'] : null;
|
||||
}
|
||||
|
||||
if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
$aktion = $_POST['aktion'] ?? 'hochladen';
|
||||
$vorschauBatchId = (int)($_POST['batch_id'] ?? 0);
|
||||
|
||||
if ($aktion === 'hochladen' && isset($_FILES["csv_file"])) {
|
||||
[$csvFile, $uploadError] = csv_prepare_upload($_FILES["csv_file"]);
|
||||
|
||||
if ($uploadError !== null) {
|
||||
$fehler = $uploadError;
|
||||
$_SESSION['flash_csvupload'] = ['fehler' => $fehler];
|
||||
csv_redirect_to_batch();
|
||||
} else {
|
||||
try {
|
||||
$checksum = hash_file('sha256', $csvFile);
|
||||
@@ -160,6 +235,7 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
|
||||
$pdo->commit();
|
||||
$vorschauBatchId = $batchId;
|
||||
$meldung = 'CSV-Datei wurde eingelesen. Bitte die Vorschau prüfen.';
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
@@ -168,9 +244,43 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
} finally {
|
||||
@unlink($csvFile);
|
||||
}
|
||||
|
||||
$_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler];
|
||||
csv_redirect_to_batch($vorschauBatchId);
|
||||
}
|
||||
} elseif ($aktion === 'zeile_zuordnen') {
|
||||
$rowId = (int)($_POST['row_id'] ?? 0);
|
||||
$participantId = (int)($_POST['participant_id'] ?? 0);
|
||||
if ($vorschauBatchId <= 0 || $rowId <= 0 || $participantId <= 0) {
|
||||
$fehler = 'Bitte eine Importzeile und ein Mitglied auswählen.';
|
||||
} else {
|
||||
$result = imports_assign_row($pdo, $tenantId, $vorschauBatchId, $rowId, $participantId);
|
||||
if ($result['ok']) {
|
||||
$meldung = 'Importzeile wurde zugeordnet.';
|
||||
} else {
|
||||
$fehler = $result['error'] ?? 'Die Importzeile konnte nicht zugeordnet werden.';
|
||||
}
|
||||
}
|
||||
|
||||
$_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler];
|
||||
csv_redirect_to_batch($vorschauBatchId);
|
||||
} elseif ($aktion === 'zeile_ignorieren') {
|
||||
$rowId = (int)($_POST['row_id'] ?? 0);
|
||||
if ($vorschauBatchId <= 0 || $rowId <= 0) {
|
||||
$fehler = 'Bitte eine Importzeile auswählen.';
|
||||
} else {
|
||||
$result = imports_ignore_row($pdo, $tenantId, $vorschauBatchId, $rowId);
|
||||
if ($result['ok']) {
|
||||
$meldung = 'Importzeile wurde ignoriert.';
|
||||
} else {
|
||||
$fehler = $result['error'] ?? 'Die Importzeile konnte nicht ignoriert werden.';
|
||||
}
|
||||
}
|
||||
|
||||
$_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler];
|
||||
csv_redirect_to_batch($vorschauBatchId);
|
||||
} elseif ($aktion === 'importieren') {
|
||||
$batchId = (int)($_POST['batch_id'] ?? 0);
|
||||
$batchId = $vorschauBatchId;
|
||||
try {
|
||||
$ergebnis = imports_commit_batch($pdo, $tenantId, $batchId);
|
||||
app_audit_log($pdo, $tenantId, $saasUser['user_id'] ?? null, 'csv_import.committed', 'payment_import_batch', $batchId, ['imported' => $ergebnis['imported']]);
|
||||
@@ -179,10 +289,22 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
$fehler = $e->getMessage();
|
||||
$vorschauBatchId = $batchId;
|
||||
}
|
||||
|
||||
$_SESSION['flash_csvupload'] = ['meldung' => $meldung, 'fehler' => $fehler];
|
||||
csv_redirect_to_batch($vorschauBatchId);
|
||||
}
|
||||
}
|
||||
|
||||
$vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vorschauBatchId) : null;
|
||||
$letzteBatches = imports_fetch_recent_batches($pdo, $tenantId, 8);
|
||||
$mitglieder = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]);
|
||||
$vorschauStatusZaehler = [];
|
||||
if ($vorschau !== null) {
|
||||
foreach ($vorschau['rows'] as $row) {
|
||||
$status = (string)$row['status'];
|
||||
$vorschauStatusZaehler[$status] = ($vorschauStatusZaehler[$status] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
@@ -197,9 +319,15 @@ $vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vo
|
||||
|
||||
<?php if ($vorschau !== null): ?>
|
||||
<h3>Vorschau: <?php echo saas_html($vorschau['batch']['original_filename']); ?></h3>
|
||||
<p>Status: <?php echo saas_html($vorschau['batch']['status']); ?></p>
|
||||
<p>Status: <?php echo saas_html($vorschau['batch']['status']); ?> ·
|
||||
<?php echo (int)($vorschauStatusZaehler['matched'] ?? 0); ?> bereit ·
|
||||
<?php echo (int)($vorschauStatusZaehler['unmatched'] ?? 0); ?> ohne Zuordnung ·
|
||||
<?php echo (int)($vorschauStatusZaehler['duplicate'] ?? 0); ?> Duplikatverdacht ·
|
||||
<?php echo (int)($vorschauStatusZaehler['invalid'] ?? 0); ?> ungültig
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<div class="table-wrapper">
|
||||
<table class="admin-table">
|
||||
<tr>
|
||||
<th>Zeile</th>
|
||||
<th>Name (CSV)</th>
|
||||
@@ -207,18 +335,58 @@ $vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vo
|
||||
<th>Betrag</th>
|
||||
<th>Datum</th>
|
||||
<th>Status</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
<?php foreach ($vorschau['rows'] as $row): ?>
|
||||
<?php
|
||||
$rowStatus = (string)$row['status'];
|
||||
$suggestions = in_array($rowStatus, ['unmatched', 'duplicate'], true)
|
||||
? imports_suggest_participants($pdo, $tenantId, (string)$row['raw_name'], null, 4)
|
||||
: [];
|
||||
$selectedId = count($suggestions) === 1 ? (int)$suggestions[0]['participant_id'] : 0;
|
||||
?>
|
||||
<tr>
|
||||
<td><?php echo (int)$row['row_num']; ?></td>
|
||||
<td><?php echo saas_html($row['raw_name']); ?></td>
|
||||
<td><?php echo saas_html($row['display_name'] ?? '—'); ?></td>
|
||||
<td><?php echo saas_html(number_format(((int)$row['amount_cents']) / 100, 2, ',', '.')); ?> €</td>
|
||||
<td><?php echo saas_html($row['booked_at']); ?></td>
|
||||
<td><?php echo saas_html(csv_status_label($row['status'])); ?></td>
|
||||
<td><span class="status-badge <?php echo saas_html(csv_status_class($rowStatus)); ?>"><?php echo saas_html(csv_status_label($rowStatus)); ?></span></td>
|
||||
<td>
|
||||
<?php if ($vorschau['batch']['status'] === 'previewed' && in_array($rowStatus, ['unmatched', 'duplicate'], true)): ?>
|
||||
<form method="post" action="csvupload.php" class="inline-admin-form"<?php echo $rowStatus === 'duplicate' ? ' onsubmit="return confirm(\'Diese Zeile wurde als Duplikat erkannt. Trotzdem als neue Einzahlung buchen?\');"' : ''; ?>>
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="zeile_zuordnen">
|
||||
<input type="hidden" name="batch_id" value="<?php echo (int)$vorschau['batch']['id']; ?>">
|
||||
<input type="hidden" name="row_id" value="<?php echo (int)$row['id']; ?>">
|
||||
<select name="participant_id" required>
|
||||
<?php echo csv_select_options($mitglieder, $suggestions, $selectedId); ?>
|
||||
</select>
|
||||
<button type="submit">Zuordnen</button>
|
||||
</form>
|
||||
<form method="post" action="csvupload.php" class="inline-admin-form" onsubmit="return confirm('Diese Importzeile ignorieren?');">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="zeile_ignorieren">
|
||||
<input type="hidden" name="batch_id" value="<?php echo (int)$vorschau['batch']['id']; ?>">
|
||||
<input type="hidden" name="row_id" value="<?php echo (int)$row['id']; ?>">
|
||||
<button type="submit" class="alt">Ignorieren</button>
|
||||
</form>
|
||||
<?php elseif ($vorschau['batch']['status'] === 'previewed' && $rowStatus === 'invalid'): ?>
|
||||
<form method="post" action="csvupload.php" class="inline-admin-form" onsubmit="return confirm('Diese ungültige Importzeile ignorieren?');">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="zeile_ignorieren">
|
||||
<input type="hidden" name="batch_id" value="<?php echo (int)$vorschau['batch']['id']; ?>">
|
||||
<input type="hidden" name="row_id" value="<?php echo (int)$row['id']; ?>">
|
||||
<button type="submit" class="alt">Ignorieren</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
—
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<?php if ($vorschau['batch']['status'] === 'previewed'): ?>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
|
||||
@@ -231,6 +399,32 @@ $vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vo
|
||||
<br>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($letzteBatches !== []): ?>
|
||||
<h3>Letzte Importvorschauen</h3>
|
||||
<div class="table-wrapper">
|
||||
<table class="admin-table">
|
||||
<tr>
|
||||
<th>Datei</th>
|
||||
<th>Status</th>
|
||||
<th>Zeilen</th>
|
||||
<th>Bereit</th>
|
||||
<th>Offen</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<?php foreach ($letzteBatches as $batch): ?>
|
||||
<tr>
|
||||
<td><?php echo saas_html($batch['original_filename']); ?><br><small><?php echo saas_html($batch['created_at']); ?></small></td>
|
||||
<td><?php echo saas_html($batch['status']); ?></td>
|
||||
<td><?php echo (int)$batch['total_rows']; ?></td>
|
||||
<td><?php echo (int)$batch['matched_rows'] + (int)$batch['imported_rows']; ?></td>
|
||||
<td><?php echo (int)$batch['unresolved_rows']; ?></td>
|
||||
<td><a class="button small" href="csvupload.php?batch_id=<?php echo (int)$batch['id']; ?>">Öffnen</a></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form action="csvupload.php" method="post" enctype="multipart/form-data">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="hochladen">
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Funktions-Schalter je Mandant, gepflegt vom Betreiber im Back-Office.
|
||||
--
|
||||
-- Bewusst getrennt von tenant_settings: dort stellt der Kunde selbst ein, wie
|
||||
-- seine Kaffeeliste arbeitet. Hier entscheidet der Betreiber, welche
|
||||
-- Funktionen dem Mandanten ueberhaupt zur Verfuegung stehen (z. B. zum
|
||||
-- Abschalten einer Funktion, die bei einem Kunden Probleme macht).
|
||||
--
|
||||
-- Ohne Zeile gilt eine Funktion als aktiv (siehe app/features.php); die
|
||||
-- Tabelle haelt also nur die Abweichungen vom Standard.
|
||||
CREATE TABLE IF NOT EXISTS tenant_features (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INT NOT NULL,
|
||||
feature_key VARCHAR(64) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_tenant_feature (tenant_id, feature_key),
|
||||
CONSTRAINT fk_tenant_features_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Eigenes Design je Mandant: Logo und Akzentfarbe fuer die App-Oberflaeche.
|
||||
--
|
||||
-- Getrennt von pdf_watermark_logo: das Wasserzeichen ist ein blasses
|
||||
-- Hintergrundbild fuer den Ausdruck, das Marken-Logo dagegen wird in der
|
||||
-- Kopfzeile jeder Seite scharf angezeigt. Leere Werte = Standarddesign.
|
||||
ALTER TABLE tenant_settings
|
||||
ADD COLUMN brand_logo VARCHAR(255) NOT NULL DEFAULT '' AFTER bank_iban,
|
||||
ADD COLUMN brand_color VARCHAR(7) NOT NULL DEFAULT '' AFTER brand_logo;
|
||||
@@ -3,6 +3,7 @@
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/data-export.php';
|
||||
require_once __DIR__ . '/app/audit.php';
|
||||
require_once __DIR__ . '/app/features.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$user = saas_require_login();
|
||||
@@ -12,6 +13,11 @@ if (!saas_can_manage_tenant_settings($user)) {
|
||||
exit('Kein Zugriff.');
|
||||
}
|
||||
|
||||
if (!app_feature_enabled($pdo, (int)$user['tenant_id'], 'data_export')) {
|
||||
http_response_code(403);
|
||||
exit('Der Datenexport ist für euren Zugang nicht freigeschaltet.');
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
app_require_csrf();
|
||||
|
||||
|
||||
@@ -29,8 +29,13 @@ dort ist ab zwei Mitgliedschaften auch „Mandant wechseln" ergänzt.
|
||||
|
||||
- ✅ Adaptives Layout nach Mitgliederanzahl (Zeilenhöhe 16–40px je nach
|
||||
Mitgliederzahl).
|
||||
- ✅ Vorder-/Rückseite bzw. mehrere Seiten erst ab 50 Mitgliedern
|
||||
(`EXPORT_MULTI_PAGE_THRESHOLD`), darunter eine einseitige Liste.
|
||||
- ✅ Vorder-/Rückseite bzw. mehrere Seiten erst bei **mehr als** 50
|
||||
Mitgliedern, bis einschließlich 50 eine einseitige Liste. Die Schwelle
|
||||
liegt seit 2026-08-17 als `LEDGER_SINGLE_PAGE_MAX_MEMBERS` /
|
||||
`ledger_uses_two_sided_sheet()` in `app/ledger.php` und gilt damit
|
||||
einheitlich für den Ausdruck **und** die Vorder-/Rückseiten-Auswahl beim
|
||||
Erfassen (`stricheintragen.php`, `einzahlung.php`) – dort erschienen die
|
||||
Buttons vorher auch bei Kleinstteams, für die es nur ein Blatt gibt.
|
||||
- ✅ Admin-Einstellung „Freie Zeilen für neue Mitglieder"
|
||||
(`tenant_settings.pdf_show_empty_rows`, Formular in
|
||||
`mandant-einstellungen.php`).
|
||||
|
||||
+65
-5
@@ -54,15 +54,75 @@ demselben Grund wie beim Setzen).
|
||||
|
||||
- `backoffice.php`: Übersicht aller Mandanten mit Kürzel, Status,
|
||||
Erstelldatum, Teilnehmerzahl (aktiv/gesamt) und Saldensumme.
|
||||
- `backoffice-mandant.php?tenant_id=X`: reine Leseansicht eines einzelnen
|
||||
Mandanten – Einstellungen, Mitglieder mit Rolle, letzte 20 Buchungen,
|
||||
letzte 20 Admin-Aktionen. Absichtlich **kein** Schreibzugriff auf
|
||||
Mandantendaten von hier aus, um das Risiko einer versehentlichen
|
||||
Fremdänderung auszuschließen.
|
||||
- `backoffice-mandant.php?tenant_id=X`: Ansicht eines einzelnen Mandanten
|
||||
– Einstellungen, Mitglieder mit Rolle, letzte 20 Buchungen, letzte 20
|
||||
Admin-Aktionen, dazu die Funktions-Schalter (siehe unten). Absichtlich
|
||||
**kein** Schreibzugriff auf die eigentlichen Mandantendaten (Buchungen,
|
||||
Mitglieder, Einstellungen des Kunden) von hier aus, um das Risiko einer
|
||||
versehentlichen Fremdänderung auszuschließen.
|
||||
- `backoffice-export.php`: nutzt dieselbe `app_export_tenant_data()`-
|
||||
Funktion wie der Selbstbedienungs-Export, aber ausgelöst durch den
|
||||
Platform-Admin für einen beliebigen Mandanten.
|
||||
|
||||
## Funktions-Schalter je Mandant
|
||||
|
||||
Stand: 2026-08-17. Zentralstelle des Betreibers, um einzelne Funktionen
|
||||
pro Mandant freizuschalten oder zu sperren – gepflegt in
|
||||
`backoffice-mandant.php`, umgesetzt in `app/features.php` und der Tabelle
|
||||
`tenant_features` (Migration `0025_tenant_features.sql`).
|
||||
|
||||
Schaltbar sind: FAQ-Seite, Striche selbst eintragen, Kaffeeliste als PDF,
|
||||
PayPal-Zahlungseingang, CSV-Import, Info-Mails und Erinnerungen,
|
||||
Jahresabschluss, Datenexport, eigenes Design.
|
||||
|
||||
Design-Entscheidungen:
|
||||
|
||||
- **Getrennt von `tenant_settings`.** Dort stellt der Kunde ein, *wie*
|
||||
eine Funktion arbeitet; hier entscheidet der Betreiber, ob sie ihm
|
||||
überhaupt zur Verfügung steht. Beide Prüfungen stehen nebeneinander,
|
||||
z. B. beim Selbsteintrag: `self_entry_enabled` (Kunde) **und**
|
||||
Feature `self_entry` (Betreiber).
|
||||
- **Ohne Zeile = freigeschaltet.** `tenant_features` hält nur die
|
||||
Abweichungen vom Standard; ein neuer Mandant bekommt automatisch den
|
||||
vollen Funktionsumfang, ohne dass der Betreiber etwas einschalten muss.
|
||||
Fehlt die Tabelle (Migration noch nicht ausgerollt), bleibt ebenfalls
|
||||
alles freigeschaltet – eine ausstehende Migration darf keine Funktion
|
||||
sperren.
|
||||
- **Gesperrt heißt unsichtbar.** Die betroffenen Menüpunkte und
|
||||
Schaltflächen verschwinden (`footer.php`, `kaffeeliste.php`,
|
||||
`einzahlung.php`, `konto.php`); ein direkter Aufruf der Seite endet mit
|
||||
einem Hinweis statt mit einem Fehler. Die POST-Verarbeitung der
|
||||
betroffenen Seiten hängt an derselben Prüfung, eine gesperrte Funktion
|
||||
lässt sich also auch nicht per Formular-POST auslösen.
|
||||
- Jede Änderung landet als `backoffice.features_updated` im Audit-Log des
|
||||
betroffenen Mandanten – wie jeder andere Back-Office-Zugriff auch.
|
||||
|
||||
Das Back-Office ist außerdem jetzt für Platform-Admins im Menü verlinkt
|
||||
(Gruppe „Betreiber" in `footer.php`); vorher war es nur über die direkte
|
||||
URL erreichbar.
|
||||
|
||||
## Eigenes Design je Mandant
|
||||
|
||||
Stand: 2026-08-17, hängt am Feature `branding`. Der Kunde hinterlegt in
|
||||
`mandant-einstellungen.php` eine Akzentfarbe und ein Logo:
|
||||
|
||||
- Die Farbe (`tenant_settings.brand_color`, validiert als `#rrggbb`) wird
|
||||
in `header.php` als kleines Stylesheet eingebettet
|
||||
(`app/branding.php`), das die Akzentfarbe des Templates überschreibt.
|
||||
Eine eigene CSS-Datei je Mandant wäre ein zusätzlicher Request für
|
||||
wenige Zeilen.
|
||||
- Das Logo (`tenant_settings.brand_logo`) liegt wie das PDF-Wasserzeichen
|
||||
in `var/tenant_logos` und ist damit **nicht** direkt per URL abrufbar –
|
||||
sonst wäre jedes Kundenlogo unter einer ratbaren Adresse öffentlich.
|
||||
Ausgeliefert wird es über `tenant-logo-anzeigen.php`, das ausschließlich
|
||||
das Logo des Mandanten des angemeldeten Nutzers ausgibt.
|
||||
- Wasserzeichen (Ausdruck) und Marken-Logo (Oberfläche) sind getrennte
|
||||
Dateien mit eigenem Namenspräfix (`logo_` bzw. `brand_`).
|
||||
- Sperrt der Betreiber `branding`, verschwindet der Abschnitt aus den
|
||||
Einstellungen und die Oberfläche fällt aufs Standarddesign zurück. Die
|
||||
gespeicherte Farbe bleibt erhalten und gilt nach einer erneuten
|
||||
Freischaltung wieder.
|
||||
|
||||
## Verhältnis zum Selbstbedienungs-Export (`datenexport.php`)
|
||||
|
||||
Bewusste Entscheidung: Der bestehende Selbstbedienungs-Export für
|
||||
|
||||
+16
-1
@@ -4,6 +4,7 @@ ob_start();
|
||||
|
||||
include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
app_require_csrf();
|
||||
include "header.php";
|
||||
|
||||
@@ -193,7 +194,17 @@ if ($_SERVER["REQUEST_METHOD"] == "POST" ) {
|
||||
$action = (string)($_GET['action'] ?? 'alle');
|
||||
}
|
||||
|
||||
$mitarbeiter = einzahlung_fetch_participants($pdo, $tenantId, $action);
|
||||
// Vorder-/Rueckseite nur, wenn der Ausdruck auch wirklich zwei Seiten hat
|
||||
// (siehe ledger_uses_two_sided_sheet). Bei kleinen Teams gibt es nur ein Blatt.
|
||||
$alleAktiven = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]);
|
||||
$zweiseitig = ledger_uses_two_sided_sheet(count($alleAktiven));
|
||||
if (!$zweiseitig) {
|
||||
$action = 'alle';
|
||||
}
|
||||
|
||||
$mitarbeiter = $action === 'alle'
|
||||
? $alleAktiven
|
||||
: einzahlung_fetch_participants($pdo, $tenantId, $action);
|
||||
|
||||
?>
|
||||
|
||||
@@ -223,6 +234,7 @@ $mitarbeiter = einzahlung_fetch_participants($pdo, $tenantId, $action);
|
||||
</div>
|
||||
|
||||
<ul class="actions">
|
||||
<?php if ($zweiseitig): ?>
|
||||
<li>
|
||||
<form action="einzahlung.php" method="get">
|
||||
<input type="hidden" name="action" value="vorderseite">
|
||||
@@ -235,6 +247,7 @@ $mitarbeiter = einzahlung_fetch_participants($pdo, $tenantId, $action);
|
||||
<button type="submit">Rückseite</button>
|
||||
</form>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<li>
|
||||
<form action="einzahlung.php" method="get">
|
||||
<input type="hidden" name="action" value="alle">
|
||||
@@ -246,11 +259,13 @@ $mitarbeiter = einzahlung_fetch_participants($pdo, $tenantId, $action);
|
||||
<button type="submit">Letzten Einträge</button>
|
||||
</form>
|
||||
</li>
|
||||
<?php if (app_feature_enabled($pdo, $tenantId, 'csv_import')): ?>
|
||||
<li>
|
||||
<form action="csvupload.php" method="get">
|
||||
<button type="submit">CSV Upload</button>
|
||||
</form>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
|
||||
+10
-4
@@ -3,6 +3,7 @@
|
||||
include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/tenant-logo.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$saasUser = saas_current_user($pdo);
|
||||
@@ -19,6 +20,11 @@ if (!$hasAccess) {
|
||||
exit('Kein Zugriff.');
|
||||
}
|
||||
|
||||
if (!app_feature_enabled($pdo, $tenantId, 'pdf_export')) {
|
||||
http_response_code(403);
|
||||
exit('Der Ausdruck der Kaffeeliste ist für euren Zugang nicht freigeschaltet.');
|
||||
}
|
||||
|
||||
$settings = saas_fetch_tenant_settings($pdo, $tenantId);
|
||||
$windowDays = $settings['sheet_window_days'] ?? 100;
|
||||
$markPrice = number_format(($settings['mark_price_cents'] ?? 20) / 100, 2, ',', '.');
|
||||
@@ -29,9 +35,9 @@ $watermarkText = trim((string)($settings['pdf_watermark_text'] ?? ''));
|
||||
$footerText = trim((string)($settings['pdf_footer_text'] ?? ''));
|
||||
$watermarkLogoPath = tenant_logo_path((string)($settings['pdf_watermark_logo'] ?? '')) ?? '';
|
||||
|
||||
// Ab dieser Mitgliederzahl wird auf zwei Seiten (Vorder-/Rueckseite) statt
|
||||
// einer einzelnen Seite gedruckt.
|
||||
define('EXPORT_MULTI_PAGE_THRESHOLD', 50);
|
||||
// Vorder-/Rueckseite erst oberhalb der gemeinsamen Schwelle aus app/ledger.php
|
||||
// (LEDGER_SINGLE_PAGE_MAX_MEMBERS): bis einschliesslich 50 Personen bleibt der
|
||||
// Ausdruck bewusst einseitig.
|
||||
// TCPDF's writeHTML() ignoriert CSS-Zeilenhoehen/-innenabstaende auf
|
||||
// Tabellenzeilen vollstaendig (getestet: weder "height" auf <tr> noch
|
||||
// "padding" auf <td> haben irgendeinen Einfluss auf die gerenderte Zeilen-
|
||||
@@ -57,7 +63,7 @@ define('EXPORT_ROW_OVERHEAD_MM', 0.2);
|
||||
|
||||
$allActive = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]);
|
||||
$memberCount = count($allActive);
|
||||
$isMultiPage = $memberCount >= EXPORT_MULTI_PAGE_THRESHOLD;
|
||||
$isMultiPage = ledger_uses_two_sided_sheet($memberCount);
|
||||
|
||||
/**
|
||||
* Verfuegbare Hoehe (mm) fuer die Datenzeilen einer A4-Seite: Gesamthoehe
|
||||
|
||||
@@ -4,6 +4,7 @@ include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/faq.php";
|
||||
require_once __DIR__ . "/app/audit.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
app_require_csrf();
|
||||
include "header.php";
|
||||
include "headerline.php";
|
||||
@@ -37,6 +38,12 @@ if (!$hasAccess) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!app_feature_enabled($pdo, $tenantId, 'faq')) {
|
||||
echo app_feature_notice_html('faq');
|
||||
include "footer.php";
|
||||
exit;
|
||||
}
|
||||
|
||||
$meldung = null;
|
||||
$fehler = null;
|
||||
$bearbeitenId = null;
|
||||
|
||||
+39
@@ -30,6 +30,31 @@ if ($saasNavUser !== null && function_exists('saas_list_user_memberships')) {
|
||||
$saasHasMultipleTenants = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Vom Betreiber gesperrte Funktionen erscheinen gar nicht erst im Menue -
|
||||
// ein Menuepunkt, der nur zu einer Absage fuehrt, ist schlechter als keiner.
|
||||
require_once __DIR__ . '/app/features.php';
|
||||
$saasNavTenantId = $saasNavUser !== null ? (int)$saasNavUser['tenant_id'] : 0;
|
||||
$saasNavFeature = static function (string $key) use ($saasNavTenantId): bool {
|
||||
if ($saasNavTenantId <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return app_feature_enabled(app_db_pdo(), $saasNavTenantId, $key);
|
||||
};
|
||||
|
||||
// Das Back-Office war bisher nur ueber die direkte URL erreichbar. Es ist die
|
||||
// Zentralstelle des Betreibers und gehoert deshalb ins Menue - aber nur fuer
|
||||
// Platform-Admins, nicht fuer Mandanten-Rollen.
|
||||
$saasNavIsPlatformAdmin = false;
|
||||
if ($saasNavUser !== null) {
|
||||
try {
|
||||
require_once __DIR__ . '/app/platform-admin.php';
|
||||
$saasNavIsPlatformAdmin = app_is_platform_admin(app_db_pdo(), (int)$saasNavUser['user_id']);
|
||||
} catch (Throwable $e) {
|
||||
$saasNavIsPlatformAdmin = false;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<ul>
|
||||
<li>
|
||||
@@ -37,7 +62,9 @@ if ($saasNavUser !== null && function_exists('saas_list_user_memberships')) {
|
||||
<ul class="menu-group-items">
|
||||
<li><a href="index.php">Meine Kaffeeliste</a></li>
|
||||
<li><a href="anleitung.php">Anleitung</a></li>
|
||||
<?php if ($saasNavFeature('faq')): ?>
|
||||
<li><a href="faq.php">FAQ Kaffeeliste</a></li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</li>
|
||||
<?php if ($canUseVerwaltungGroup): ?>
|
||||
@@ -45,11 +72,15 @@ if ($saasNavUser !== null && function_exists('saas_list_user_memberships')) {
|
||||
<span class="menu-group-label">Verwaltung</span>
|
||||
<ul class="menu-group-items">
|
||||
<li><a href="einzahlung.php">Einzahlung eintragen</a></li>
|
||||
<?php if ($saasNavFeature('paypal_inbox')): ?>
|
||||
<li><a href="paypal-zuordnung.php">PayPal-Zahlungen</a></li>
|
||||
<?php endif; ?>
|
||||
<li><a href="stricheintragen.php">Striche eintragen</a></li>
|
||||
<li><a href="kaffeeliste.php">Kaffeeliste anzeigen</a></li>
|
||||
<li><a href="ledger-preview.php">Journal-Vorschau</a></li>
|
||||
<?php if ($saasNavFeature('year_closing')): ?>
|
||||
<li><a href="jahresauswertung.php">Jahresabschluss</a></li>
|
||||
<?php endif; ?>
|
||||
<?php if ($saasCanManageMembersNav): ?>
|
||||
<li><a href="mitarbeiterverwalten.php">Mitglieder verwalten</a></li>
|
||||
<li><a href="hinweise.php">Hinweise verwalten</a></li>
|
||||
@@ -74,6 +105,14 @@ if ($saasNavUser !== null && function_exists('saas_list_user_memberships')) {
|
||||
<li><a href="logout.php">Logout</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php if ($saasNavIsPlatformAdmin): ?>
|
||||
<li>
|
||||
<span class="menu-group-label">Betreiber</span>
|
||||
<ul class="menu-group-items">
|
||||
<li><a href="backoffice.php">Back-Office</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
+17
-2
@@ -11,12 +11,27 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="assets/css/main.css" />
|
||||
<link rel="stylesheet" href="assets/css/app.css" />
|
||||
</head>
|
||||
<body class="is-preload">
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/app/ledger.php';
|
||||
require_once __DIR__ . '/app/notices.php';
|
||||
require_once __DIR__ . '/app/features.php';
|
||||
require_once __DIR__ . '/app/branding.php';
|
||||
|
||||
// Eigenes Design des Mandanten: die Akzentfarbe wird als kleines Stylesheet
|
||||
// direkt hier eingebettet - eine eigene CSS-Datei je Mandant waere ein
|
||||
// zusaetzlicher Request fuer wenige Zeilen. Die Farbe ist als Hex-Wert
|
||||
// validiert (saas_update_tenant_settings), kann also nicht aus der
|
||||
// Style-Deklaration ausbrechen.
|
||||
$headerBranding = app_branding_for_current_user(app_db_pdo());
|
||||
if ($headerBranding['color'] !== '') {
|
||||
echo "\t\t<style>\n" . app_branding_css($headerBranding['color']) . "\t\t</style>\n";
|
||||
}
|
||||
|
||||
?>
|
||||
</head>
|
||||
<body class="is-preload">
|
||||
<?php
|
||||
|
||||
// Aktuellen Hinweis fuer den passenden Mandanten abrufen: eingeloggte
|
||||
// SaaS-Nutzer sehen den Hinweis ihres Mandanten, der Legacy-/Dev-Fallback
|
||||
|
||||
+17
-1
@@ -1,2 +1,18 @@
|
||||
<?php
|
||||
// Reserved for an optional header line.
|
||||
|
||||
// Kopfzeile des Mandanten: zeigt das hinterlegte Logo, wenn der Betreiber das
|
||||
// eigene Design freigeschaltet hat und der Kunde eines hochgeladen hat.
|
||||
// Ohne Logo bleibt die Zeile komplett aus - eine leere Flaeche waere
|
||||
// schlechter als gar keine.
|
||||
|
||||
require_once __DIR__ . '/app/branding.php';
|
||||
|
||||
$headerlineBranding = app_branding_for_current_user(app_db_pdo());
|
||||
|
||||
if ($headerlineBranding['logo_url'] !== '') {
|
||||
echo '<div class="tenant-brand-line">'
|
||||
. '<img src="' . htmlspecialchars($headerlineBranding['logo_url'], ENT_QUOTES, 'UTF-8') . '"'
|
||||
. ' alt="' . htmlspecialchars($headerlineBranding['tenant_name'], ENT_QUOTES, 'UTF-8') . '"'
|
||||
. ' class="tenant-brand-logo">'
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ if ($appHost !== null && $requestHost !== strtolower($appHost)) {
|
||||
|
||||
include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
app_require_csrf();
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
@@ -106,6 +107,12 @@ function dashboard_record_self_entry(PDO $pdo, array $participant, array $settin
|
||||
return ['ok' => false, 'error' => 'Die eigene Stricherfassung ist aktuell deaktiviert.'];
|
||||
}
|
||||
|
||||
// Zweite, uebergeordnete Huerde: der Betreiber kann die Funktion fuer den
|
||||
// ganzen Mandanten sperren, unabhaengig von dessen eigener Einstellung.
|
||||
if (!app_feature_enabled($pdo, (int)$participant['tenant_id'], 'self_entry')) {
|
||||
return ['ok' => false, 'error' => 'Die eigene Stricherfassung ist für euren Zugang nicht freigeschaltet.'];
|
||||
}
|
||||
|
||||
$marks = filter_var($marksValue, FILTER_VALIDATE_INT);
|
||||
if ($marks !== 1 && $marks !== 2) {
|
||||
return ['ok' => false, 'error' => 'Bitte einen oder zwei Striche auswählen.'];
|
||||
@@ -223,7 +230,8 @@ if ($hasAccess && $participant !== null && $settings !== null) {
|
||||
$balanceCents = (int)$participant['balance_cents'];
|
||||
$balance = dashboard_money($balanceCents);
|
||||
$currentName = dashboard_current_name($saasUser, $participant);
|
||||
$selfEntryEnabled = (int)$settings['self_entry_enabled'] === 1;
|
||||
$selfEntryEnabled = (int)$settings['self_entry_enabled'] === 1
|
||||
&& app_feature_enabled($pdo, $tenantId, 'self_entry');
|
||||
$paypalEnabled = (int)$settings['paypal_enabled'] === 1
|
||||
&& trim((string)$settings['paypal_url_template']) !== '';
|
||||
$cashEnabled = (int)$settings['cash_enabled'] === 1;
|
||||
|
||||
+12
-1
@@ -4,6 +4,7 @@ include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/saas-mail.php";
|
||||
require_once __DIR__ . "/app/audit.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
app_require_csrf();
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
@@ -16,6 +17,14 @@ if ($saasUser !== null && saas_user_has_role(['owner', 'admin', 'treasurer'], $s
|
||||
$hasAccess = true;
|
||||
}
|
||||
|
||||
// Vom Betreiber gesperrte Funktion: $hasAccess faellt zurueck, damit auch die
|
||||
// POST-Verarbeitung weiter unten nicht mehr laeuft.
|
||||
$gesperrteFunktion = null;
|
||||
if ($hasAccess && !app_feature_enabled($pdo, $tenantId, 'year_closing')) {
|
||||
$gesperrteFunktion = 'year_closing';
|
||||
$hasAccess = false;
|
||||
}
|
||||
|
||||
$jahr = (int)date('Y');
|
||||
$fehler = null;
|
||||
$ergebnisse = null;
|
||||
@@ -181,7 +190,9 @@ include "nav.php";
|
||||
<section id="banner">
|
||||
<div class="content">
|
||||
|
||||
<?php if (!$hasAccess): ?>
|
||||
<?php if ($gesperrteFunktion !== null): ?>
|
||||
<?php echo app_feature_notice_html($gesperrteFunktion); ?>
|
||||
<?php elseif (!$hasAccess): ?>
|
||||
<h2>Kein Zugriff</h2>
|
||||
<?php else: ?>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$saasUser = saas_current_user($pdo);
|
||||
@@ -35,21 +36,25 @@ if ($hasAccess) {
|
||||
<h2>Aktive Mitarbeiter mit Gesamtstand</h2>
|
||||
<br>
|
||||
<ul class="actions">
|
||||
<?php if (app_feature_enabled($pdo, $tenantId, 'pdf_export')): ?>
|
||||
<li>
|
||||
<form action="exportKaffeeliste.php" method="get">
|
||||
<button type="submit">Kaffeeliste exportieren</button>
|
||||
</form>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<li>
|
||||
<form action="letzteneintraege.php" method="get">
|
||||
<button type="submit">Letzten Einträge</button>
|
||||
</form>
|
||||
</li>
|
||||
<?php if (app_feature_enabled($pdo, $tenantId, 'csv_import')): ?>
|
||||
<li>
|
||||
<form action="csvupload.php" method="get">
|
||||
<button type="submit">CSV Upload</button>
|
||||
</form>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
|
||||
<br>
|
||||
@@ -85,9 +90,11 @@ if ($hasAccess) {
|
||||
</table>
|
||||
<br><br>
|
||||
|
||||
<?php if (app_feature_enabled($pdo, $tenantId, 'mail_dispatch')): ?>
|
||||
<form action="mailversenden.php" method="get">
|
||||
<button type="submit">Info-Mail versenden</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
} else {
|
||||
echo "<h2>Kein Zugriff</h2>";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/features.php';
|
||||
|
||||
$user = saas_require_login();
|
||||
$devVerificationToken = null;
|
||||
@@ -124,7 +125,9 @@ include 'nav.php';
|
||||
<?php if (saas_can_manage_tenant_settings($user)): ?>
|
||||
<ul class="actions">
|
||||
<li><a href="mandant-einstellungen.php" class="button">Mandant-Einstellungen</a></li>
|
||||
<li><a href="datenexport.php" class="button">Datenexport</a></li>
|
||||
<?php if (app_feature_enabled(app_db_pdo(), (int)$user['tenant_id'], 'data_export')): ?>
|
||||
<li><a href="datenexport.php" class="button">Datenexport</a></li>
|
||||
<?php endif; ?>
|
||||
<?php if (saas_user_has_role('owner', $user)): ?>
|
||||
<li><a href="mandant-loeschen.php" class="button">Mandant löschen</a></li>
|
||||
<?php endif; ?>
|
||||
|
||||
+12
-1
@@ -4,6 +4,7 @@ include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/saas-mail.php";
|
||||
require_once __DIR__ . "/app/audit.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
app_require_csrf();
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
@@ -16,6 +17,14 @@ if ($saasUser !== null && saas_user_has_role(['owner', 'admin', 'treasurer'], $s
|
||||
$hasAccess = true;
|
||||
}
|
||||
|
||||
// Vom Betreiber gesperrte Funktion: $hasAccess faellt zurueck, damit auch die
|
||||
// POST-Verarbeitung weiter unten nicht mehr laeuft.
|
||||
$gesperrteFunktion = null;
|
||||
if ($hasAccess && !app_feature_enabled($pdo, $tenantId, 'mail_dispatch')) {
|
||||
$gesperrteFunktion = 'mail_dispatch';
|
||||
$hasAccess = false;
|
||||
}
|
||||
|
||||
$ergebnisse = null;
|
||||
$dryRun = true;
|
||||
$liveFlash = false;
|
||||
@@ -117,7 +126,9 @@ include "nav.php";
|
||||
<section id="banner">
|
||||
<div class="content">
|
||||
|
||||
<?php if (!$hasAccess): ?>
|
||||
<?php if ($gesperrteFunktion !== null): ?>
|
||||
<?php echo app_feature_notice_html($gesperrteFunktion); ?>
|
||||
<?php elseif (!$hasAccess): ?>
|
||||
<h2>Kein Zugriff</h2>
|
||||
<?php else: ?>
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@ require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/audit.php';
|
||||
require_once __DIR__ . '/app/billing.php';
|
||||
require_once __DIR__ . '/app/tenant-logo.php';
|
||||
require_once __DIR__ . '/app/features.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$user = saas_require_login();
|
||||
$canManageSettings = saas_can_manage_tenant_settings($user);
|
||||
$brandingEnabled = app_feature_enabled($pdo, (int)$user['tenant_id'], 'branding');
|
||||
$errors = [];
|
||||
$saved = false;
|
||||
$settings = null;
|
||||
@@ -15,12 +17,39 @@ $settings = null;
|
||||
if ($canManageSettings) {
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
app_require_csrf();
|
||||
$result = saas_update_tenant_settings($pdo, (int)$user['tenant_id'], $_POST);
|
||||
$eingaben = $_POST;
|
||||
// Ohne freigeschaltetes Design fehlt das Farbfeld im Formular. Der
|
||||
// gespeicherte Wert wird dann durchgereicht statt geleert, damit eine
|
||||
// spaetere Freischaltung die alte Farbe wiederfindet.
|
||||
if (!$brandingEnabled) {
|
||||
$bestehend = saas_fetch_tenant_settings($pdo, (int)$user['tenant_id']);
|
||||
$eingaben['brand_color'] = (string)($bestehend['brand_color'] ?? '');
|
||||
}
|
||||
$result = saas_update_tenant_settings($pdo, (int)$user['tenant_id'], $eingaben);
|
||||
if ($result['ok']) {
|
||||
$saved = true;
|
||||
$settings = $result['settings'];
|
||||
app_audit_log($pdo, (int)$user['tenant_id'], (int)$user['user_id'], 'tenant_settings.updated', 'tenant', (int)$user['tenant_id']);
|
||||
$tenantIdLogo = (int)$user['tenant_id'];
|
||||
|
||||
// Marken-Logo der App-Oberflaeche: nur wenn der Betreiber das
|
||||
// eigene Design fuer diesen Mandanten freigeschaltet hat.
|
||||
if ($brandingEnabled) {
|
||||
if (!empty($_POST['brand_logo_remove'])) {
|
||||
tenant_logo_delete((string)($settings['brand_logo'] ?? ''));
|
||||
$pdo->prepare("UPDATE tenant_settings SET brand_logo = '' WHERE tenant_id = ?")->execute([$tenantIdLogo]);
|
||||
$settings['brand_logo'] = '';
|
||||
} elseif (isset($_FILES['brand_logo_file']) && ($_FILES['brand_logo_file']['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK) {
|
||||
$brandResult = tenant_logo_store($tenantIdLogo, $_FILES['brand_logo_file'], 'brand');
|
||||
if ($brandResult['ok']) {
|
||||
$pdo->prepare('UPDATE tenant_settings SET brand_logo = ? WHERE tenant_id = ?')->execute([$brandResult['filename'], $tenantIdLogo]);
|
||||
$settings['brand_logo'] = $brandResult['filename'];
|
||||
} else {
|
||||
$errors[] = $brandResult['error'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($_POST['pdf_watermark_logo_remove'])) {
|
||||
tenant_logo_delete((string)($settings['pdf_watermark_logo'] ?? ''));
|
||||
$pdo->prepare("UPDATE tenant_settings SET pdf_watermark_logo = '' WHERE tenant_id = ?")->execute([$tenantIdLogo]);
|
||||
@@ -59,6 +88,8 @@ if ($canManageSettings) {
|
||||
'pdf_footer_text' => $_POST['pdf_footer_text'] ?? '',
|
||||
'payment_reminder_enabled' => !empty($_POST['payment_reminder_enabled']) ? 1 : 0,
|
||||
'payment_reminder_interval_days' => $_POST['payment_reminder_interval_days'] ?? 7,
|
||||
'brand_color' => $_POST['brand_color'] ?? '',
|
||||
'brand_logo' => (string)(saas_fetch_tenant_settings($pdo, (int)$user['tenant_id'])['brand_logo'] ?? ''),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
@@ -200,6 +231,31 @@ include 'nav.php';
|
||||
<label for="pdf_footer_text">Hinweis in der Fußzeile des Ausdrucks (leer = keine Fußzeile)</label>
|
||||
<input type="text" name="pdf_footer_text" id="pdf_footer_text" maxlength="500" value="<?php echo saas_html($settings['pdf_footer_text']); ?>">
|
||||
</div>
|
||||
<?php if ($brandingEnabled): ?>
|
||||
<div class="col-12">
|
||||
<h3>Eigenes Design</h3>
|
||||
<p>Logo und Akzentfarbe für die Oberfläche eurer Kaffeeliste. Leere Felder =
|
||||
Standarddesign. Das Logo hier erscheint sichtbar in der Kopfzeile jeder Seite –
|
||||
das Wasserzeichen weiter oben betrifft nur den Ausdruck.</p>
|
||||
</div>
|
||||
<div class="col-6 col-12-small">
|
||||
<label for="brand_color">Akzentfarbe</label>
|
||||
<input type="color" name="brand_color" id="brand_color"
|
||||
value="<?php echo saas_html($settings['brand_color'] !== '' ? $settings['brand_color'] : '#38761d'); ?>">
|
||||
</div>
|
||||
<div class="col-6 col-12-small">
|
||||
<label for="brand_logo_file">Logo für die Kopfzeile (PNG/JPEG)</label>
|
||||
<input type="file" name="brand_logo_file" id="brand_logo_file" accept="image/png,image/jpeg">
|
||||
<?php if (trim((string)($settings['brand_logo'] ?? '')) !== ''): ?>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="brand_logo_remove" id="brand_logo_remove" value="1">
|
||||
<label class="form-check-label" for="brand_logo_remove">Hinterlegtes Logo entfernen</label>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<small>Aktuell kein Logo hinterlegt.</small>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="col-6 col-12-small">
|
||||
<input type="checkbox" id="payment_reminder_enabled" name="payment_reminder_enabled" value="1" <?php echo (int)$settings['payment_reminder_enabled'] === 1 ? 'checked' : ''; ?>>
|
||||
<label for="payment_reminder_enabled">Automatische Zahlungserinnerung ab Warnschwelle</label>
|
||||
|
||||
+211
-12
@@ -33,6 +33,66 @@ if ($saasUser !== null && saas_user_has_role(['owner', 'admin'], $saasUser)) {
|
||||
$hasAccess = true;
|
||||
}
|
||||
|
||||
function mitglieder_parse_filters(array $source): array
|
||||
{
|
||||
$status = (string)($source['status'] ?? 'alle');
|
||||
if (!in_array($status, ['alle', 'aktiv', 'inaktiv'], true)) {
|
||||
$status = 'alle';
|
||||
}
|
||||
|
||||
$zugang = (string)($source['zugang'] ?? 'alle');
|
||||
if (!in_array($zugang, ['alle', 'mit_zugang', 'ohne_zugang', 'entzogen'], true)) {
|
||||
$zugang = 'alle';
|
||||
}
|
||||
|
||||
$paypal = (string)($source['paypal'] ?? 'alle');
|
||||
if (!in_array($paypal, ['alle', 'hinterlegt', 'fehlt', 'doppelt'], true)) {
|
||||
$paypal = 'alle';
|
||||
}
|
||||
|
||||
return [
|
||||
'q' => trim((string)($source['q'] ?? '')),
|
||||
'status' => $status,
|
||||
'zugang' => $zugang,
|
||||
'paypal' => $paypal,
|
||||
];
|
||||
}
|
||||
|
||||
function mitglieder_build_filter_query(array $filters): string
|
||||
{
|
||||
$params = [];
|
||||
foreach (['q', 'status', 'zugang', 'paypal'] as $key) {
|
||||
$value = (string)($filters[$key] ?? '');
|
||||
if ($value !== '' && !in_array($value, ['alle'], true)) {
|
||||
$params[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return http_build_query($params);
|
||||
}
|
||||
|
||||
function mitglieder_filter_fields(array $filters): string
|
||||
{
|
||||
return '<input type="hidden" name="filter_q" value="' . saas_html((string)$filters['q']) . '">'
|
||||
. '<input type="hidden" name="filter_status" value="' . saas_html((string)$filters['status']) . '">'
|
||||
. '<input type="hidden" name="filter_zugang" value="' . saas_html((string)$filters['zugang']) . '">'
|
||||
. '<input type="hidden" name="filter_paypal" value="' . saas_html((string)$filters['paypal']) . '">';
|
||||
}
|
||||
|
||||
function mitglieder_status_badge(bool $active): string
|
||||
{
|
||||
return '<span class="status-badge ' . ($active ? 'success' : 'muted') . '">' . ($active ? 'Aktiv' : 'Inaktiv') . '</span>';
|
||||
}
|
||||
|
||||
function mitglieder_access_label(array $mitglied, array $rollenLabels): string
|
||||
{
|
||||
if ($mitglied['membership_status'] === 'active') {
|
||||
return $rollenLabels[$mitglied['role']] ?? (string)$mitglied['role'];
|
||||
}
|
||||
|
||||
return $mitglied['membership_status'] === 'revoked' ? 'Zugang entzogen' : 'Kein Zugang';
|
||||
}
|
||||
|
||||
if($hasAccess){
|
||||
|
||||
$meldung = null;
|
||||
@@ -40,6 +100,17 @@ if($hasAccess){
|
||||
$einladungslink = null;
|
||||
$bearbeitenId = null;
|
||||
$actorUserId = $saasUser['user_id'] ?? null;
|
||||
$filterSource = $_SERVER['REQUEST_METHOD'] === 'POST'
|
||||
? [
|
||||
'q' => $_POST['filter_q'] ?? '',
|
||||
'status' => $_POST['filter_status'] ?? 'alle',
|
||||
'zugang' => $_POST['filter_zugang'] ?? 'alle',
|
||||
'paypal' => $_POST['filter_paypal'] ?? 'alle',
|
||||
]
|
||||
: $_GET;
|
||||
$filter = mitglieder_parse_filters($filterSource);
|
||||
$filterQuery = mitglieder_build_filter_query($filter);
|
||||
$filterSuffix = $filterQuery !== '' ? '?' . $filterQuery : '';
|
||||
|
||||
if (isset($_SESSION['flash_mitglieder']) && is_array($_SESSION['flash_mitglieder'])) {
|
||||
$flash = $_SESSION['flash_mitglieder'];
|
||||
@@ -224,7 +295,7 @@ if($hasAccess){
|
||||
'fehler' => $fehler,
|
||||
'einladungslink' => $einladungslink,
|
||||
];
|
||||
header('Location: mitarbeiterverwalten.php');
|
||||
header('Location: mitarbeiterverwalten.php' . $filterSuffix);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -235,6 +306,77 @@ if($hasAccess){
|
||||
$billingPlan = billing_plans()[$billingInfo['plan_code']] ?? null;
|
||||
$aktiveMitgliederAnzahl = billing_count_active_participants($pdo, $tenantId);
|
||||
$rollenLabels = ['owner' => 'Inhaber', 'admin' => 'Administrator', 'treasurer' => 'Kassenwart', 'member' => 'Mitglied', 'viewer' => 'Betrachter'];
|
||||
$paypalNameCounts = [];
|
||||
foreach ($mitglieder as $mitglied) {
|
||||
$paypalNorm = imports_normalize_match_text((string)($mitglied['paypal_name'] ?? ''));
|
||||
if ($paypalNorm !== '') {
|
||||
$paypalNameCounts[$paypalNorm] = ($paypalNameCounts[$paypalNorm] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
$mitgliederStats = [
|
||||
'gesamt' => count($mitglieder),
|
||||
'aktiv' => 0,
|
||||
'inaktiv' => 0,
|
||||
'mit_zugang' => 0,
|
||||
'ohne_zugang' => 0,
|
||||
'paypal_fehlt' => 0,
|
||||
'paypal_doppelt' => 0,
|
||||
];
|
||||
$gefilterteMitglieder = [];
|
||||
foreach ($mitglieder as $mitglied) {
|
||||
$isActive = (bool)$mitglied['active'];
|
||||
$hasAccessAccount = $mitglied['membership_status'] === 'active';
|
||||
$paypalName = trim((string)($mitglied['paypal_name'] ?? ''));
|
||||
$paypalNorm = imports_normalize_match_text($paypalName);
|
||||
$paypalDuplicate = $paypalNorm !== '' && ($paypalNameCounts[$paypalNorm] ?? 0) > 1;
|
||||
|
||||
$mitgliederStats[$isActive ? 'aktiv' : 'inaktiv']++;
|
||||
$mitgliederStats[$hasAccessAccount ? 'mit_zugang' : 'ohne_zugang']++;
|
||||
if ($paypalName === '') {
|
||||
$mitgliederStats['paypal_fehlt']++;
|
||||
}
|
||||
if ($paypalDuplicate) {
|
||||
$mitgliederStats['paypal_doppelt']++;
|
||||
}
|
||||
|
||||
$matches = true;
|
||||
$q = imports_normalize_match_text((string)$filter['q']);
|
||||
if ($q !== '') {
|
||||
$searchText = imports_normalize_match_text(
|
||||
(string)$mitglied['display_name'] . ' ' . (string)($mitglied['email'] ?? '') . ' ' . $paypalName
|
||||
);
|
||||
$matches = str_contains($searchText, $q);
|
||||
}
|
||||
if ($matches && $filter['status'] === 'aktiv') {
|
||||
$matches = $isActive;
|
||||
}
|
||||
if ($matches && $filter['status'] === 'inaktiv') {
|
||||
$matches = !$isActive;
|
||||
}
|
||||
if ($matches && $filter['zugang'] === 'mit_zugang') {
|
||||
$matches = $hasAccessAccount;
|
||||
}
|
||||
if ($matches && $filter['zugang'] === 'ohne_zugang') {
|
||||
$matches = $mitglied['membership_status'] === null || $mitglied['membership_status'] === 'pending';
|
||||
}
|
||||
if ($matches && $filter['zugang'] === 'entzogen') {
|
||||
$matches = $mitglied['membership_status'] === 'revoked';
|
||||
}
|
||||
if ($matches && $filter['paypal'] === 'hinterlegt') {
|
||||
$matches = $paypalName !== '';
|
||||
}
|
||||
if ($matches && $filter['paypal'] === 'fehlt') {
|
||||
$matches = $paypalName === '';
|
||||
}
|
||||
if ($matches && $filter['paypal'] === 'doppelt') {
|
||||
$matches = $paypalDuplicate;
|
||||
}
|
||||
|
||||
if ($matches) {
|
||||
$mitglied['paypal_duplicate'] = $paypalDuplicate;
|
||||
$gefilterteMitglieder[] = $mitglied;
|
||||
}
|
||||
}
|
||||
$bearbeitenMitglied = null;
|
||||
if ($bearbeitenId !== null) {
|
||||
foreach ($mitglieder as $m) {
|
||||
@@ -263,12 +405,21 @@ if($hasAccess){
|
||||
Limit erreicht – für weitere aktive Mitglieder ist ein <a href="mandant-einstellungen.php">Upgrade</a> nötig.
|
||||
<?php endif; ?></p>
|
||||
|
||||
<div class="admin-summary-grid">
|
||||
<div><strong><?php echo (int)$mitgliederStats['gesamt']; ?></strong><span>Mitglieder</span></div>
|
||||
<div><strong><?php echo (int)$mitgliederStats['aktiv']; ?></strong><span>aktiv</span></div>
|
||||
<div><strong><?php echo (int)$mitgliederStats['mit_zugang']; ?></strong><span>mit Zugang</span></div>
|
||||
<div><strong><?php echo (int)$mitgliederStats['paypal_fehlt']; ?></strong><span>ohne PayPal-Name</span></div>
|
||||
<div><strong><?php echo (int)$mitgliederStats['paypal_doppelt']; ?></strong><span>PayPal-Konflikte</span></div>
|
||||
</div>
|
||||
|
||||
<?php if ($bearbeitenMitglied !== null): ?>
|
||||
<h3>Bearbeiten von <?php echo saas_html($bearbeitenMitglied['display_name']); ?></h3>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
|
||||
<input type="hidden" name="aktion" value="bearbeitenspeichern">
|
||||
<input type="hidden" name="mitgliedID" value="<?php echo $bearbeitenMitglied['participant_id']; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
|
||||
<label for="name">Name:</label>
|
||||
<input type="text" name="name" id="name" value="<?php echo saas_html($bearbeitenMitglied['display_name']); ?>" required>
|
||||
@@ -292,6 +443,7 @@ if($hasAccess){
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
|
||||
<input type="hidden" name="aktion" id="aktion" value="anlegen">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
|
||||
<label for="new-name">Name:</label>
|
||||
<input type="text" name="name" id="new-name" required>
|
||||
@@ -316,6 +468,7 @@ if($hasAccess){
|
||||
<p><a class="button" href="mitglieder-vorlage.php">Vorlage herunterladen</a></p>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" enctype="multipart/form-data">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
<input type="hidden" name="aktion" value="csv_import">
|
||||
<label for="mitglieder_csv">CSV-Datei mit Mitgliedern:</label>
|
||||
<input type="file" name="mitglieder_csv" id="mitglieder_csv" accept=".csv" required>
|
||||
@@ -326,8 +479,38 @@ if($hasAccess){
|
||||
<p>Name und E-Mail dienen der Kaffeeliste und Benachrichtigungen. Ein Login-Zugang
|
||||
ist davon unabhängig und wird separat je Mitglied gewährt oder entzogen.</p>
|
||||
|
||||
<form method="get" action="mitarbeiterverwalten.php" class="admin-filter-bar">
|
||||
<label for="mitglieder-q">Suche</label>
|
||||
<input type="search" name="q" id="mitglieder-q" value="<?php echo saas_html((string)$filter['q']); ?>" placeholder="Name, E-Mail oder PayPal">
|
||||
<label for="mitglieder-status">Status</label>
|
||||
<select name="status" id="mitglieder-status">
|
||||
<option value="alle" <?php echo $filter['status'] === 'alle' ? 'selected' : ''; ?>>Alle</option>
|
||||
<option value="aktiv" <?php echo $filter['status'] === 'aktiv' ? 'selected' : ''; ?>>Aktiv</option>
|
||||
<option value="inaktiv" <?php echo $filter['status'] === 'inaktiv' ? 'selected' : ''; ?>>Inaktiv</option>
|
||||
</select>
|
||||
<label for="mitglieder-zugang">Zugang</label>
|
||||
<select name="zugang" id="mitglieder-zugang">
|
||||
<option value="alle" <?php echo $filter['zugang'] === 'alle' ? 'selected' : ''; ?>>Alle</option>
|
||||
<option value="mit_zugang" <?php echo $filter['zugang'] === 'mit_zugang' ? 'selected' : ''; ?>>Mit Zugang</option>
|
||||
<option value="ohne_zugang" <?php echo $filter['zugang'] === 'ohne_zugang' ? 'selected' : ''; ?>>Ohne Zugang</option>
|
||||
<option value="entzogen" <?php echo $filter['zugang'] === 'entzogen' ? 'selected' : ''; ?>>Entzogen</option>
|
||||
</select>
|
||||
<label for="mitglieder-paypal">PayPal</label>
|
||||
<select name="paypal" id="mitglieder-paypal">
|
||||
<option value="alle" <?php echo $filter['paypal'] === 'alle' ? 'selected' : ''; ?>>Alle</option>
|
||||
<option value="hinterlegt" <?php echo $filter['paypal'] === 'hinterlegt' ? 'selected' : ''; ?>>Hinterlegt</option>
|
||||
<option value="fehlt" <?php echo $filter['paypal'] === 'fehlt' ? 'selected' : ''; ?>>Fehlt</option>
|
||||
<option value="doppelt" <?php echo $filter['paypal'] === 'doppelt' ? 'selected' : ''; ?>>Konflikte</option>
|
||||
</select>
|
||||
<button type="submit">Filtern</button>
|
||||
<a class="button alt" href="mitarbeiterverwalten.php">Zurücksetzen</a>
|
||||
</form>
|
||||
|
||||
<p><?php echo count($gefilterteMitglieder); ?> von <?php echo count($mitglieder); ?> Mitgliedern angezeigt.</p>
|
||||
|
||||
<!-- Tabelle zur Anzeige und Bearbeitung von Mitgliedern -->
|
||||
<table class="table table-striped">
|
||||
<div class="table-wrapper">
|
||||
<table class="table table-striped admin-table">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>E-Mail</th>
|
||||
@@ -336,22 +519,28 @@ if($hasAccess){
|
||||
<th>Zugang</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
<?php foreach ($mitglieder as $mitglied): ?>
|
||||
<?php foreach ($gefilterteMitglieder as $mitglied): ?>
|
||||
<tr>
|
||||
<td><?php echo saas_html($mitglied['display_name']); ?></td>
|
||||
<td><?php echo saas_html($mitglied['email'] ?? ''); ?></td>
|
||||
<td><?php echo saas_html($mitglied['paypal_name'] ?? ''); ?></td>
|
||||
<td><?php echo $mitglied['active'] ? '1' : '0'; ?></td>
|
||||
<td>
|
||||
<?php echo saas_html($mitglied['paypal_name'] ?? ''); ?>
|
||||
<?php if (!empty($mitglied['paypal_duplicate'])): ?>
|
||||
<br><span class="status-badge warning">Konflikt</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo mitglieder_status_badge((bool)$mitglied['active']); ?></td>
|
||||
<td>
|
||||
<?php if ($mitglied['membership_status'] === 'active'): ?>
|
||||
<?php if ($mitglied['role'] === 'owner'): ?>
|
||||
Inhaber
|
||||
<span class="status-badge info">Inhaber</span>
|
||||
<?php else: ?>
|
||||
<?php echo saas_html($rollenLabels[$mitglied['role']] ?? $mitglied['role']); ?>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" style="display:inline">
|
||||
<span class="status-badge success"><?php echo saas_html($rollenLabels[$mitglied['role']] ?? $mitglied['role']); ?></span>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" class="inline-admin-form">
|
||||
<input type="hidden" name="aktion" value="zugang_gewaehren">
|
||||
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
<select name="rolle">
|
||||
<?php foreach (saas_grantable_roles() as $rolle): ?>
|
||||
<option value="<?php echo saas_html($rolle); ?>" <?php echo $rolle === $mitglied['role'] ? 'selected' : ''; ?>><?php echo saas_html($rollenLabels[$rolle] ?? $rolle); ?></option>
|
||||
@@ -359,20 +548,22 @@ if($hasAccess){
|
||||
</select>
|
||||
<button type="submit">Rolle ändern</button>
|
||||
</form>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" style="display:inline">
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" class="inline-admin-form">
|
||||
<input type="hidden" name="aktion" value="zugang_entziehen">
|
||||
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
<button type="submit">Zugang entziehen</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<?php echo $mitglied['membership_status'] === 'revoked' ? 'Zugang entzogen' : 'Kein Zugang'; ?>
|
||||
<span class="status-badge <?php echo $mitglied['membership_status'] === 'revoked' ? 'warning' : 'muted'; ?>"><?php echo saas_html(mitglieder_access_label($mitglied, $rollenLabels)); ?></span>
|
||||
<?php if ($mitglied['email'] !== null && $mitglied['email'] !== ''): ?>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" style="display:inline">
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" class="inline-admin-form">
|
||||
<input type="hidden" name="aktion" value="zugang_gewaehren">
|
||||
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
<select name="rolle">
|
||||
<?php foreach (saas_grantable_roles() as $rolle): ?>
|
||||
<option value="<?php echo saas_html($rolle); ?>"><?php echo saas_html($rollenLabels[$rolle] ?? $rolle); ?></option>
|
||||
@@ -384,12 +575,13 @@ if($hasAccess){
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<ul class="actions">
|
||||
<ul class="actions compact-actions">
|
||||
<li>
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
|
||||
<input type="hidden" name="aktion" value="bearbeiten">
|
||||
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
<button type="submit">Bearbeiten</button>
|
||||
</form>
|
||||
</li>
|
||||
@@ -399,6 +591,7 @@ if($hasAccess){
|
||||
<input type="hidden" name="aktion" value="deaktivieren">
|
||||
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
<button type="submit">Deaktivieren</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
@@ -406,6 +599,7 @@ if($hasAccess){
|
||||
<input type="hidden" name="aktion" value="aktivieren">
|
||||
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
<button type="submit">Aktivieren</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
@@ -416,6 +610,7 @@ if($hasAccess){
|
||||
<input type="hidden" name="aktion" value="anonymisieren">
|
||||
<input type="hidden" name="mitgliedID" value="<?php echo $mitglied['participant_id']; ?>">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<?php echo mitglieder_filter_fields($filter); ?>
|
||||
<button type="submit">Anonymisieren</button>
|
||||
</form>
|
||||
</li>
|
||||
@@ -424,7 +619,11 @@ if($hasAccess){
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if ($gefilterteMitglieder === []): ?>
|
||||
<tr><td colspan="6">Keine Mitglieder für diese Filter gefunden.</td></tr>
|
||||
<?php endif; ?>
|
||||
</table>
|
||||
</div>
|
||||
<?php
|
||||
|
||||
}else{
|
||||
|
||||
+106
-13
@@ -3,6 +3,7 @@
|
||||
include "functions.php";
|
||||
require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/paypal-inbox.php";
|
||||
require_once __DIR__ . "/app/features.php";
|
||||
app_require_csrf();
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
@@ -15,12 +16,73 @@ if ($saasUser !== null && saas_user_has_role(['owner', 'admin', 'treasurer'], $s
|
||||
$hasAccess = true;
|
||||
}
|
||||
|
||||
// Vom Betreiber gesperrte Funktion: $hasAccess faellt zurueck, damit auch die
|
||||
// POST-Verarbeitung weiter unten nicht mehr laeuft.
|
||||
$gesperrteFunktion = null;
|
||||
if ($hasAccess && !app_feature_enabled($pdo, $tenantId, 'paypal_inbox')) {
|
||||
$gesperrteFunktion = 'paypal_inbox';
|
||||
$hasAccess = false;
|
||||
}
|
||||
|
||||
$flash = null;
|
||||
if ($hasAccess && isset($_SESSION['flash_paypal'])) {
|
||||
$flash = $_SESSION['flash_paypal'];
|
||||
unset($_SESSION['flash_paypal']);
|
||||
}
|
||||
|
||||
$suchbegriff = trim((string)($_SERVER['REQUEST_METHOD'] === 'POST' ? ($_POST['return_q'] ?? '') : ($_GET['q'] ?? '')));
|
||||
$returnQuery = $suchbegriff !== '' ? '?q=' . urlencode($suchbegriff) : '';
|
||||
|
||||
function paypal_select_options(array $members, array $suggestions = [], int $selectedId = 0): string
|
||||
{
|
||||
$suggestedIds = [];
|
||||
foreach ($suggestions as $suggestion) {
|
||||
$suggestedIds[(int)$suggestion['participant_id']] = true;
|
||||
}
|
||||
|
||||
$html = '<option value="">- Mitglied wählen -</option>';
|
||||
if ($suggestions !== []) {
|
||||
$html .= '<optgroup label="Vorschläge">';
|
||||
foreach ($suggestions as $suggestion) {
|
||||
$id = (int)$suggestion['participant_id'];
|
||||
$html .= '<option value="' . $id . '"' . ($selectedId === $id ? ' selected' : '') . '>'
|
||||
. saas_html((string)$suggestion['display_name'])
|
||||
. '</option>';
|
||||
}
|
||||
$html .= '</optgroup>';
|
||||
}
|
||||
|
||||
$html .= '<optgroup label="Alle aktiven Mitglieder">';
|
||||
foreach ($members as $member) {
|
||||
$id = (int)$member['participant_id'];
|
||||
if (isset($suggestedIds[$id])) {
|
||||
continue;
|
||||
}
|
||||
$html .= '<option value="' . $id . '"' . ($selectedId === $id ? ' selected' : '') . '>'
|
||||
. saas_html((string)$member['display_name'])
|
||||
. '</option>';
|
||||
}
|
||||
$html .= '</optgroup>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
function paypal_payment_matches_query(array $payment, string $query): bool
|
||||
{
|
||||
$query = imports_normalize_match_text($query);
|
||||
if ($query === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$text = imports_normalize_match_text(
|
||||
(string)$payment['payer_name'] . ' '
|
||||
. (string)($payment['note'] ?? '') . ' '
|
||||
. (string)($payment['transaction_code'] ?? '')
|
||||
);
|
||||
|
||||
return str_contains($text, $query);
|
||||
}
|
||||
|
||||
if ($hasAccess && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$aktion = (string)($_POST['aktion'] ?? '');
|
||||
$paymentId = (int)($_POST['payment_id'] ?? 0);
|
||||
@@ -45,7 +107,7 @@ if ($hasAccess && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
// Post-Redirect-Get gegen Doppelbuchung per Refresh.
|
||||
$_SESSION['flash_paypal'] = $flash;
|
||||
header('Location: paypal-zuordnung.php');
|
||||
header('Location: paypal-zuordnung.php' . $returnQuery);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -58,6 +120,7 @@ if ($hasAccess) {
|
||||
$offene = paypal_fetch_unmatched($pdo, $tenantId);
|
||||
$mitglieder = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]);
|
||||
}
|
||||
$offeneGefiltert = array_values(array_filter($offene, static fn(array $payment): bool => paypal_payment_matches_query($payment, $suchbegriff)));
|
||||
|
||||
include "header.php";
|
||||
include "headerline.php";
|
||||
@@ -67,7 +130,9 @@ include "nav.php";
|
||||
<section id="banner">
|
||||
<div class="content">
|
||||
|
||||
<?php if (!$hasAccess): ?>
|
||||
<?php if ($gesperrteFunktion !== null): ?>
|
||||
<?php echo app_feature_notice_html($gesperrteFunktion); ?>
|
||||
<?php elseif (!$hasAccess): ?>
|
||||
<h2>Kein Zugriff</h2>
|
||||
<?php else: ?>
|
||||
|
||||
@@ -88,27 +153,53 @@ include "nav.php";
|
||||
<?php endif; ?>
|
||||
|
||||
<h3>Offene Zahlungen (<?php echo count($offene); ?>)</h3>
|
||||
<form method="get" action="paypal-zuordnung.php" class="admin-filter-bar">
|
||||
<label for="paypal-q">Suche</label>
|
||||
<input type="search" name="q" id="paypal-q" value="<?php echo saas_html($suchbegriff); ?>" placeholder="Zahler, Mitteilung oder Transaktion">
|
||||
<button type="submit">Filtern</button>
|
||||
<a class="button alt" href="paypal-zuordnung.php">Zurücksetzen</a>
|
||||
</form>
|
||||
<?php if ($offene === []): ?>
|
||||
<p>Keine offenen Zahlungen. Alles automatisch zugeordnet. 🎉</p>
|
||||
<p>Keine offenen Zahlungen. Alles automatisch zugeordnet.</p>
|
||||
<?php elseif ($offeneGefiltert === []): ?>
|
||||
<p>Keine offenen Zahlungen für diesen Filter gefunden.</p>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<tr><th>Eingang</th><th>Zahler (PayPal)</th><th>Mitteilung</th><th>Betrag</th><th>Zuordnen</th><th></th></tr>
|
||||
<?php foreach ($offene as $z): ?>
|
||||
<p><?php echo count($offeneGefiltert); ?> von <?php echo count($offene); ?> offenen Zahlungen angezeigt.</p>
|
||||
<div class="table-wrapper">
|
||||
<table class="admin-table">
|
||||
<tr><th>Eingang</th><th>Zahlung</th><th>Betrag</th><th>Vorschläge</th><th>Zuordnen</th><th></th></tr>
|
||||
<?php foreach ($offeneGefiltert as $z): ?>
|
||||
<?php
|
||||
$suggestions = imports_suggest_participants($pdo, $tenantId, (string)$z['payer_name'], (string)($z['note'] ?? ''), 4);
|
||||
$selectedId = count($suggestions) === 1 ? (int)$suggestions[0]['participant_id'] : 0;
|
||||
?>
|
||||
<tr>
|
||||
<td><?php echo saas_html((string)($z['paid_at'] ?? $z['created_at'])); ?></td>
|
||||
<td><?php echo saas_html($z['payer_name']); ?></td>
|
||||
<td><?php echo saas_html((string)($z['note'] ?? '')); ?></td>
|
||||
<td>
|
||||
<strong><?php echo saas_html($z['payer_name']); ?></strong>
|
||||
<?php if (($z['note'] ?? '') !== null && trim((string)$z['note']) !== ''): ?>
|
||||
<br><small><?php echo saas_html((string)$z['note']); ?></small>
|
||||
<?php endif; ?>
|
||||
<br><small>Transaktion: <?php echo saas_html((string)$z['transaction_code']); ?></small>
|
||||
</td>
|
||||
<td><?php echo saas_html(saas_format_money_cents((int)$z['net_cents'])); ?> €</td>
|
||||
<td>
|
||||
<form method="post" action="paypal-zuordnung.php" style="display:flex;gap:0.5em;align-items:center;margin:0">
|
||||
<?php if ($suggestions === []): ?>
|
||||
<span class="status-badge muted">Kein Vorschlag</span>
|
||||
<?php else: ?>
|
||||
<?php foreach ($suggestions as $suggestion): ?>
|
||||
<span class="status-badge info"><?php echo saas_html((string)$suggestion['display_name']); ?></span>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="paypal-zuordnung.php" class="inline-admin-form">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="zuordnen">
|
||||
<input type="hidden" name="payment_id" value="<?php echo (int)$z['id']; ?>">
|
||||
<input type="hidden" name="return_q" value="<?php echo saas_html($suchbegriff); ?>">
|
||||
<select name="participant_id" required>
|
||||
<option value="">– Mitglied wählen –</option>
|
||||
<?php foreach ($mitglieder as $m): ?>
|
||||
<option value="<?php echo (int)$m['participant_id']; ?>"><?php echo saas_html($m['display_name']); ?></option>
|
||||
<?php endforeach; ?>
|
||||
<?php echo paypal_select_options($mitglieder, $suggestions, $selectedId); ?>
|
||||
</select>
|
||||
<button type="submit">Buchen</button>
|
||||
</form>
|
||||
@@ -118,12 +209,14 @@ include "nav.php";
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="aktion" value="ignorieren">
|
||||
<input type="hidden" name="payment_id" value="<?php echo (int)$z['id']; ?>">
|
||||
<input type="hidden" name="return_q" value="<?php echo saas_html($suchbegriff); ?>">
|
||||
<button type="submit" class="alt">Ignorieren</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -43,6 +43,12 @@ function payment_matching_insert_participant(PDO $pdo, int $tenantId, string $di
|
||||
function payment_matching_cleanup(PDO $pdo, array $tenantIds): void
|
||||
{
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
$pdo->prepare(
|
||||
'DELETE r FROM payment_import_rows r
|
||||
JOIN payment_import_batches b ON b.id = r.batch_id
|
||||
WHERE b.tenant_id = ?'
|
||||
)->execute([$tenantId]);
|
||||
$pdo->prepare('DELETE FROM payment_import_batches WHERE tenant_id = ?')->execute([$tenantId]);
|
||||
$pdo->prepare('DELETE FROM participants WHERE tenant_id = ?')->execute([$tenantId]);
|
||||
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]);
|
||||
}
|
||||
@@ -139,6 +145,64 @@ try {
|
||||
$failures,
|
||||
$passes
|
||||
);
|
||||
|
||||
$suggestions = imports_suggest_participants($pdo, $tenantId, "paypal unique {$suffix}", null, 3);
|
||||
payment_matching_assert(
|
||||
'Vorschlaege enthalten den passenden Teilnehmer',
|
||||
$suggestions !== [] && $suggestions[0]['participant_id'] === $uniqueId,
|
||||
$failures,
|
||||
$passes
|
||||
);
|
||||
|
||||
$batchId = imports_create_batch($pdo, $tenantId, 'matching-test.csv', hash('sha256', $suffix), null);
|
||||
$unmatchedRowId = imports_store_row(
|
||||
$pdo,
|
||||
$batchId,
|
||||
1,
|
||||
null,
|
||||
"PayPal Unique {$suffix}",
|
||||
500,
|
||||
date('Y-m-d H:i:s'),
|
||||
'unmatched',
|
||||
['row' => 1]
|
||||
);
|
||||
$invalidRowId = imports_store_row(
|
||||
$pdo,
|
||||
$batchId,
|
||||
2,
|
||||
null,
|
||||
"Kaputt {$suffix}",
|
||||
0,
|
||||
date('Y-m-d H:i:s'),
|
||||
'invalid',
|
||||
['row' => 2]
|
||||
);
|
||||
|
||||
$assignResult = imports_assign_row($pdo, $tenantId, $batchId, $unmatchedRowId, $uniqueId);
|
||||
$stmt = $pdo->prepare('SELECT participant_id, status FROM payment_import_rows WHERE id = ?');
|
||||
$stmt->execute([$unmatchedRowId]);
|
||||
$assignedRow = $stmt->fetch();
|
||||
payment_matching_assert(
|
||||
'unmatched CSV-Zeile kann manuell zugeordnet werden',
|
||||
$assignResult['ok'] === true
|
||||
&& (int)$assignedRow['participant_id'] === $uniqueId
|
||||
&& (string)$assignedRow['status'] === 'matched',
|
||||
$failures,
|
||||
$passes
|
||||
);
|
||||
|
||||
$ignoreResult = imports_ignore_row($pdo, $tenantId, $batchId, $invalidRowId);
|
||||
$stmt = $pdo->prepare('SELECT participant_id, status FROM payment_import_rows WHERE id = ?');
|
||||
$stmt->execute([$invalidRowId]);
|
||||
$ignoredRow = $stmt->fetch();
|
||||
payment_matching_assert(
|
||||
'ungueltige CSV-Zeile kann ignoriert werden',
|
||||
$ignoreResult['ok'] === true
|
||||
&& $ignoredRow['participant_id'] === null
|
||||
&& (string)$ignoredRow['status'] === 'ignored',
|
||||
$failures,
|
||||
$passes
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
|
||||
+16
-1
@@ -164,7 +164,20 @@ if ($_SERVER["REQUEST_METHOD"] == "POST") {
|
||||
}
|
||||
}
|
||||
|
||||
$mitarbeiter = stricheintragen_fetch_participants($pdo, $tenantId, $action);
|
||||
// Vorder-/Rueckseite nur anbieten, wenn der Ausdruck auch wirklich zwei Seiten
|
||||
// hat - bei kleinen Teams gibt es nur ein Blatt und die Aufteilung waere eine
|
||||
// Auswahl ohne Entsprechung auf dem Papier. Muss vor dem Laden der Liste
|
||||
// stehen, damit ein alter Link auf "vorderseite" nicht doch eine halbe Liste
|
||||
// zeigt.
|
||||
$alleAktiven = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]);
|
||||
$zweiseitig = ledger_uses_two_sided_sheet(count($alleAktiven));
|
||||
if (!$zweiseitig) {
|
||||
$action = 'alle';
|
||||
}
|
||||
|
||||
$mitarbeiter = $action === 'alle'
|
||||
? $alleAktiven
|
||||
: stricheintragen_fetch_participants($pdo, $tenantId, $action);
|
||||
$settings = saas_fetch_tenant_settings($pdo, $tenantId);
|
||||
$kostenproStrichAnzeige = $kostenproStrichEingabe !== null
|
||||
? $kostenproStrichEingabe
|
||||
@@ -192,6 +205,7 @@ $kostenproStrichAnzeige = $kostenproStrichEingabe !== null
|
||||
<?php endif; ?>
|
||||
|
||||
<ul class="actions">
|
||||
<?php if ($zweiseitig): ?>
|
||||
<li>
|
||||
<form action="stricheintragen.php" method="get">
|
||||
<input type="hidden" name="action" value="vorderseite">
|
||||
@@ -204,6 +218,7 @@ $kostenproStrichAnzeige = $kostenproStrichEingabe !== null
|
||||
<button type="submit">Rückseite</button>
|
||||
</form>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<li>
|
||||
<form action="stricheintragen.php" method="get">
|
||||
<input type="hidden" name="action" value="alle">
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Liefert das Marken-Logo des eigenen Mandanten aus.
|
||||
*
|
||||
* Die Logo-Dateien liegen in var/tenant_logos und sind per .htaccess bewusst
|
||||
* nicht direkt abrufbar - sonst waere jedes Kundenlogo unter einer ratbaren
|
||||
* URL oeffentlich. Deshalb dieser Umweg: ausgeliefert wird ausschliesslich das
|
||||
* Logo des Mandanten, an dem der angemeldete Nutzer selbst haengt.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/app/tenant-logo.php';
|
||||
require_once __DIR__ . '/app/features.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$user = saas_current_user($pdo);
|
||||
if ($user === null) {
|
||||
http_response_code(403);
|
||||
exit('Kein Zugriff.');
|
||||
}
|
||||
|
||||
$tenantId = (int)$user['tenant_id'];
|
||||
if (!app_feature_enabled($pdo, $tenantId, 'branding')) {
|
||||
http_response_code(404);
|
||||
exit('Kein Logo hinterlegt.');
|
||||
}
|
||||
|
||||
$settings = saas_fetch_tenant_settings($pdo, $tenantId);
|
||||
$path = tenant_logo_path((string)($settings['brand_logo'] ?? ''));
|
||||
if ($path === null) {
|
||||
http_response_code(404);
|
||||
exit('Kein Logo hinterlegt.');
|
||||
}
|
||||
|
||||
$info = @getimagesize($path);
|
||||
$mime = $info !== false ? (string)$info['mime'] : 'application/octet-stream';
|
||||
if (!in_array($mime, ['image/png', 'image/jpeg'], true)) {
|
||||
http_response_code(404);
|
||||
exit('Kein Logo hinterlegt.');
|
||||
}
|
||||
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Content-Length: ' . (string)filesize($path));
|
||||
// Privat cachen: das Logo gehoert genau einem Mandanten und darf nicht in
|
||||
// einem geteilten Proxy-Cache landen.
|
||||
header('Cache-Control: private, max-age=3600');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
readfile($path);
|
||||
Reference in New Issue
Block a user