diff --git a/saas-app/public/admin.php b/saas-app/public/admin.php index f31e7be..6db4c3b 100644 --- a/saas-app/public/admin.php +++ b/saas-app/public/admin.php @@ -171,7 +171,7 @@ SELECT t.tenant_key, t.name, t.status, - COALESCE(lp.plan_key, 'team') AS plan_key + COALESCE(lp.plan_key, 'free') AS plan_key FROM tenants t LEFT JOIN tenant_licenses tl ON tl.tenant_id = t.id AND tl.status = 'active' LEFT JOIN license_plans lp ON lp.id = tl.license_plan_id @@ -182,7 +182,7 @@ SQL, ) : app_query_one( $pdo, - "SELECT id, tenant_key, name, status, 'team' AS plan_key FROM tenants WHERE id = :id LIMIT 1", + "SELECT id, tenant_key, name, status, 'free' AS plan_key FROM tenants WHERE id = :id LIMIT 1", ['id' => (string) $_GET['edit']] ); } @@ -320,7 +320,7 @@ SQL, - +
Zur Übersicht @@ -333,9 +333,9 @@ SQL,

Lizenzrahmen

-

Starter

Basisfunktionen wie Mitglieder, Buchungen, Einzahlungen und Inhalte.

-

Team

Zusätzlich Mandanten-Einstellungen, PDF-Listen, Papierlisten-Erfassung und Basis-Exporte.

-

Business & Enterprise

Vorbereitung für SSO, Importe, erweiterte Exporte, Benachrichtigungen und priorisierte Updates.

+

Free

Bis zu 10 aktive Mitglieder, inklusive Mandanten-Einstellungen, PDF-Listen und Papier-Nacherfassung.

+

Starter & Team

Mehr Mitglieder für wachsende Standorte, weiterhin mit vollem Drucklisten-Prozess als Kernfunktion.

+

Business & Enterprise

Zusätzliche Erweiterungen wie SSO, Benachrichtigungen, Auswertungen und individuelle Freischaltungen.

@@ -350,7 +350,7 @@ SQL, - + diff --git a/saas-app/public/app-support.php b/saas-app/public/app-support.php index 6bd108b..cce7947 100644 --- a/saas-app/public/app-support.php +++ b/saas-app/public/app-support.php @@ -29,6 +29,29 @@ function app_uses_mysql(): bool return in_array(app_connection(), ['mysql', 'mariadb'], true); } +function app_table_has_column(PDO $pdo, string $tableName, string $columnName): bool +{ + if (!scripts_table_exists($pdo, $tableName)) { + return false; + } + + return app_query_one( + $pdo, + <<<'SQL' +SELECT 1 +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = :table_name + AND COLUMN_NAME = :column_name +LIMIT 1 +SQL, + [ + 'table_name' => $tableName, + 'column_name' => $columnName, + ] + ) !== null; +} + function app_request_path(): string { $path = parse_url((string) ($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH); @@ -323,11 +346,11 @@ function app_handle_platform_admin_login(PDO $pdo): array function app_admin_tenant_list(PDO $pdo): array { - $licenseSelect = "'starter' AS plan_key, 'Starter' AS plan_name"; + $licenseSelect = "'free' AS plan_key, 'Free' AS plan_name"; $licenseJoins = ''; if (scripts_table_exists($pdo, 'tenant_licenses') && scripts_table_exists($pdo, 'license_plans')) { - $licenseSelect = "COALESCE(lp.plan_key, 'starter') AS plan_key, COALESCE(lp.name, 'Starter') AS plan_name"; + $licenseSelect = "COALESCE(lp.plan_key, 'free') AS plan_key, COALESCE(lp.name, 'Free') AS plan_name"; $licenseJoins = "\nLEFT JOIN tenant_licenses tl ON tl.tenant_id = t.id AND tl.status = 'active'\nLEFT JOIN license_plans lp ON lp.id = tl.license_plan_id"; } @@ -656,6 +679,33 @@ function app_upsert_member(PDO $pdo, string $tenantId, array $data): void throw new RuntimeException('Diese E-Mail-Adresse gehört bereits zu einer anderen Person im Mandanten.'); } + $license = app_tenant_license($pdo, $tenantId); + $memberLimit = isset($license['member_limit']) && (int) $license['member_limit'] > 0 + ? (int) $license['member_limit'] + : null; + + if ($memberLimit !== null) { + $activeMembers = (int) app_query_value( + $pdo, + 'SELECT COUNT(*) FROM members WHERE tenant_id = :tenant_id AND status = :status', + ['tenant_id' => $tenantId, 'status' => 'active'], + 0 + ); + $wasActive = $editingMembership !== null + && (string) ($editingMembership['member_status'] ?? $editingMembership['status'] ?? 'active') === 'active'; + $willBeActive = $status === 'active'; + + if ($willBeActive && !$wasActive && $activeMembers >= $memberLimit) { + throw new RuntimeException( + 'Das Mitgliederlimit des Lizenzplans ' + . (string) ($license['plan_name'] ?? 'Free') + . ' ist erreicht. Aktuell sind maximal ' + . $memberLimit + . ' aktive Mitglieder möglich.' + ); + } + } + scripts_ensure_core_roles($pdo); $tenantAdminRoleId = scripts_role_id('tenant_admin', 'tenant'); $now = date('Y-m-d H:i:s'); @@ -1067,21 +1117,84 @@ SQL, function app_print_list_pdf_groups(PDO $pdo, string $tenantId, string $listId): array { - $heavyRows = []; - $lightRows = []; + return app_print_list_pdf_groups_with_settings($pdo, $tenantId, $listId, app_tenant_settings($pdo, $tenantId)); +} - foreach (app_print_list_export_rows($pdo, $tenantId, $listId) as $row) { - if ((int) ($row['recent_strokes'] ?? 0) >= 10) { - $heavyRows[] = $row; - continue; +function app_print_list_row_height_mm(array $settings): float +{ + $height = (float) ($settings['pdf_row_height_mm'] ?? 4.0); + + return max(3.2, min(12.0, $height)); +} + +function app_print_list_layout(array $allRows, array $frontRows, array $backRows, array $settings): array +{ + $configuredHeight = app_print_list_row_height_mm($settings); + $maxTableHeightMm = 264.0; + $minimumHeight = 3.2; + $singleRequiredHeight = $maxTableHeightMm / max(1, count($allRows)); + + if ($singleRequiredHeight >= $minimumHeight) { + $autoHeight = min(12.0, max($configuredHeight, $singleRequiredHeight)); + + return [ + 'mode' => 'single', + 'row_height_mm' => $autoHeight, + 'rows_per_page' => max(1, (int) floor($maxTableHeightMm / $autoHeight)), + ]; + } + + $largestSide = max(1, (int) ceil(count($allRows) / 2)); + $duplexHeight = min($configuredHeight, $maxTableHeightMm / $largestSide); + $duplexHeight = max($minimumHeight, min(12.0, $duplexHeight)); + + return [ + 'mode' => 'duplex', + 'row_height_mm' => $duplexHeight, + 'rows_per_page' => max(1, (int) floor($maxTableHeightMm / $duplexHeight)), + ]; +} + +function app_print_list_pdf_groups_with_settings(PDO $pdo, string $tenantId, string $listId, array $settings): array +{ + $allRows = app_print_list_export_rows($pdo, $tenantId, $listId); + $rankedRows = $allRows; + + usort( + $rankedRows, + static function (array $left, array $right): int { + $strokeCompare = (int) ($right['recent_strokes'] ?? 0) <=> (int) ($left['recent_strokes'] ?? 0); + + if ($strokeCompare !== 0) { + return $strokeCompare; + } + + return strcasecmp((string) ($left['member_name'] ?? ''), (string) ($right['member_name'] ?? '')); } + ); - $lightRows[] = $row; + $splitIndex = (int) ceil(count($rankedRows) / 2); + $heavyRows = array_slice($rankedRows, 0, $splitIndex); + $lightRows = array_slice($rankedRows, $splitIndex); + + $layout = app_print_list_layout($allRows, $heavyRows, $lightRows, $settings); + + if ($layout['mode'] === 'single') { + return [ + 'mode' => 'single', + 'front' => $allRows, + 'back' => [], + 'row_height_mm' => $layout['row_height_mm'], + 'rows_per_page' => $layout['rows_per_page'], + ]; } return [ + 'mode' => 'duplex', 'front' => $heavyRows, 'back' => $lightRows, + 'row_height_mm' => $layout['row_height_mm'], + 'rows_per_page' => $layout['rows_per_page'], ]; } @@ -1199,7 +1312,7 @@ function app_pdf_truncate(string $text, int $maxLength): string return $text; } - return mb_substr($text, 0, $maxLength - 1, 'UTF-8') . '…'; + return mb_substr($text, 0, $maxLength - 3, 'UTF-8') . '...'; } if (strlen($text) <= $maxLength) { @@ -1209,7 +1322,7 @@ function app_pdf_truncate(string $text, int $maxLength): string return substr($text, 0, $maxLength - 3) . '...'; } -function app_render_print_list_pdf_page(string $headline, array $headerFill, array $headerText, string $metaText, array $rows, int $targetRows, ?string $sideNote = null): string +function app_render_print_list_pdf_page(string $headline, array $headerFill, array $headerText, string $metaText, array $rows, int $rowSlots, float $rowHeightMm, ?string $sideNote = null): string { $commands = "0.4 w\n"; $x = 5.0; @@ -1219,11 +1332,11 @@ function app_render_print_list_pdf_page(string $headline, array $headerFill, arr $strikeWidth = 140.0; $headerHeight = 9.0; $subHeaderHeight = 6.5; - $rowHeight = 4.0; + $rowHeight = max(3.2, min(12.0, $rowHeightMm)); $commands .= app_pdf_rect($x, $y, $nameWidth + $balanceWidth, $headerHeight, $headerFill); $commands .= app_pdf_rect($x + $nameWidth + $balanceWidth, $y, $strikeWidth, $headerHeight, $headerFill); - $commands .= app_pdf_text($x + 2, $y + 5.8, $headline, 'F2', 13, $headerText); + $commands .= app_pdf_text($x + 2, $y + 5.8, app_pdf_truncate($headline, 34), 'F2', 13, $headerText); $commands .= app_pdf_text($x + $nameWidth + $balanceWidth + 2, $y + 5.8, app_pdf_truncate($metaText, 86), 'F1', 9, $headerText); $y += $headerHeight; @@ -1235,10 +1348,13 @@ function app_render_print_list_pdf_page(string $headline, array $headerFill, arr $commands .= app_pdf_text($x + $nameWidth + $balanceWidth + 2, $y + 4.4, 'Striche', 'F2', 9.5); $y += $subHeaderHeight; - $rowCount = 1; + $noteRows = ($sideNote !== null && $sideNote !== '') ? 1 : 0; + $dataRows = max(0, $rowSlots - $noteRows); + $baselineY = min($rowHeight - 1.1, 2.9 + max(0.0, ($rowHeight - 4.0) * 0.45)); + $renderedRows = 0; - foreach ($rows as $row) { - $rowCount++; + foreach (array_slice($rows, 0, $dataRows) as $row) { + $renderedRows++; $balance = (float) ($row['balance'] ?? 0); $balanceFill = null; $balanceTextColor = [0, 0, 0]; @@ -1254,23 +1370,24 @@ function app_render_print_list_pdf_page(string $headline, array $headerFill, arr $commands .= app_pdf_rect($x, $y, $nameWidth, $rowHeight); $commands .= app_pdf_rect($x + $nameWidth, $y, $balanceWidth, $rowHeight, $balanceFill); $commands .= app_pdf_rect($x + $nameWidth + $balanceWidth, $y, $strikeWidth, $rowHeight); - $commands .= app_pdf_text($x + 1.5, $y + 2.9, app_pdf_truncate((string) ($row['member_name'] ?? ''), 28), 'F1', 9); - $commands .= app_pdf_text($x + $nameWidth + 1.5, $y + 2.9, app_pdf_money($balance), 'F1', 8.5, $balanceTextColor); + $commands .= app_pdf_text($x + 1.5, $y + $baselineY, app_pdf_truncate((string) ($row['member_name'] ?? ''), 28), 'F1', 9); + $commands .= app_pdf_text($x + $nameWidth + 1.5, $y + $baselineY, app_pdf_money($balance), 'F1', 8.5, $balanceTextColor); $y += $rowHeight; } - for ($i = $rowCount; $i < $targetRows; $i++) { + while ($renderedRows < $dataRows) { $commands .= app_pdf_rect($x, $y, $nameWidth, $rowHeight); $commands .= app_pdf_rect($x + $nameWidth, $y, $balanceWidth, $rowHeight); $commands .= app_pdf_rect($x + $nameWidth + $balanceWidth, $y, $strikeWidth, $rowHeight); $y += $rowHeight; + $renderedRows++; } if ($sideNote !== null && $sideNote !== '') { $commands .= app_pdf_rect($x, $y, $nameWidth, $rowHeight); $commands .= app_pdf_rect($x + $nameWidth, $y, $balanceWidth, $rowHeight); $commands .= app_pdf_rect($x + $nameWidth + $balanceWidth, $y, $strikeWidth, $rowHeight, [255, 255, 0]); - $commands .= app_pdf_text($x + $nameWidth + $balanceWidth + 88, $y + 2.9, $sideNote, 'F2', 8.5); + $commands .= app_pdf_text($x + $nameWidth + $balanceWidth + 82, $y + $baselineY, $sideNote, 'F2', 8.5); } return $commands; @@ -1280,8 +1397,8 @@ function app_build_simple_pdf(array $pageStreams): string { $objects = []; $objects[1] = "<< /Type /Catalog /Pages 2 0 R >>"; - $objects[3] = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"; - $objects[4] = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>"; + $objects[3] = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; + $objects[4] = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>"; $kids = []; $nextObject = 5; @@ -1328,36 +1445,48 @@ function app_send_print_list_pdf(PDO $pdo, string $tenantId, string $listId): vo } $tenant = app_tenant_by_id($pdo, $tenantId) ?? ['name' => 'Kaffeeliste']; - $groups = app_print_list_pdf_groups($pdo, $tenantId, $listId); - $heavyRows = $groups['front']; - $lightRows = $groups['back']; + $settings = app_tenant_settings($pdo, $tenantId); + $groups = app_print_list_pdf_groups_with_settings($pdo, $tenantId, $listId, $settings); + $frontRows = $groups['front']; + $backRows = $groups['back']; + $rowSlots = max(1, (int) ($groups['rows_per_page'] ?? 60)); + $rowHeight = (float) ($groups['row_height_mm'] ?? app_print_list_row_height_mm($settings)); $unitPrice = (float) ($list['unit_price'] ?? 0.50); $meta = '1 Strich = ' . app_pdf_money($unitPrice) . '. Bitte spätestens bei 10,00 € einzahlen. ' . date('d.m.Y H:i') . ' | ' . (string) ($tenant['name'] ?? 'Kaffeeliste'); $pageStreams = []; - if ($heavyRows !== []) { + if (($groups['mode'] ?? 'single') === 'single') { $pageStreams[] = app_render_print_list_pdf_page( - 'Kaffeeliste - Vieltrinker', - [0, 94, 63], - [255, 255, 255], - $meta, - $heavyRows, - 64, - $lightRows !== [] ? 'Rückseite beachten!' : null + (string) ($list['title'] ?? ($settings['pdf_list_title'] ?? 'Kaffeeliste')), + [0, 94, 63], + [255, 255, 255], + $meta, + $frontRows, + $rowSlots, + $rowHeight ); - } - - if ($lightRows !== [] || $heavyRows === []) { + } else { $pageStreams[] = app_render_print_list_pdf_page( - 'Kaffeeliste - Wenigtrinker', - [91, 209, 215], - [0, 0, 0], - $meta, - $lightRows, - 65, - $heavyRows !== [] ? 'Vorderseite beachten!' : null + (string) ($settings['front_page_label'] ?? 'Vorderseite') . ' - Vieltrinker', + [0, 94, 63], + [255, 255, 255], + $meta, + $frontRows, + $rowSlots, + $rowHeight, + $backRows !== [] ? 'Rückseite beachten!' : null + ); + $pageStreams[] = app_render_print_list_pdf_page( + (string) ($settings['back_page_label'] ?? 'Rückseite') . ' - Wenigtrinker', + [91, 209, 215], + [0, 0, 0], + $meta, + $backRows, + $rowSlots, + $rowHeight, + $frontRows !== [] ? 'Vorderseite beachten!' : null ); } @@ -1852,9 +1981,9 @@ function app_feature_defaults(): array 'ledger' => true, 'payments' => true, 'content' => true, - 'tenant_settings' => false, - 'pdf_export' => false, - 'paper_strike_entry' => false, + 'tenant_settings' => true, + 'pdf_export' => true, + 'paper_strike_entry' => true, 'basic_exports' => false, 'oidc' => false, 'imports' => false, @@ -1872,25 +2001,32 @@ function app_tenant_license(PDO $pdo, string $tenantId): array { if (!app_supports_license_features($pdo)) { return [ - 'plan_key' => 'starter', - 'plan_name' => 'Starter', + 'plan_key' => 'free', + 'plan_name' => 'Free', + 'member_limit' => 10, 'features' => app_feature_defaults(), ]; } + $hasMemberLimitColumn = app_table_has_column($pdo, 'license_plans', 'member_limit'); $row = app_query_one( $pdo, - <<<'SQL' + str_replace( + '__MEMBER_LIMIT__', + $hasMemberLimitColumn ? 'lp.member_limit' : 'NULL AS member_limit', + <<<'SQL' SELECT lp.id AS plan_id, lp.plan_key, - lp.name AS plan_name + lp.name AS plan_name, + __MEMBER_LIMIT__ FROM tenant_licenses tl INNER JOIN license_plans lp ON lp.id = tl.license_plan_id WHERE tl.tenant_id = :tenant_id AND tl.status = 'active' LIMIT 1 SQL, + ), ['tenant_id' => $tenantId] ); @@ -1898,8 +2034,9 @@ SQL, if ($row === null) { return [ - 'plan_key' => 'starter', - 'plan_name' => 'Starter', + 'plan_key' => 'free', + 'plan_name' => 'Free', + 'member_limit' => 10, 'features' => $features, ]; } @@ -1933,6 +2070,7 @@ SQL, return [ 'plan_key' => (string) $row['plan_key'], 'plan_name' => (string) $row['plan_name'], + 'member_limit' => isset($row['member_limit']) ? (int) $row['member_limit'] : null, 'features' => $features, ]; } @@ -1950,9 +2088,16 @@ function app_license_plan_options(PDO $pdo): array return []; } + if (!app_table_has_column($pdo, 'license_plans', 'member_limit')) { + return app_query_all( + $pdo, + 'SELECT id, plan_key, name, NULL AS member_limit FROM license_plans WHERE is_active = 1 ORDER BY sort_order ASC, name ASC' + ); + } + return app_query_all( $pdo, - 'SELECT id, plan_key, name FROM license_plans WHERE is_active = 1 ORDER BY sort_order ASC, name ASC' + 'SELECT id, plan_key, name, member_limit FROM license_plans WHERE is_active = 1 ORDER BY sort_order ASC, name ASC' ); } @@ -2008,6 +2153,7 @@ function app_tenant_settings_defaults(): array 'pdf_list_title' => 'Kaffeeliste', 'front_page_label' => 'Vorderseite', 'back_page_label' => 'Rückseite', + 'pdf_row_height_mm' => '4.00', 'support_email' => '', 'location_label' => '', 'allow_self_service_booking' => '1', @@ -2041,6 +2187,7 @@ function app_save_tenant_settings(PDO $pdo, string $tenantId, array $data): void 'pdf_list_title' => trim((string) ($data['pdf_list_title'] ?? 'Kaffeeliste')), 'front_page_label' => trim((string) ($data['front_page_label'] ?? 'Vorderseite')), 'back_page_label' => trim((string) ($data['back_page_label'] ?? 'Rückseite')), + 'pdf_row_height_mm' => trim((string) ($data['pdf_row_height_mm'] ?? '4.00')), 'support_email' => trim((string) ($data['support_email'] ?? '')), 'location_label' => trim((string) ($data['location_label'] ?? '')), 'allow_self_service_booking' => !empty($data['allow_self_service_booking']) ? '1' : '0', @@ -2051,6 +2198,10 @@ function app_save_tenant_settings(PDO $pdo, string $tenantId, array $data): void throw new RuntimeException('Bitte gib einen gültigen Standardpreis pro Strich an.'); } + if (!is_numeric($values['pdf_row_height_mm']) || (float) $values['pdf_row_height_mm'] < 3.2 || (float) $values['pdf_row_height_mm'] > 12.0) { + throw new RuntimeException('Bitte gib eine gültige Zeilenhöhe zwischen 3,2 mm und 12,0 mm an.'); + } + if ($values['support_email'] !== '' && !filter_var($values['support_email'], FILTER_VALIDATE_EMAIL)) { throw new RuntimeException('Bitte gib eine gültige Support-E-Mail-Adresse an.'); } diff --git a/saas-app/public/index.php b/saas-app/public/index.php index b304a78..39b04b6 100644 --- a/saas-app/public/index.php +++ b/saas-app/public/index.php @@ -84,7 +84,7 @@ $memberSummary = null; $loginFlow = ['state' => app_login_state(), 'message' => null, 'error' => null]; $editingMember = null; $memberForm = app_member_form_defaults(); -$tenantLicense = ['plan_key' => 'starter', 'plan_name' => 'Starter', 'features' => app_feature_defaults()]; +$tenantLicense = ['plan_key' => 'free', 'plan_name' => 'Free', 'member_limit' => 10, 'features' => app_feature_defaults()]; $tenantSettings = app_tenant_settings_defaults(); $printLists = []; $activePrintList = null; @@ -251,7 +251,7 @@ $canManageTenant = app_can_manage_tenant($auth); ist angemeldet als - +
@@ -605,6 +605,7 @@ $canManageTenant = app_can_manage_tenant($auth); +