From e6146da778d5cda62c30e9bc2dadc1c9b33e03bf Mon Sep 17 00:00:00 2001 From: Clemens Creutzburg Date: Thu, 9 Apr 2026 00:24:05 +0200 Subject: [PATCH] Jahresabschluss und Benachrichitgungseinrichtung --- ...001_create_notification_messages_table.php | 20 + ...00001_create_survey_publications_table.php | 22 + ...4_09_000001_create_year_end_runs_table.php | 26 + ...audit_status_to_finance_entries_tables.php | 21 + saas-app/public/app-support.php | 1065 ++++++++++++++++- saas-app/public/index.php | 340 +++++- saas-app/public/notifications/index.php | 7 + saas-app/public/surveys/index.php | 416 ++++++- saas-app/public/year-end/index.php | 7 + .../views/notifications/index.blade.php | 205 +++- 10 files changed, 1986 insertions(+), 143 deletions(-) create mode 100644 saas-app/database/migrations/2026_04_09_000001_create_notification_messages_table.php create mode 100644 saas-app/database/migrations/2026_04_09_000001_create_survey_publications_table.php create mode 100644 saas-app/database/migrations/2026_04_09_000001_create_year_end_runs_table.php create mode 100644 saas-app/database/migrations/2026_04_09_000002_add_audit_status_to_finance_entries_tables.php create mode 100644 saas-app/public/notifications/index.php create mode 100644 saas-app/public/year-end/index.php diff --git a/saas-app/database/migrations/2026_04_09_000001_create_notification_messages_table.php b/saas-app/database/migrations/2026_04_09_000001_create_notification_messages_table.php new file mode 100644 index 0000000..148b588 --- /dev/null +++ b/saas-app/database/migrations/2026_04_09_000001_create_notification_messages_table.php @@ -0,0 +1,20 @@ + 'ledger', 'label' => 'Buchungen', 'href' => '/ledger/']; $items[] = ['key' => 'payments', 'label' => 'Zahlungen', 'href' => '/payments/']; $items[] = ['key' => 'imports', 'label' => 'Importe', 'href' => '/imports/']; + $items[] = ['key' => 'year-end', 'label' => 'Jahresabschluss', 'href' => '/year-end/']; } - if ($canManage) { + if ($canManage || app_can_manage_support($auth)) { $items[] = ['key' => 'content', 'label' => 'Hinweise & FAQ', 'href' => '/content/']; + $items[] = ['key' => 'notifications', 'label' => 'Benachrichtigungen', 'href' => '/notifications/']; $items[] = ['key' => 'reports', 'label' => 'Reporting', 'href' => '/reports/']; if (!empty($features['tenant_settings'])) { @@ -462,7 +464,9 @@ function app_tenant_navigation_groups(array $items): array 'imports' => 'data', 'exports' => 'data', 'reports' => 'data', + 'year-end' => 'data', 'content' => 'content', + 'notifications' => 'content', 'support' => 'content', 'surveys' => 'content', 'members' => 'management', @@ -2367,8 +2371,23 @@ SQL; ]; } +function app_finance_entries_support_status(PDO $pdo, string $table): bool +{ + return scripts_table_exists($pdo, $table) + && app_table_has_column($pdo, $table, 'status'); +} + +function app_finance_entry_active_sql(PDO $pdo, string $table, string $alias): string +{ + return app_finance_entries_support_status($pdo, $table) + ? 'COALESCE(' . $alias . '.status, \'booked\') = \'booked\'' + : '1 = 1'; +} + function app_tenant_dashboard(PDO $pdo, string $tenantId): array { + $coffeeActiveSql = app_finance_entry_active_sql($pdo, 'coffee_entries', 'ce'); + $paymentActiveSql = app_finance_entry_active_sql($pdo, 'payment_entries', 'pe'); $summarySql = <<<'SQL' SELECT COUNT(DISTINCT CASE WHEN m.status = 'active' THEN m.id END) AS active_members, @@ -2386,33 +2405,41 @@ SQL; $yearSummary = app_query_one( $pdo, - <<<'SQL' + str_replace( + ['__COFFEE_ACTIVE__', '__PAYMENT_ACTIVE__'], + [$coffeeActiveSql, $paymentActiveSql], + <<<'SQL' SELECT COALESCE(( SELECT SUM(ce.strokes) FROM coffee_entries ce WHERE ce.tenant_id = :tenant_id + AND __COFFEE_ACTIVE__ AND YEAR(ce.booked_at) = YEAR(CURRENT_DATE()) ), 0) AS year_strokes, COALESCE(( SELECT SUM(ce.total_cost) FROM coffee_entries ce WHERE ce.tenant_id = :tenant_id + AND __COFFEE_ACTIVE__ AND YEAR(ce.booked_at) = YEAR(CURRENT_DATE()) ), 0) AS year_spend, COALESCE(( SELECT SUM(pe.amount) FROM payment_entries pe WHERE pe.tenant_id = :tenant_id + AND __PAYMENT_ACTIVE__ AND YEAR(pe.booked_at) = YEAR(CURRENT_DATE()) ), 0) AS year_payments, COALESCE(( SELECT COUNT(DISTINCT ce.member_id) FROM coffee_entries ce WHERE ce.tenant_id = :tenant_id + AND __COFFEE_ACTIVE__ AND YEAR(ce.booked_at) = YEAR(CURRENT_DATE()) ), 0) AS year_active_members SQL, + ), ['tenant_id' => $tenantId] ) ?? []; @@ -2430,7 +2457,10 @@ ORDER BY le.booked_at DESC LIMIT 10 SQL; - $memberOverviewSql = <<<'SQL' + $memberOverviewSql = str_replace( + ['__COFFEE_ACTIVE__', '__PAYMENT_ACTIVE__'], + [$coffeeActiveSql, $paymentActiveSql], + <<<'SQL' SELECT m.id, m.display_name, @@ -2440,6 +2470,7 @@ SELECT FROM coffee_entries ce WHERE ce.member_id = m.id AND ce.tenant_id = m.tenant_id + AND __COFFEE_ACTIVE__ AND YEAR(ce.booked_at) = YEAR(CURRENT_DATE()) ), 0) AS year_strokes, COALESCE(( @@ -2447,6 +2478,7 @@ SELECT FROM payment_entries pe WHERE pe.member_id = m.id AND pe.tenant_id = m.tenant_id + AND __PAYMENT_ACTIVE__ AND YEAR(pe.booked_at) = YEAR(CURRENT_DATE()) ), 0) AS year_payments, MAX(le.booked_at) AS last_booking_at @@ -2457,7 +2489,8 @@ WHERE m.tenant_id = :tenant_id GROUP BY m.id, m.display_name ORDER BY balance ASC, m.display_name ASC LIMIT 12 -SQL; +SQL + ); $memberRows = app_query_all($pdo, $memberOverviewSql, ['tenant_id' => $tenantId]); $largestDebtors = array_values(array_filter( @@ -2500,6 +2533,8 @@ function app_member_summary(PDO $pdo, array $auth): array { $tenantId = $auth['tenant_id']; $memberId = $auth['member_id']; + $coffeeActiveSql = app_finance_entry_active_sql($pdo, 'coffee_entries', 'ce'); + $paymentActiveSql = app_finance_entry_active_sql($pdo, 'payment_entries', 'pe'); if ($memberId === null || $memberId === '') { return [ @@ -2511,7 +2546,10 @@ function app_member_summary(PDO $pdo, array $auth): array ]; } - $summarySql = <<<'SQL' + $summarySql = str_replace( + ['__COFFEE_ACTIVE__', '__PAYMENT_ACTIVE__'], + [$coffeeActiveSql, $paymentActiveSql], + <<<'SQL' SELECT COALESCE(SUM(le.amount), 0) AS balance, ( @@ -2519,6 +2557,7 @@ SELECT FROM coffee_entries ce WHERE ce.member_id = :member_id AND ce.tenant_id = :tenant_id + AND __COFFEE_ACTIVE__ AND DATE_FORMAT(ce.booked_at, '%Y-%m') = DATE_FORMAT(CURRENT_DATE(), '%Y-%m') ) AS coffee_strokes_this_month, ( @@ -2526,13 +2565,15 @@ SELECT FROM payment_entries pe WHERE pe.member_id = :member_id AND pe.tenant_id = :tenant_id + AND __PAYMENT_ACTIVE__ AND DATE_FORMAT(pe.booked_at, '%Y-%m') = DATE_FORMAT(CURRENT_DATE(), '%Y-%m') ) AS payments_this_month, MAX(le.booked_at) AS latest_booking_at FROM ledger_entries le WHERE le.member_id = :member_id AND le.tenant_id = :tenant_id -SQL; +SQL + ); $summary = app_query_one($pdo, $summarySql, [ 'member_id' => $memberId, @@ -2566,6 +2607,9 @@ function app_member_report(PDO $pdo, string $tenantId, string $memberId): ?array return null; } + $coffeeActiveSql = app_finance_entry_active_sql($pdo, 'coffee_entries', 'ce'); + $paymentActiveSql = app_finance_entry_active_sql($pdo, 'payment_entries', 'pe'); + $member = app_query_one( $pdo, 'SELECT id, display_name, email, status FROM members WHERE tenant_id = :tenant_id AND id = :id LIMIT 1', @@ -2578,7 +2622,10 @@ function app_member_report(PDO $pdo, string $tenantId, string $memberId): ?array $member['summary'] = app_query_one( $pdo, - <<<'SQL' + str_replace( + ['__COFFEE_ACTIVE__', '__PAYMENT_ACTIVE__'], + [$coffeeActiveSql, $paymentActiveSql], + <<<'SQL' SELECT COALESCE(SUM(le.amount), 0) AS balance, COALESCE(( @@ -2586,24 +2633,28 @@ SELECT FROM coffee_entries ce WHERE ce.tenant_id = :tenant_id AND ce.member_id = :member_id + AND __COFFEE_ACTIVE__ ), 0) AS total_strokes, COALESCE(( SELECT SUM(ce.total_cost) FROM coffee_entries ce WHERE ce.tenant_id = :tenant_id AND ce.member_id = :member_id + AND __COFFEE_ACTIVE__ ), 0) AS total_coffee_cost, COALESCE(( SELECT SUM(pe.amount) FROM payment_entries pe WHERE pe.tenant_id = :tenant_id AND pe.member_id = :member_id + AND __PAYMENT_ACTIVE__ ), 0) AS total_payments, COALESCE(( SELECT SUM(ce.strokes) FROM coffee_entries ce WHERE ce.tenant_id = :tenant_id AND ce.member_id = :member_id + AND __COFFEE_ACTIVE__ AND YEAR(ce.booked_at) = YEAR(CURRENT_DATE()) ), 0) AS year_strokes, COALESCE(( @@ -2611,6 +2662,7 @@ SELECT FROM coffee_entries ce WHERE ce.tenant_id = :tenant_id AND ce.member_id = :member_id + AND __COFFEE_ACTIVE__ AND YEAR(ce.booked_at) = YEAR(CURRENT_DATE()) ), 0) AS year_coffee_cost, COALESCE(( @@ -2618,12 +2670,14 @@ SELECT FROM payment_entries pe WHERE pe.tenant_id = :tenant_id AND pe.member_id = :member_id + AND __PAYMENT_ACTIVE__ AND YEAR(pe.booked_at) = YEAR(CURRENT_DATE()) ), 0) AS year_payments FROM ledger_entries le WHERE le.tenant_id = :tenant_id AND le.member_id = :member_id SQL, + ), ['tenant_id' => $tenantId, 'member_id' => $memberId] ) ?? []; @@ -2643,6 +2697,393 @@ SQL, return $member; } +function app_year_end_year_range(int $year): array +{ + $year = max(1970, $year); + + return [ + sprintf('%04d-01-01 00:00:00', $year), + sprintf('%04d-01-01 00:00:00', $year + 1), + ]; +} + +function app_year_end_member_rows(PDO $pdo, string $tenantId, int $year): array +{ + [$yearStart, $yearEnd] = app_year_end_year_range($year); + $coffeeActiveSql = app_finance_entry_active_sql($pdo, 'coffee_entries', 'ce'); + $paymentActiveSql = app_finance_entry_active_sql($pdo, 'payment_entries', 'pe'); + + return app_query_all( + $pdo, + str_replace( + ['__COFFEE_ACTIVE__', '__PAYMENT_ACTIVE__'], + [$coffeeActiveSql, $paymentActiveSql], + <<<'SQL' +SELECT + m.id, + m.display_name, + m.email, + COALESCE(SUM(le.amount), 0) AS current_balance, + COALESCE(( + SELECT SUM(ce.strokes) + FROM coffee_entries ce + WHERE ce.tenant_id = m.tenant_id + AND ce.member_id = m.id + AND __COFFEE_ACTIVE__ + AND ce.booked_at >= :year_start + AND ce.booked_at < :year_end + ), 0) AS year_strokes, + COALESCE(( + SELECT SUM(ce.total_cost) + FROM coffee_entries ce + WHERE ce.tenant_id = m.tenant_id + AND ce.member_id = m.id + AND __COFFEE_ACTIVE__ + AND ce.booked_at >= :year_start + AND ce.booked_at < :year_end + ), 0) AS year_cost, + COALESCE(( + SELECT SUM(pe.amount) + FROM payment_entries pe + WHERE pe.tenant_id = m.tenant_id + AND pe.member_id = m.id + AND __PAYMENT_ACTIVE__ + AND pe.booked_at >= :year_start + AND pe.booked_at < :year_end + ), 0) AS year_payments +FROM members m +LEFT JOIN ledger_entries le ON le.tenant_id = m.tenant_id AND le.member_id = m.id +WHERE m.tenant_id = :tenant_id + AND m.status = 'active' +GROUP BY m.id, m.display_name, m.email +ORDER BY m.display_name ASC +SQL, + ), + [ + 'tenant_id' => $tenantId, + 'year_start' => $yearStart, + 'year_end' => $yearEnd, + ] + ); +} + +/** + * @param array> $rows + * @return array{allocations: array, total_cents: int} + */ +function app_year_end_allocate_amounts(array $rows, string $mode, int $poolCents, int $flatCents): array +{ + $allocations = []; + $totalCents = 0; + + if ($mode === 'flat') { + foreach ($rows as $index => $_row) { + $allocations[(int) $index] = $flatCents; + $totalCents += $flatCents; + } + + return ['allocations' => $allocations, 'total_cents' => $totalCents]; + } + + $totalStrokes = 0; + foreach ($rows as $row) { + $totalStrokes += max(0, (int) ($row['year_strokes'] ?? 0)); + } + + if ($totalStrokes <= 0) { + foreach (array_keys($rows) as $index) { + $allocations[(int) $index] = 0; + } + + return ['allocations' => $allocations, 'total_cents' => 0]; + } + + $remainders = []; + foreach ($rows as $index => $row) { + $strokes = max(0, (int) ($row['year_strokes'] ?? 0)); + $rawCents = ($poolCents * $strokes) / $totalStrokes; + $floorCents = (int) floor($rawCents); + $allocations[(int) $index] = $floorCents; + $remainders[(int) $index] = $rawCents - $floorCents; + $totalCents += $floorCents; + } + + $remaining = max(0, $poolCents - $totalCents); + arsort($remainders); + foreach (array_keys($remainders) as $index) { + if ($remaining <= 0) { + break; + } + + $allocations[(int) $index]++; + $remaining--; + $totalCents++; + } + + return ['allocations' => $allocations, 'total_cents' => $totalCents]; +} + +function app_year_end_run_exists(PDO $pdo, string $tenantId, int $year): ?array +{ + if (!scripts_table_exists($pdo, 'year_end_runs')) { + return null; + } + + return app_query_one( + $pdo, + 'SELECT id, year, status, executed_at FROM year_end_runs WHERE tenant_id = :tenant_id AND year = :year LIMIT 1', + [ + 'tenant_id' => $tenantId, + 'year' => $year, + ] + ); +} + +function app_year_end_preview(PDO $pdo, string $tenantId, array $input): array +{ + $year = (int) ($input['year'] ?? ((int) date('Y') - 1)); + if ($year < 2000) { + $year = (int) date('Y') - 1; + } + + $mode = (string) ($input['bonus_mode'] ?? 'proportional'); + if (!in_array($mode, ['proportional', 'flat'], true)) { + $mode = 'proportional'; + } + + $poolAmount = max(0.0, (float) ($input['bonus_pool_amount'] ?? 0)); + $flatAmount = max(0.0, (float) ($input['bonus_per_member_amount'] ?? 0)); + $bookedAt = app_normalize_datetime_input((string) ($input['booked_at'] ?? '')); + $notes = trim((string) ($input['notes'] ?? '')); + + $rows = app_year_end_member_rows($pdo, $tenantId, $year); + if ($rows === []) { + throw new RuntimeException('Für den gewählten Zeitraum wurden keine aktiven Mitglieder gefunden.'); + } + + if ($mode === 'proportional' && $poolAmount <= 0) { + throw new RuntimeException('Bitte gib für die proportionale Verteilung einen Bonus-Topf größer 0 an.'); + } + + if ($mode === 'flat' && $flatAmount <= 0) { + throw new RuntimeException('Bitte gib für die fixe Verteilung einen Bonus pro Mitglied größer 0 an.'); + } + + $totalStrokes = 0; + foreach ($rows as $row) { + $totalStrokes += max(0, (int) ($row['year_strokes'] ?? 0)); + } + + if ($mode === 'proportional' && $totalStrokes <= 0) { + throw new RuntimeException('Für die proportionale Verteilung braucht es mindestens einen Jahresstrich.'); + } + + $poolCents = (int) round($poolAmount * 100); + $flatCents = (int) round($flatAmount * 100); + $allocationResult = app_year_end_allocate_amounts($rows, $mode, $poolCents, $flatCents); + $allocations = $allocationResult['allocations']; + $assignedCents = $allocationResult['total_cents']; + $recipientCount = 0; + $previewRows = []; + + foreach ($rows as $index => $row) { + $strokes = max(0, (int) ($row['year_strokes'] ?? 0)); + $bonusCents = (int) ($allocations[(int) $index] ?? 0); + $currentBalance = (float) ($row['current_balance'] ?? 0); + $bonusAmount = $bonusCents / 100; + + if ($bonusCents > 0) { + $recipientCount++; + } + + $sharePercent = $mode === 'proportional' && $totalStrokes > 0 + ? (($strokes / $totalStrokes) * 100) + : null; + + $previewRows[] = [ + 'member_id' => (string) ($row['id'] ?? ''), + 'display_name' => (string) ($row['display_name'] ?? ''), + 'email' => (string) ($row['email'] ?? ''), + 'year_strokes' => $strokes, + 'year_cost' => (float) ($row['year_cost'] ?? 0), + 'year_payments' => (float) ($row['year_payments'] ?? 0), + 'current_balance' => $currentBalance, + 'bonus_cents' => $bonusCents, + 'bonus_amount' => number_format($bonusAmount, 2, '.', ''), + 'projected_balance' => number_format($currentBalance + $bonusAmount, 2, '.', ''), + 'share_percent' => $sharePercent === null ? null : number_format($sharePercent, 2, '.', ''), + ]; + } + + return [ + 'year' => $year, + 'mode' => $mode, + 'bonus_pool_amount' => number_format($poolAmount, 2, '.', ''), + 'bonus_per_member_amount' => number_format($flatAmount, 2, '.', ''), + 'booked_at' => $bookedAt, + 'notes' => $notes, + 'total_strokes' => $totalStrokes, + 'member_count' => count($rows), + 'recipient_count' => $recipientCount, + 'total_amount' => number_format($assignedCents / 100, 2, '.', ''), + 'rows' => $previewRows, + ]; +} + +function app_year_end_execute(PDO $pdo, array $auth, array $input): array +{ + $tenantId = (string) ($auth['tenant_id'] ?? ''); + $userId = (string) ($auth['user_id'] ?? ''); + + if ($tenantId === '' || $userId === '') { + throw new RuntimeException('Der Jahresabschluss konnte nicht gestartet werden.'); + } + + if (!scripts_table_exists($pdo, 'year_end_runs')) { + throw new RuntimeException('Die Jahresabschluss-Tabelle fehlt noch. Bitte die Migrationen ausführen.'); + } + + $preview = app_year_end_preview($pdo, $tenantId, $input); + + if (app_year_end_run_exists($pdo, $tenantId, (int) $preview['year']) !== null) { + throw new RuntimeException('Für dieses Jahr wurde bereits ein Jahresabschluss verbucht.'); + } + + $runId = app_uuid(); + $now = date('Y-m-d H:i:s'); + $paymentCount = 0; + + $pdo->beginTransaction(); + + try { + app_execute( + $pdo, + <<<'SQL' +INSERT INTO year_end_runs ( + id, + tenant_id, + year, + bonus_mode, + bonus_pool_amount, + bonus_per_member_amount, + booked_at, + total_strokes, + recipient_count, + total_amount, + preview_json, + notes, + status, + created_by_user_id, + created_at, + updated_at, + executed_at +) VALUES ( + :id, + :tenant_id, + :year, + :bonus_mode, + :bonus_pool_amount, + :bonus_per_member_amount, + :booked_at, + :total_strokes, + :recipient_count, + :total_amount, + :preview_json, + :notes, + :status, + :created_by_user_id, + :created_at, + :updated_at, + :executed_at +) +SQL, + [ + 'id' => $runId, + 'tenant_id' => $tenantId, + 'year' => (int) $preview['year'], + 'bonus_mode' => (string) $preview['mode'], + 'bonus_pool_amount' => $preview['bonus_pool_amount'], + 'bonus_per_member_amount' => $preview['bonus_per_member_amount'], + 'booked_at' => $preview['booked_at'], + 'total_strokes' => (int) $preview['total_strokes'], + 'recipient_count' => (int) $preview['recipient_count'], + 'total_amount' => $preview['total_amount'], + 'preview_json' => json_encode($preview, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'notes' => (string) ($preview['notes'] ?? ''), + 'status' => 'executed', + 'created_by_user_id' => $userId, + 'created_at' => $now, + 'updated_at' => $now, + 'executed_at' => $now, + ] + ); + + foreach (($preview['rows'] ?? []) as $row) { + $bonus = (float) ($row['bonus_amount'] ?? 0); + if ($bonus <= 0) { + continue; + } + + app_create_payment_booking( + $pdo, + $tenantId, + (string) ($row['member_id'] ?? ''), + $bonus, + (string) $preview['booked_at'], + 'year_end_bonus' + ); + $paymentCount++; + } + + $pdo->commit(); + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + throw $exception; + } + + return [ + 'run_id' => $runId, + 'preview' => $preview, + 'payment_count' => $paymentCount, + ]; +} + +function app_year_end_runs_for_tenant(PDO $pdo, string $tenantId): array +{ + if (!scripts_table_exists($pdo, 'year_end_runs')) { + return []; + } + + return app_query_all( + $pdo, + <<<'SQL' +SELECT + yer.id, + yer.year, + yer.bonus_mode, + yer.bonus_pool_amount, + yer.bonus_per_member_amount, + yer.total_strokes, + yer.recipient_count, + yer.total_amount, + yer.status, + yer.booked_at, + yer.executed_at, + yer.created_at, + u.display_name AS created_by_name +FROM year_end_runs yer +LEFT JOIN users u ON u.id = yer.created_by_user_id +WHERE yer.tenant_id = :tenant_id +ORDER BY yer.year DESC, yer.created_at DESC +LIMIT 12 +SQL, + ['tenant_id' => $tenantId] + ); +} + function app_members_for_tenant(PDO $pdo, string $tenantId): array { $paymentReferenceSelect = app_members_support_payment_reference($pdo) @@ -2665,6 +3106,7 @@ SELECT SELECT SUM(le.amount) FROM ledger_entries le WHERE le.member_id = m.id + AND le.tenant_id = m.tenant_id ), 0) AS current_balance FROM members m LEFT JOIN tenant_users tu ON tu.id = m.tenant_user_id @@ -2699,6 +3141,8 @@ function app_normalize_datetime_input(?string $value): string function app_member_detail(PDO $pdo, string $tenantId, string $memberId): ?array { $member = app_member_exists($pdo, $tenantId, $memberId); + $coffeeActiveSql = app_finance_entry_active_sql($pdo, 'coffee_entries', 'ce'); + $paymentActiveSql = app_finance_entry_active_sql($pdo, 'payment_entries', 'pe'); if ($member === null) { return null; @@ -2706,7 +3150,10 @@ function app_member_detail(PDO $pdo, string $tenantId, string $memberId): ?array $summary = app_query_one( $pdo, - <<<'SQL' + str_replace( + ['__COFFEE_ACTIVE__', '__PAYMENT_ACTIVE__'], + [$coffeeActiveSql, $paymentActiveSql], + <<<'SQL' SELECT COALESCE(SUM(le.amount), 0) AS balance, COALESCE(( @@ -2714,23 +3161,27 @@ SELECT FROM coffee_entries ce WHERE ce.member_id = :member_id AND ce.tenant_id = :tenant_id + AND __COFFEE_ACTIVE__ ), 0) AS total_spend, COALESCE(( SELECT SUM(ce.strokes) FROM coffee_entries ce WHERE ce.member_id = :member_id AND ce.tenant_id = :tenant_id + AND __COFFEE_ACTIVE__ ), 0) AS total_strokes, COALESCE(( SELECT SUM(pe.amount) FROM payment_entries pe WHERE pe.member_id = :member_id AND pe.tenant_id = :tenant_id + AND __PAYMENT_ACTIVE__ ), 0) AS total_payments FROM ledger_entries le WHERE le.member_id = :member_id AND le.tenant_id = :tenant_id SQL, + ), ['member_id' => $memberId, 'tenant_id' => $tenantId] ) ?? []; @@ -2753,9 +3204,13 @@ SQL, function app_members_for_scope(PDO $pdo, string $tenantId, string $scope = 'all'): array { + $coffeeActiveSql = app_finance_entry_active_sql($pdo, 'coffee_entries', 'ce'); $rows = app_query_all( $pdo, - <<<'SQL' + str_replace( + '__COFFEE_ACTIVE__', + $coffeeActiveSql, + <<<'SQL' SELECT m.id, m.display_name, @@ -2764,6 +3219,7 @@ SELECT FROM coffee_entries ce WHERE ce.tenant_id = m.tenant_id AND ce.member_id = m.id + AND __COFFEE_ACTIVE__ AND ce.booked_at >= DATE_SUB(CURRENT_DATE(), INTERVAL 100 DAY) ), 0) AS recent_strokes FROM members m @@ -2771,6 +3227,7 @@ WHERE m.tenant_id = :tenant_id AND m.status = 'active' ORDER BY m.display_name ASC SQL, + ), ['tenant_id' => $tenantId] ); @@ -2822,7 +3279,29 @@ function app_paypal_amount_links(array $settings, float $balance): array function app_ledger_for_tenant(PDO $pdo, string $tenantId): array { - $sql = <<<'SQL' + $coffeeStatusSelect = app_finance_entries_support_status($pdo, 'coffee_entries') + ? 'ce.status AS coffee_reference_status,' + : "'booked' AS coffee_reference_status,"; + $coffeeCancelledSelect = app_finance_entries_support_status($pdo, 'coffee_entries') + ? 'ce.cancelled_at AS coffee_reference_cancelled_at,' + : 'NULL AS coffee_reference_cancelled_at,'; + $paymentStatusSelect = app_finance_entries_support_status($pdo, 'payment_entries') + ? 'pe.status AS payment_reference_status, pe.cancelled_at AS payment_cancelled_at,' + : "'booked' AS payment_reference_status, NULL AS payment_cancelled_at,"; + $referenceStatusSql = app_finance_entries_support_status($pdo, 'coffee_entries') || app_finance_entries_support_status($pdo, 'payment_entries') + ? <<<'SQL' +CASE + WHEN le.reference_type = 'coffee_entry' THEN COALESCE(ce.status, 'booked') + WHEN le.reference_type = 'payment_entry' THEN COALESCE(pe.status, 'booked') + ELSE 'booked' +END AS reference_status +SQL + : "'booked' AS reference_status"; + + $sql = str_replace( + ['__COFFEE_STATUS__', '__COFFEE_CANCELLED__', '__PAYMENT_STATUS__', '__REFERENCE_STATUS__', '__REFERENCE_CANCELLED_AT__'], + [$coffeeStatusSelect, $coffeeCancelledSelect, $paymentStatusSelect, $referenceStatusSql, 'COALESCE(coffee_reference_cancelled_at, payment_cancelled_at) AS reference_cancelled_at,'], + <<<'SQL' SELECT le.id, le.booked_at, @@ -2830,62 +3309,145 @@ SELECT le.amount, le.reference_type, le.reference_id, - m.display_name AS member_name + m.display_name AS member_name, + __COFFEE_STATUS__ + __COFFEE_CANCELLED__ + __PAYMENT_STATUS__ + __REFERENCE_CANCELLED_AT__ + __REFERENCE_STATUS__ FROM ledger_entries le LEFT JOIN members m ON m.id = le.member_id +LEFT JOIN coffee_entries ce ON le.reference_type = 'coffee_entry' AND ce.id = le.reference_id AND ce.tenant_id = le.tenant_id +LEFT JOIN payment_entries pe ON le.reference_type = 'payment_entry' AND pe.id = le.reference_id AND pe.tenant_id = le.tenant_id WHERE le.tenant_id = :tenant_id ORDER BY le.booked_at DESC LIMIT 25 -SQL; +SQL + ); return app_query_all($pdo, $sql, ['tenant_id' => $tenantId]); } function app_payments_for_tenant(PDO $pdo, string $tenantId): array { - $sql = <<<'SQL' + $paymentStatusSelect = app_finance_entries_support_status($pdo, 'payment_entries') + ? 'pe.status, pe.cancelled_at, pe.cancellation_reason,' + : "'booked' AS status, NULL AS cancelled_at, NULL AS cancellation_reason,"; + + $sql = str_replace( + '__PAYMENT_STATUS__', + $paymentStatusSelect, + <<<'SQL' SELECT pe.id, pe.booked_at, pe.amount, pe.payment_method, m.display_name AS member_name, - pe.member_id + pe.member_id, + __PAYMENT_STATUS__ FROM payment_entries pe LEFT JOIN members m ON m.id = pe.member_id WHERE pe.tenant_id = :tenant_id ORDER BY pe.booked_at DESC LIMIT 25 -SQL; +SQL + ); return app_query_all($pdo, $sql, ['tenant_id' => $tenantId]); } -function app_delete_coffee_entry(PDO $pdo, string $tenantId, string $referenceId): void +function app_delete_coffee_entry(PDO $pdo, string $tenantId, string $referenceId, ?string $actorMemberId = null, ?string $reason = null): void { if ($referenceId === '') { throw new RuntimeException('Der Kaffeeeintrag konnte nicht zugeordnet werden.'); } + if (!app_finance_entries_support_status($pdo, 'coffee_entries')) { + $pdo->beginTransaction(); + + try { + app_execute( + $pdo, + 'DELETE FROM ledger_entries WHERE tenant_id = :tenant_id AND reference_type = :reference_type AND reference_id = :reference_id', + [ + 'tenant_id' => $tenantId, + 'reference_type' => 'coffee_entry', + 'reference_id' => $referenceId, + ] + ); + + app_execute( + $pdo, + 'DELETE FROM coffee_entries WHERE tenant_id = :tenant_id AND id = :id', + [ + 'tenant_id' => $tenantId, + 'id' => $referenceId, + ] + ); + + $pdo->commit(); + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + throw $exception; + } + + return; + } + + $entry = app_query_one( + $pdo, + 'SELECT id, member_id, total_cost, booked_at, status FROM coffee_entries WHERE tenant_id = :tenant_id AND id = :id LIMIT 1', + ['tenant_id' => $tenantId, 'id' => $referenceId] + ); + + if ($entry === null) { + throw new RuntimeException('Der Kaffeeeintrag konnte nicht gefunden werden.'); + } + + if ((string) ($entry['status'] ?? 'booked') === 'cancelled') { + throw new RuntimeException('Dieser Kaffeeeintrag wurde bereits storniert.'); + } + $pdo->beginTransaction(); try { + $ledgerId = app_uuid(); + $now = date('Y-m-d H:i:s'); + $amount = abs((float) ($entry['total_cost'] ?? 0)); + app_execute( $pdo, - 'DELETE FROM ledger_entries WHERE tenant_id = :tenant_id AND reference_type = :reference_type AND reference_id = :reference_id', + 'UPDATE coffee_entries SET status = :status, cancelled_at = :cancelled_at, cancelled_by_member_id = :cancelled_by_member_id, cancellation_reason = :cancellation_reason, cancellation_ledger_entry_id = :cancellation_ledger_entry_id, updated_at = :updated_at WHERE tenant_id = :tenant_id AND id = :id', [ + 'status' => 'cancelled', + 'cancelled_at' => $now, + 'cancelled_by_member_id' => $actorMemberId !== '' ? $actorMemberId : null, + 'cancellation_reason' => $reason !== null && trim($reason) !== '' ? trim($reason) : 'manual_reversal', + 'cancellation_ledger_entry_id' => $ledgerId, + 'updated_at' => $now, 'tenant_id' => $tenantId, - 'reference_type' => 'coffee_entry', - 'reference_id' => $referenceId, + 'id' => $referenceId, ] ); app_execute( $pdo, - 'DELETE FROM coffee_entries WHERE tenant_id = :tenant_id AND id = :id', + 'INSERT INTO ledger_entries (id, tenant_id, member_id, entry_type, amount, reference_type, reference_id, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :entry_type, :amount, :reference_type, :reference_id, :booked_at, :created_at, :updated_at)', [ + 'id' => $ledgerId, 'tenant_id' => $tenantId, - 'id' => $referenceId, + 'member_id' => (string) ($entry['member_id'] ?? ''), + 'entry_type' => 'coffee_cancellation', + 'amount' => number_format($amount, 2, '.', ''), + 'reference_type' => 'coffee_cancellation', + 'reference_id' => $referenceId, + 'booked_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, ] ); @@ -2899,31 +3461,97 @@ function app_delete_coffee_entry(PDO $pdo, string $tenantId, string $referenceId } } -function app_delete_payment_entry(PDO $pdo, string $tenantId, string $paymentId): void +function app_delete_payment_entry(PDO $pdo, string $tenantId, string $paymentId, ?string $actorMemberId = null, ?string $reason = null): void { if ($paymentId === '') { throw new RuntimeException('Die Einzahlung konnte nicht zugeordnet werden.'); } + if (!app_finance_entries_support_status($pdo, 'payment_entries')) { + $pdo->beginTransaction(); + + try { + app_execute( + $pdo, + 'DELETE FROM ledger_entries WHERE tenant_id = :tenant_id AND reference_type = :reference_type AND reference_id = :reference_id', + [ + 'tenant_id' => $tenantId, + 'reference_type' => 'payment_entry', + 'reference_id' => $paymentId, + ] + ); + + app_execute( + $pdo, + 'DELETE FROM payment_entries WHERE tenant_id = :tenant_id AND id = :id', + [ + 'tenant_id' => $tenantId, + 'id' => $paymentId, + ] + ); + + $pdo->commit(); + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + throw $exception; + } + + return; + } + + $entry = app_query_one( + $pdo, + 'SELECT id, member_id, amount, booked_at, status FROM payment_entries WHERE tenant_id = :tenant_id AND id = :id LIMIT 1', + ['tenant_id' => $tenantId, 'id' => $paymentId] + ); + + if ($entry === null) { + throw new RuntimeException('Die Einzahlung konnte nicht gefunden werden.'); + } + + if ((string) ($entry['status'] ?? 'booked') === 'cancelled') { + throw new RuntimeException('Diese Einzahlung wurde bereits storniert.'); + } + $pdo->beginTransaction(); try { + $ledgerId = app_uuid(); + $now = date('Y-m-d H:i:s'); + $amount = abs((float) ($entry['amount'] ?? 0)); + app_execute( $pdo, - 'DELETE FROM ledger_entries WHERE tenant_id = :tenant_id AND reference_type = :reference_type AND reference_id = :reference_id', + 'UPDATE payment_entries SET status = :status, cancelled_at = :cancelled_at, cancelled_by_member_id = :cancelled_by_member_id, cancellation_reason = :cancellation_reason, cancellation_ledger_entry_id = :cancellation_ledger_entry_id, updated_at = :updated_at WHERE tenant_id = :tenant_id AND id = :id', [ + 'status' => 'cancelled', + 'cancelled_at' => $now, + 'cancelled_by_member_id' => $actorMemberId !== '' ? $actorMemberId : null, + 'cancellation_reason' => $reason !== null && trim($reason) !== '' ? trim($reason) : 'manual_reversal', + 'cancellation_ledger_entry_id' => $ledgerId, + 'updated_at' => $now, 'tenant_id' => $tenantId, - 'reference_type' => 'payment_entry', - 'reference_id' => $paymentId, + 'id' => $paymentId, ] ); app_execute( $pdo, - 'DELETE FROM payment_entries WHERE tenant_id = :tenant_id AND id = :id', + 'INSERT INTO ledger_entries (id, tenant_id, member_id, entry_type, amount, reference_type, reference_id, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :entry_type, :amount, :reference_type, :reference_id, :booked_at, :created_at, :updated_at)', [ + 'id' => $ledgerId, 'tenant_id' => $tenantId, - 'id' => $paymentId, + 'member_id' => (string) ($entry['member_id'] ?? ''), + 'entry_type' => 'payment_reversal', + 'amount' => number_format($amount * -1, 2, '.', ''), + 'reference_type' => 'payment_cancellation', + 'reference_id' => $paymentId, + 'booked_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, ] ); @@ -2961,6 +3589,363 @@ function app_content_for_tenant(PDO $pdo, string $tenantId): array ]; } +function app_notifications_supports_storage(PDO $pdo): bool +{ + return scripts_table_exists($pdo, 'notification_messages'); +} + +function app_notification_scope_label(string $scope): string +{ + return match ($scope) { + 'front' => 'Vorderseite', + 'back' => 'Rückseite', + default => 'Alle Mitglieder', + }; +} + +function app_notification_channel_label(string $channel): string +{ + return match ($channel) { + 'inapp' => 'In-App', + 'sms' => 'SMS', + default => 'E-Mail', + }; +} + +function app_notification_status_label(string $status): array +{ + return match ($status) { + 'sent' => ['label' => 'Gesendet', 'tone' => 'success'], + 'scheduled' => ['label' => 'Geplant', 'tone' => 'warning'], + 'draft' => ['label' => 'Entwurf', 'tone' => 'neutral'], + 'failed' => ['label' => 'Fehler', 'tone' => 'danger'], + default => ['label' => ucfirst($status), 'tone' => 'neutral'], + }; +} + +function app_notifications_demo_entries(string $tenantId): array +{ + $now = date('Y-m-d H:i:s'); + + $rows = [ + [ + 'id' => 'demo-notification-1', + 'tenant_id' => $tenantId, + 'title' => 'Saldo-Erinnerung', + 'message' => 'Alle Mitglieder mit negativem Kontostand erhalten eine freundliche Erinnerung.', + 'channel' => 'email', + 'recipient_scope' => 'back', + 'recipient_count' => 6, + 'status' => 'scheduled', + 'scheduled_at' => $now, + 'sent_at' => null, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'id' => 'demo-notification-2', + 'tenant_id' => $tenantId, + 'title' => 'Service-Hinweis', + 'message' => 'Wichtige Hinweise zum Monatsabschluss werden am gleichen Tag an alle Mitglieder verschickt.', + 'channel' => 'inapp', + 'recipient_scope' => 'all', + 'recipient_count' => 18, + 'status' => 'draft', + 'scheduled_at' => null, + 'sent_at' => null, + 'created_at' => $now, + 'updated_at' => $now, + ], + ]; + + return array_map( + static function (array $row): array { + $status = app_notification_status_label((string) ($row['status'] ?? 'draft')); + + return array_merge($row, [ + 'scope_label' => app_notification_scope_label((string) ($row['recipient_scope'] ?? 'all')), + 'channel_label' => app_notification_channel_label((string) ($row['channel'] ?? 'email')), + 'status_label' => $status['label'], + 'status_tone' => $status['tone'], + ]); + }, + $rows + ); +} + +function app_notifications_for_tenant(PDO $pdo, string $tenantId): array +{ + if (!app_notifications_supports_storage($pdo)) { + return app_notifications_demo_entries($tenantId); + } + + $rows = app_query_all( + $pdo, + <<<'SQL' +SELECT + id, + tenant_id, + title, + message, + channel, + recipient_scope, + recipient_count, + status, + scheduled_at, + sent_at, + created_at, + updated_at +FROM notification_messages +WHERE tenant_id = :tenant_id +ORDER BY + CASE status + WHEN 'draft' THEN 0 + WHEN 'scheduled' THEN 1 + WHEN 'sent' THEN 2 + ELSE 3 + END, + created_at DESC +LIMIT 20 +SQL, + ['tenant_id' => $tenantId] + ); + + return array_map( + static function (array $row): array { + $status = app_notification_status_label((string) ($row['status'] ?? 'draft')); + + return array_merge($row, [ + 'scope_label' => app_notification_scope_label((string) ($row['recipient_scope'] ?? 'all')), + 'channel_label' => app_notification_channel_label((string) ($row['channel'] ?? 'email')), + 'status_label' => $status['label'], + 'status_tone' => $status['tone'], + ]); + }, + $rows + ); +} + +function app_notification_message_by_id(PDO $pdo, string $tenantId, string $messageId): ?array +{ + if ($messageId === '' || !app_notifications_supports_storage($pdo)) { + return null; + } + + $row = app_query_one( + $pdo, + <<<'SQL' +SELECT + id, + tenant_id, + title, + message, + channel, + recipient_scope, + recipient_count, + status, + scheduled_at, + sent_at, + created_at, + updated_at +FROM notification_messages +WHERE tenant_id = :tenant_id + AND id = :id +LIMIT 1 +SQL, + ['tenant_id' => $tenantId, 'id' => $messageId] + ); + + if ($row === null) { + return null; + } + + $status = app_notification_status_label((string) ($row['status'] ?? 'draft')); + + return array_merge($row, [ + 'scope_label' => app_notification_scope_label((string) ($row['recipient_scope'] ?? 'all')), + 'channel_label' => app_notification_channel_label((string) ($row['channel'] ?? 'email')), + 'status_label' => $status['label'], + 'status_tone' => $status['tone'], + ]); +} + +function app_notification_logs_for_tenant(PDO $pdo, string $tenantId): array +{ + if (!scripts_table_exists($pdo, 'notification_logs')) { + return []; + } + + $rows = app_query_all( + $pdo, + <<<'SQL' +SELECT id, channel, template_key, recipient, status, sent_at, created_at, updated_at +FROM notification_logs +WHERE tenant_id = :tenant_id +ORDER BY created_at DESC +LIMIT 25 +SQL, + ['tenant_id' => $tenantId] + ); + + return array_map( + static function (array $row): array { + $status = app_notification_status_label((string) ($row['status'] ?? 'planned')); + + return array_merge($row, [ + 'status_label' => $status['label'], + 'status_tone' => $status['tone'], + 'channel_label' => app_notification_channel_label((string) ($row['channel'] ?? 'email')), + ]); + }, + $rows + ); +} + +function app_notification_recipients_for_scope(PDO $pdo, string $tenantId, string $scope): array +{ + return app_members_for_scope($pdo, $tenantId, $scope); +} + +function app_handle_notifications_action(PDO $pdo, array $auth): void +{ + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + return; + } + + $action = (string) ($_POST['action'] ?? ''); + + if (!in_array($action, ['save-notification', 'send-notification'], true)) { + return; + } + + if (!hash_equals((string) ($_SESSION['notifications_csrf'] ?? ''), (string) ($_POST['csrf'] ?? ''))) { + app_flash('Die Sitzung ist abgelaufen. Bitte lade die Benachrichtigungen neu.', 'error'); + app_redirect('/notifications/'); + } + + if (!app_can_manage_support($auth)) { + app_flash('Für diese Aktion brauchst du Support- oder Tenant-Admin-Rechte.', 'warning'); + app_redirect('/notifications/'); + } + + $tenantId = (string) ($auth['tenant_id'] ?? ''); + + if (!app_notifications_supports_storage($pdo)) { + app_flash('Die Benachrichtigungsdatenbank ist noch nicht angelegt. Bitte zuerst die Migration ausführen.', 'warning'); + app_redirect('/notifications/'); + } + + $messageId = trim((string) ($_POST['notification_id'] ?? '')); + $title = trim((string) ($_POST['title'] ?? '')); + $message = trim((string) ($_POST['message'] ?? '')); + $channel = in_array((string) ($_POST['channel'] ?? 'email'), ['email', 'inapp', 'sms'], true) + ? (string) ($_POST['channel'] ?? 'email') + : 'email'; + $scope = in_array((string) ($_POST['recipient_scope'] ?? 'all'), ['all', 'front', 'back'], true) + ? (string) ($_POST['recipient_scope'] ?? 'all') + : 'all'; + $scheduledAtRaw = trim((string) ($_POST['scheduled_at'] ?? '')); + $scheduledAt = $scheduledAtRaw !== '' ? str_replace('T', ' ', $scheduledAtRaw) . ':00' : null; + $status = $action === 'send-notification' + ? 'sent' + : ($scheduledAt !== null ? 'scheduled' : 'draft'); + $recipientRows = app_notification_recipients_for_scope($pdo, $tenantId, $scope); + $recipientCount = count($recipientRows); + $now = date('Y-m-d H:i:s'); + + if ($title === '' || $message === '') { + app_flash('Bitte gib Titel und Nachricht an.', 'error'); + app_redirect('/notifications/'); + } + + if ($action === 'send-notification' && $recipientCount === 0) { + app_flash('Für die gewählte Zielgruppe wurden keine aktiven Mitglieder gefunden.', 'warning'); + app_redirect('/notifications/'); + } + + $pdo->beginTransaction(); + + try { + if ($messageId !== '') { + app_execute( + $pdo, + 'UPDATE notification_messages SET title = :title, message = :message, channel = :channel, recipient_scope = :recipient_scope, recipient_count = :recipient_count, status = :status, scheduled_at = :scheduled_at, sent_at = :sent_at, updated_at = :updated_at WHERE tenant_id = :tenant_id AND id = :id', + [ + 'title' => $title, + 'message' => $message, + 'channel' => $channel, + 'recipient_scope' => $scope, + 'recipient_count' => $recipientCount, + 'status' => $status, + 'scheduled_at' => $scheduledAt, + 'sent_at' => $status === 'sent' ? $now : null, + 'updated_at' => $now, + 'tenant_id' => $tenantId, + 'id' => $messageId, + ] + ); + } else { + $messageId = app_uuid(); + app_execute( + $pdo, + 'INSERT INTO notification_messages (id, tenant_id, title, message, channel, recipient_scope, recipient_count, status, scheduled_at, sent_at, created_at, updated_at) VALUES (:id, :tenant_id, :title, :message, :channel, :recipient_scope, :recipient_count, :status, :scheduled_at, :sent_at, :created_at, :updated_at)', + [ + 'id' => $messageId, + 'tenant_id' => $tenantId, + 'title' => $title, + 'message' => $message, + 'channel' => $channel, + 'recipient_scope' => $scope, + 'recipient_count' => $recipientCount, + 'status' => $status, + 'scheduled_at' => $scheduledAt, + 'sent_at' => $status === 'sent' ? $now : null, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + } + + if ($status === 'sent' && scripts_table_exists($pdo, 'notification_logs')) { + foreach ($recipientRows as $recipientRow) { + app_execute( + $pdo, + 'INSERT INTO notification_logs (id, tenant_id, channel, template_key, recipient, status, sent_at, created_at, updated_at) VALUES (:id, :tenant_id, :channel, :template_key, :recipient, :status, :sent_at, :created_at, :updated_at)', + [ + 'id' => app_uuid(), + 'tenant_id' => $tenantId, + 'channel' => $channel, + 'template_key' => 'broadcast', + 'recipient' => trim((string) ($recipientRow['display_name'] ?? 'Mitglied')), + 'status' => 'sent', + 'sent_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + } + } + + $pdo->commit(); + + if ($status === 'sent') { + app_flash('Die Benachrichtigung wurde an ' . $recipientCount . ' Mitglieder ausgeliefert.', 'success'); + } elseif ($status === 'scheduled') { + app_flash('Die Benachrichtigung wurde für ' . app_notification_scope_label($scope) . ' geplant.', 'success'); + } else { + app_flash('Der Entwurf wurde gespeichert.', 'success'); + } + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + app_flash($exception->getMessage(), 'error'); + } + + app_redirect('/notifications/'); +} + /** * @return array */ @@ -4144,9 +5129,13 @@ function app_import_payment_csv(PDO $pdo, string $tenantId, array $file): array } $bookedAt = $bookedAtRaw !== '' ? app_normalize_datetime_input($bookedAtRaw) : date('Y-m-d H:i:s'); + $duplicateSql = 'SELECT COUNT(*) AS aggregate_value FROM payment_entries WHERE tenant_id = :tenant_id AND member_id = :member_id AND amount = :amount AND booked_at = :booked_at AND payment_method = :payment_method'; + if (app_finance_entries_support_status($pdo, 'payment_entries')) { + $duplicateSql .= ' AND COALESCE(status, \'booked\') = \'booked\''; + } $duplicateCount = (int) app_query_value( $pdo, - 'SELECT COUNT(*) AS aggregate_value FROM payment_entries WHERE tenant_id = :tenant_id AND member_id = :member_id AND amount = :amount AND booked_at = :booked_at AND payment_method = :payment_method', + $duplicateSql, [ 'tenant_id' => $tenantId, 'member_id' => (string) $member['id'], @@ -4194,13 +5183,25 @@ function app_handle_tenant_action(PDO $pdo, array $auth): void try { if ($action === 'delete-coffee') { - app_delete_coffee_entry($pdo, $tenantId, (string) ($_POST['reference_id'] ?? '')); - app_flash('Der Stricheintrag wurde entfernt.', 'success'); + app_delete_coffee_entry( + $pdo, + $tenantId, + (string) ($_POST['reference_id'] ?? ''), + (string) ($auth['member_id'] ?? ''), + 'manual_reversal' + ); + app_flash('Der Stricheintrag wurde storniert.', 'success'); app_redirect('/ledger/'); } - app_delete_payment_entry($pdo, $tenantId, (string) ($_POST['payment_id'] ?? '')); - app_flash('Die Einzahlung wurde entfernt.', 'success'); + app_delete_payment_entry( + $pdo, + $tenantId, + (string) ($_POST['payment_id'] ?? ''), + (string) ($auth['member_id'] ?? ''), + 'manual_reversal' + ); + app_flash('Die Einzahlung wurde storniert.', 'success'); app_redirect('/payments/'); } catch (Throwable $exception) { app_flash($exception->getMessage(), 'error'); diff --git a/saas-app/public/index.php b/saas-app/public/index.php index f4b7f6f..807d25a 100644 --- a/saas-app/public/index.php +++ b/saas-app/public/index.php @@ -34,6 +34,17 @@ function dt(?string $value): string return $time === false ? $value : date('d.m.Y H:i', $time); } +function dt_local(?string $value): string +{ + if ($value === null || trim($value) === '') { + return ''; + } + + $time = strtotime($value); + + return $time === false ? '' : date('Y-m-d\TH:i', $time); +} + function badge(string $label, string $tone = 'neutral'): string { return '' . h($label) . ''; @@ -54,8 +65,10 @@ if ($requestedPage === null) { '/content' => 'content', '/imports' => 'imports', '/reports' => 'reports', + '/year-end' => 'year-end', '/support' => 'support', '/surveys' => 'surveys', + '/notifications' => 'notifications', '/profil', '/profile' => 'profile', '/settings' => 'settings', '/exports' => 'exports', @@ -66,7 +79,7 @@ if ($requestedPage === null) { $page = (string) $requestedPage; $requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET'; -$tenantPages = ['dashboard', 'members', 'ledger', 'payments', 'content', 'imports', 'reports', 'support', 'surveys', 'profile', 'settings', 'exports']; +$tenantPages = ['dashboard', 'members', 'ledger', 'payments', 'content', 'imports', 'reports', 'year-end', 'support', 'surveys', 'notifications', 'profile', 'settings', 'exports']; if ($page === 'logout' && $requestMethod === 'POST') { app_logout(); @@ -111,6 +124,49 @@ $hasTenantSettingsFeature = false; $hasPdfExportFeature = false; $hasPaperStrikeEntryFeature = false; $hasBasicExportsFeature = false; +$yearEndPreview = null; +$yearEndPreviewError = null; +$yearEndRuns = []; +$notificationStorageReady = false; +$notificationMessages = []; +$notificationLogs = []; +$notificationPreview = []; +$editingNotification = null; +$notificationSummary = [ + 'total' => 0, + 'draft' => 0, + 'scheduled' => 0, + 'sent' => 0, + 'recipient_count' => 0, + 'log_count' => 0, +]; +$notificationForm = [ + 'notification_id' => '', + 'title' => '', + 'message' => '', + 'channel' => 'email', + 'recipient_scope' => 'all', + 'scheduled_at' => '', +]; +$notificationScopeOptions = [ + ['value' => 'all', 'label' => 'Alle Mitglieder'], + ['value' => 'front', 'label' => 'Vorderseite'], + ['value' => 'back', 'label' => 'Rückseite'], +]; +$notificationChannelOptions = [ + ['value' => 'email', 'label' => 'E-Mail'], + ['value' => 'inapp', 'label' => 'In-App'], + ['value' => 'sms', 'label' => 'SMS'], +]; +$yearEndForm = [ + 'year' => (int) date('Y') - 1, + 'bonus_mode' => 'proportional', + 'bonus_pool_amount' => '100.00', + 'bonus_per_member_amount' => '2.50', + 'booked_at' => sprintf('%04d-12-31T23:59', (int) date('Y') - 1), + 'notes' => '', +]; +$yearEndExistingRun = null; try { $pdo = app_pdo(); @@ -177,6 +233,100 @@ if ($auth !== null && $pdo instanceof PDO) { app_handle_settings_action($pdo, $auth); } + if ($page === 'notifications') { + if (!isset($_SESSION['notifications_csrf'])) { + $_SESSION['notifications_csrf'] = bin2hex(random_bytes(24)); + } + + $notificationStorageReady = app_notifications_supports_storage($pdo); + app_handle_notifications_action($pdo, $auth); + $notificationMessages = app_notifications_for_tenant($pdo, (string) $auth['tenant_id']); + $notificationLogs = app_notification_logs_for_tenant($pdo, (string) $auth['tenant_id']); + $notificationSummary['total'] = count($notificationMessages); + $notificationSummary['draft'] = count(array_filter($notificationMessages, static fn(array $row): bool => (string) ($row['status'] ?? '') === 'draft')); + $notificationSummary['scheduled'] = count(array_filter($notificationMessages, static fn(array $row): bool => (string) ($row['status'] ?? '') === 'scheduled')); + $notificationSummary['sent'] = count(array_filter($notificationMessages, static fn(array $row): bool => (string) ($row['status'] ?? '') === 'sent')); + $notificationSummary['recipient_count'] = array_sum(array_map(static fn(array $row): int => (int) ($row['recipient_count'] ?? 0), $notificationMessages)); + $notificationSummary['log_count'] = count($notificationLogs); + + $editNotificationId = trim((string) ($_GET['edit'] ?? '')); + if ($editNotificationId !== '') { + $editingNotification = app_notification_message_by_id($pdo, (string) $auth['tenant_id'], $editNotificationId); + } + + if ($editingNotification !== null) { + $notificationForm = [ + 'notification_id' => (string) ($editingNotification['id'] ?? ''), + 'title' => (string) ($editingNotification['title'] ?? ''), + 'message' => (string) ($editingNotification['message'] ?? ''), + 'channel' => (string) ($editingNotification['channel'] ?? 'email'), + 'recipient_scope' => (string) ($editingNotification['recipient_scope'] ?? 'all'), + 'scheduled_at' => dt_local((string) ($editingNotification['scheduled_at'] ?? '')), + ]; + } else { + $notificationForm['scheduled_at'] = date('Y-m-d\TH:i', time() + 3600); + } + + $selectedScope = in_array((string) ($_GET['scope'] ?? $notificationForm['recipient_scope']), ['all', 'front', 'back'], true) + ? (string) ($_GET['scope'] ?? $notificationForm['recipient_scope']) + : 'all'; + $notificationPreview = app_notification_recipients_for_scope($pdo, (string) $auth['tenant_id'], $selectedScope); + $notificationForm['recipient_scope'] = $selectedScope; + } + + if ($page === 'year-end') { + if ($requestMethod === 'POST') { + $yearEndForm = array_merge($yearEndForm, [ + 'year' => max(2000, (int) ($_POST['year'] ?? $yearEndForm['year'])), + 'bonus_mode' => in_array((string) ($_POST['bonus_mode'] ?? 'proportional'), ['proportional', 'flat'], true) + ? (string) ($_POST['bonus_mode'] ?? 'proportional') + : 'proportional', + 'bonus_pool_amount' => number_format(max(0.0, (float) ($_POST['bonus_pool_amount'] ?? 0)), 2, '.', ''), + 'bonus_per_member_amount' => number_format(max(0.0, (float) ($_POST['bonus_per_member_amount'] ?? 0)), 2, '.', ''), + 'booked_at' => str_replace(' ', 'T', (string) ($_POST['booked_at'] ?? $yearEndForm['booked_at'])), + 'notes' => trim((string) ($_POST['notes'] ?? '')), + ]); + + $yearEndForm['booked_at'] = substr($yearEndForm['booked_at'], 0, 16); + + if ((string) ($_POST['action'] ?? '') === 'preview-year-end') { + try { + $yearEndPreview = app_year_end_preview($pdo, (string) $auth['tenant_id'], [ + 'year' => $yearEndForm['year'], + 'bonus_mode' => $yearEndForm['bonus_mode'], + 'bonus_pool_amount' => $yearEndForm['bonus_pool_amount'], + 'bonus_per_member_amount' => $yearEndForm['bonus_per_member_amount'], + 'booked_at' => $yearEndForm['booked_at'], + 'notes' => $yearEndForm['notes'], + ]); + } catch (Throwable $exception) { + $yearEndPreviewError = $exception->getMessage(); + } + } + + if ((string) ($_POST['action'] ?? '') === 'execute-year-end') { + try { + $result = app_year_end_execute($pdo, $auth, [ + 'year' => $yearEndForm['year'], + 'bonus_mode' => $yearEndForm['bonus_mode'], + 'bonus_pool_amount' => $yearEndForm['bonus_pool_amount'], + 'bonus_per_member_amount' => $yearEndForm['bonus_per_member_amount'], + 'booked_at' => $yearEndForm['booked_at'], + 'notes' => $yearEndForm['notes'], + ]); + app_flash('Der Jahresabschluss wurde verbucht. ' . (string) ($result['payment_count'] ?? 0) . ' Auszahlungen wurden erzeugt.', 'success'); + app_redirect('/year-end/'); + } catch (Throwable $exception) { + app_flash($exception->getMessage(), 'error'); + app_redirect('/year-end/'); + } + } + } + + $yearEndRuns = app_year_end_runs_for_tenant($pdo, (string) $auth['tenant_id']); + $yearEndExistingRun = app_year_end_run_exists($pdo, (string) $auth['tenant_id'], (int) $yearEndForm['year']); + } + if ($page === 'exports') { app_handle_export_action($pdo, $auth, $tenantSettings); app_handle_export_download($pdo, $auth); @@ -309,6 +459,8 @@ $restrictedPages = [ 'payments' => static fn(array $user): bool => app_can_manage_finance($user), 'imports' => static fn(array $user): bool => app_can_manage_finance($user), 'reports' => static fn(array $user): bool => app_can_manage_tenant($user), + 'year-end' => static fn(array $user): bool => app_can_manage_finance($user), + 'notifications' => static fn(array $user): bool => app_can_manage_support($user), 'settings' => static fn(array $user): bool => app_can_manage_tenant($user), 'exports' => static fn(array $user): bool => app_can_manage_tenant($user), ]; @@ -905,6 +1057,7 @@ $marketing = app_marketing_messages();

+

0 ? 'Anmeldung per Passwort oder ADFS/OIDC vorbereitet.' : 'Lokale Passwort-Anmeldung.' ?>

@@ -921,6 +1074,12 @@ $marketing = app_marketing_messages(); Neu starten + 0): ?> +
+

ADFS / OIDC im Tenant

+

Für diesen Bereich ist Single-Sign-on vorbereitet. Die produktive Weiterleitung hängt von der aktivierten Provider-Konfiguration des Tenants ab; die lokale Passwort-Anmeldung bleibt als Fallback verfügbar.

+
+
Schritt 1

E-Mail eingeben

@@ -1312,11 +1471,13 @@ $marketing = app_marketing_messages(); - + + Storniert am +
- +
@@ -1377,7 +1538,7 @@ $marketing = app_marketing_messages();
-
ZeitMitgliedTypReferenzBetragAktion
-
+
ZeitMitgliedTypReferenzBetragAktion

Storniert
Bereits storniert
-
Einzahlungen

Zahlungen

Einzahlungen werden direkt in Zahlungstabelle und Ledger geschrieben.

@@ -1433,7 +1594,7 @@ $marketing = app_marketing_messages();
-
ZeitMitgliedMethodeBetragAktion
+
ZeitMitgliedMethodeBetragStatusAktion

Bereits storniert
Hinweise und FAQ

Hinweise und FAQ

Hinweise und häufige Fragen werden pro Tenant gepflegt und direkt an die Mitglieder ausgespielt.

@@ -1466,6 +1627,39 @@ $marketing = app_marketing_messages();

Aktuell keine FAQ vorhanden.

+ +
Benachrichtigungen

Massenkommunikation pro Tenant

Hinweise, Erinnerungen und Service-Meldungen werden als Entwurf, geplanter Versand oder direkte Auslieferung pro Tenant geführt.

+
Die Tabelle für Benachrichtigungen fehlt noch. Solange die Migration nicht gelaufen ist, werden Demo-Daten gezeigt und kein echter Versand gespeichert.
+
+

Kampagnen

Gespeicherte Benachrichtigungen im Tenant.

+

Entwürfe

Noch nicht ausgelöste Nachrichten.

+

Geplant

Nachrichten mit Versandzeitpunkt.

+

Empfänger

Geplante oder erreichte Mitglieder.

+
+
+
+

+
+ + + + + + + +
+
+
+
+

Empfängervorschau

+
+

Striche in 100 Tagen

Keine aktiven Mitglieder

Für die gewählte Zielgruppe wurden derzeit keine Empfänger gefunden.

+
+
+
+

Kampagnen

TitelKanalEmpfängerStatusAktion


Personen
Bearbeiten
Noch keine Benachrichtigungen gespeichert.
+

Versandprotokoll

KanalEmpfängerTypStatus
Noch keine Versandprotokolle vorhanden.
+
Konto

Persönliche Einstellungen

Hier pflegst du deinen Anzeigenamen, dein Passwort und deinen persönlichen Anzeigemodus. Diese Einstellungen gelten nur für dich.

@@ -1584,6 +1778,142 @@ $marketing = app_marketing_messages();
+ +
+
Sonderprozess
+

Jahresabschluss und Bonus

+

Hier berechnest du die Jahresverteilung auf Basis der im Jahr gebuchten Striche und verbuchst sie als nachvollziehbare Auszahlungen.

+
+ + +
Die Migration für den Jahresabschluss fehlt noch. Bitte die Datenbankmigrationen ausführen.
+ + + +
+ + +
+
+

Vorschau berechnen

+
+ + + +

Im proportionalen Modus wird der Bonus-Topf nach Jahresstrichen verteilt. Im fixen Modus erhält jedes aktive Mitglied denselben Betrag.

+ + + + +
+
+
+
+

Was die Vorschau zeigt

+
+
+ +

Jahresstriche

+

Aktive Striche im ausgewählten Jahr.

+
+
+ +

Aktive Mitglieder

+

Mitglieder, die in die Berechnung eingehen.

+
+
+ +

Verteilte Summe

+

Effektiv berechnete Bonusgesamtsumme.

+
+
+ +

Empfänger

+

Mitglieder mit einer Auszahlung größer 0.

+
+
+ +
Für dieses Jahr existiert bereits ein verbuchter Jahresabschluss vom .
+ +
+
+ + +
+
+
+
Vorschau
+

Berechnete Verteilung für

+
+ + aktive Mitglieder +
+
+ +
+ + + + + + + + +
+ +
Für dieses Jahr wurde bereits ein Lauf abgeschlossen. Neue Buchungen werden hier deshalb nicht mehr angeboten.
+ +
+
+ + + + + + + + + + + + + + +
MitgliedJahresstricheAnteilBonusSaldo vorherSaldo nachher

+
+
+ + +
+
+
+
Verlauf
+

Letzte Jahresabschlüsse

+
+ Läufe +
+
+ + + + + + + + + + + + + + + + + + +
JahrModusSummeStricheEmpfängerAusgeführtVon
Noch kein Jahresabschluss verbucht.
+
+
PDF-Vorschau

Die Zeilenhöhe liegt aktuell bei mm.

diff --git a/saas-app/public/notifications/index.php b/saas-app/public/notifications/index.php new file mode 100644 index 0000000..aaa4951 --- /dev/null +++ b/saas-app/public/notifications/index.php @@ -0,0 +1,7 @@ + $surveyId] + ); + + foreach ($questions as &$question) { + $question['options'] = survey_options_from_storage((string) ($question['options_json'] ?? ''), (string) ($question['question_type'] ?? 'text')); + } + unset($question); + + return $questions; +} + +function survey_build_results(PDO $pdo, array $questions, string $surveyId): array +{ + $results = []; + + foreach ($questions as $question) { + $questionId = (string) ($question['id'] ?? ''); + $questionType = (string) ($question['question_type'] ?? 'text'); + $entries = []; + + if ($questionType === 'text') { + $entries = app_query_all( + $pdo, + 'SELECT answer_text, created_at FROM survey_answers WHERE survey_id = :survey_id AND question_id = :question_id AND answer_text <> "" ORDER BY created_at DESC LIMIT 5', + ['survey_id' => $surveyId, 'question_id' => $questionId] + ); + } elseif ($questionType === 'multi_select') { + $summary = []; + foreach (app_query_all( + $pdo, + 'SELECT answer_text FROM survey_answers WHERE survey_id = :survey_id AND question_id = :question_id ORDER BY created_at ASC', + ['survey_id' => $surveyId, 'question_id' => $questionId] + ) as $answer) { + $answerText = trim((string) ($answer['answer_text'] ?? '')); + if ($answerText === '') { + continue; + } + + foreach (array_filter(array_map('trim', preg_split('/\R+/', str_replace([",", "\r\n"], "\n", $answerText)) ?: [])) as $item) { + $summary[$item] = ($summary[$item] ?? 0) + 1; + } + } + + $options = is_array($question['options'] ?? null) ? array_values(array_map('strval', $question['options'])) : []; + $orderedSummary = []; + if ($options !== []) { + foreach ($options as $option) { + $orderedSummary[$option] = $summary[$option] ?? 0; + } + foreach ($summary as $label => $count) { + if (!array_key_exists($label, $orderedSummary)) { + $orderedSummary[$label] = $count; + } + } + } else { + ksort($summary); + $orderedSummary = $summary; + } + + foreach ($orderedSummary as $label => $count) { + $entries[] = [ + 'answer_text' => $label, + 'answer_count' => $count, + ]; + } + } else { + $entries = app_query_all( + $pdo, + 'SELECT answer_text, COUNT(*) AS answer_count FROM survey_answers WHERE survey_id = :survey_id AND question_id = :question_id GROUP BY answer_text ORDER BY answer_count DESC, answer_text ASC', + ['survey_id' => $surveyId, 'question_id' => $questionId] + ); + } + + $results[] = [ + 'question' => $question, + 'entries' => $entries, + ]; + } + + return $results; +} + +function survey_build_snapshot(PDO $pdo, array $survey): array +{ + $questions = is_array($survey['questions'] ?? null) ? $survey['questions'] : []; + $surveyId = (string) ($survey['id'] ?? ''); + + return [ + 'survey' => [ + 'id' => $surveyId, + 'tenant_id' => (string) ($survey['tenant_id'] ?? ''), + 'title' => (string) ($survey['title'] ?? ''), + 'status' => (string) ($survey['status'] ?? 'draft'), + 'starts_at' => (string) ($survey['starts_at'] ?? ''), + 'ends_at' => (string) ($survey['ends_at'] ?? ''), + 'created_at' => (string) ($survey['created_at'] ?? ''), + 'updated_at' => (string) ($survey['updated_at'] ?? ''), + ], + 'questions' => array_map(static function (array $question): array { + return [ + 'id' => (string) ($question['id'] ?? ''), + 'question' => (string) ($question['question'] ?? ''), + 'question_type' => (string) ($question['question_type'] ?? 'text'), + 'is_required' => (int) ($question['is_required'] ?? 0), + 'sort_order' => (int) ($question['sort_order'] ?? 0), + 'options' => is_array($question['options'] ?? null) ? array_values($question['options']) : [], + ]; + }, $questions), + 'results' => survey_build_results($pdo, $questions, $surveyId), + ]; +} + +function survey_decode_publication_row(array $row): array +{ + $snapshot = json_decode((string) ($row['snapshot_json'] ?? '[]'), true); + $results = json_decode((string) ($row['results_json'] ?? '[]'), true); + + return [ + 'id' => (string) ($row['id'] ?? ''), + 'survey_id' => (string) ($row['survey_id'] ?? ''), + 'tenant_id' => (string) ($row['tenant_id'] ?? ''), + 'version_no' => (int) ($row['version_no'] ?? 0), + 'title' => (string) ($row['title'] ?? ''), + 'member_visible' => !empty($row['member_visible']), + 'published_by' => (string) ($row['published_by'] ?? ''), + 'published_at' => (string) ($row['published_at'] ?? ''), + 'response_count' => (int) ($row['response_count'] ?? 0), + 'snapshot' => is_array($snapshot) ? $snapshot : [], + 'results' => is_array($results) ? $results : [], + ]; +} + +function survey_fetch_publications(PDO $pdo, string $surveyId): array +{ + if (!survey_publications_ready($pdo) || $surveyId === '') { + return []; + } + + $rows = app_query_all( + $pdo, + 'SELECT id, survey_id, tenant_id, version_no, title, member_visible, published_by, published_at, snapshot_json, results_json, response_count, created_at, updated_at FROM survey_publications WHERE survey_id = :survey_id ORDER BY version_no DESC, published_at DESC', + ['survey_id' => $surveyId] + ); + + return array_map('survey_decode_publication_row', $rows); +} + +function survey_latest_publication(PDO $pdo, string $surveyId): ?array +{ + $publications = survey_fetch_publications($pdo, $surveyId); + + return $publications[0] ?? null; +} + +function survey_publish_snapshot(PDO $pdo, string $tenantId, array $auth, array $survey, bool $memberVisible): array +{ + if (!survey_publications_ready($pdo)) { + throw new RuntimeException('Die Freigabe kann erst nach dem Datenbank-Update verwendet werden.'); + } + + $surveyId = (string) ($survey['id'] ?? ''); + $questions = is_array($survey['questions'] ?? null) ? $survey['questions'] : []; + + if ($surveyId === '') { + throw new RuntimeException('Die gewählte Umfrage konnte nicht freigegeben werden.'); + } + + if ($questions === []) { + throw new RuntimeException('Eine Umfrage braucht mindestens eine Frage, bevor sie freigegeben werden kann.'); + } + + $now = date('Y-m-d H:i:s'); + $snapshot = survey_build_snapshot($pdo, $survey); + $results = $snapshot['results']; + + $versionNo = (int) (app_query_value( + $pdo, + 'SELECT COALESCE(MAX(version_no), 0) FROM survey_publications WHERE survey_id = :survey_id', + ['survey_id' => $surveyId], + 0 + ) ?? 0) + 1; + + $publishedBy = trim((string) ($auth['display_name'] ?? '')); + if ($publishedBy === '') { + $publishedBy = trim((string) ($auth['name'] ?? '')); + } + if ($publishedBy === '') { + $publishedBy = 'Unbekannt'; + } + + app_execute( + $pdo, + 'INSERT INTO survey_publications (id, tenant_id, survey_id, version_no, title, member_visible, published_by, published_at, snapshot_json, results_json, response_count, created_at, updated_at) VALUES (:id, :tenant_id, :survey_id, :version_no, :title, :member_visible, :published_by, :published_at, :snapshot_json, :results_json, :response_count, :created_at, :updated_at)', + [ + 'id' => app_uuid(), + 'tenant_id' => $tenantId, + 'survey_id' => $surveyId, + 'version_no' => $versionNo, + 'title' => (string) ($survey['title'] ?? ''), + 'member_visible' => $memberVisible ? 1 : 0, + 'published_by' => $publishedBy, + 'published_at' => $now, + 'snapshot_json' => json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'results_json' => json_encode($results, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'response_count' => (int) app_query_value( + $pdo, + 'SELECT COUNT(DISTINCT tenant_user_id) FROM survey_answers WHERE survey_id = :survey_id', + ['survey_id' => $surveyId], + 0 + ), + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + + app_execute( + $pdo, + 'UPDATE surveys SET status = :status, updated_at = :updated_at WHERE tenant_id = :tenant_id AND id = :id', + [ + 'status' => 'published', + 'updated_at' => $now, + 'tenant_id' => $tenantId, + 'id' => $surveyId, + ] + ); + + return survey_latest_publication($pdo, $surveyId) ?? []; +} + function survey_fetch_list(PDO $pdo, string $tenantId): array { - return app_query_all( + $surveys = app_query_all( $pdo, <<<'SQL' SELECT @@ -85,6 +336,11 @@ ORDER BY SQL, ['tenant_id' => $tenantId] ); + + return array_map(static function (array $survey) use ($pdo): array { + $survey['questions'] = survey_fetch_questions($pdo, (string) ($survey['id'] ?? '')); + return $survey; + }, $surveys); } function survey_fetch_detail(PDO $pdo, string $tenantId, string $surveyId): ?array @@ -103,22 +359,7 @@ function survey_fetch_detail(PDO $pdo, string $tenantId, string $surveyId): ?arr return null; } - $hasOptions = app_table_has_column($pdo, 'survey_questions', 'options_json'); - $questionSelect = $hasOptions ? 'options_json' : 'NULL AS options_json'; - $survey['questions'] = app_query_all( - $pdo, - str_replace( - '__OPTIONS__', - $questionSelect, - <<<'SQL' -SELECT id, question, question_type, is_required, sort_order, __OPTIONS__ -FROM survey_questions -WHERE survey_id = :survey_id -ORDER BY sort_order ASC, created_at ASC -SQL - ), - ['survey_id' => $surveyId] - ); + $survey['questions'] = survey_fetch_questions($pdo, $surveyId); return $survey; } @@ -143,49 +384,7 @@ function survey_fetch_answer_map(PDO $pdo, string $surveyId, string $tenantUserI function survey_fetch_results(PDO $pdo, string $surveyId): array { - $results = []; - $hasOptions = app_table_has_column($pdo, 'survey_questions', 'options_json'); - $questionSelect = $hasOptions ? 'options_json' : 'NULL AS options_json'; - - foreach (app_query_all( - $pdo, - str_replace( - '__OPTIONS__', - $questionSelect, - <<<'SQL' -SELECT id, question, question_type, is_required, sort_order, __OPTIONS__ -FROM survey_questions -WHERE survey_id = :survey_id -ORDER BY sort_order ASC, created_at ASC -SQL - ), - ['survey_id' => $surveyId] - ) as $question) { - $questionId = (string) ($question['id'] ?? ''); - $questionType = (string) ($question['question_type'] ?? 'text'); - $entries = []; - - if ($questionType === 'text') { - $entries = app_query_all( - $pdo, - 'SELECT answer_text, created_at FROM survey_answers WHERE question_id = :question_id AND answer_text <> "" ORDER BY created_at DESC LIMIT 5', - ['question_id' => $questionId] - ); - } else { - $entries = app_query_all( - $pdo, - 'SELECT answer_text, COUNT(*) AS answer_count FROM survey_answers WHERE question_id = :question_id GROUP BY answer_text ORDER BY answer_count DESC, answer_text ASC', - ['question_id' => $questionId] - ); - } - - $results[] = [ - 'question' => $question, - 'entries' => $entries, - ]; - } - - return $results; + return survey_build_results($pdo, survey_fetch_questions($pdo, $surveyId), $surveyId); } function survey_save_survey(PDO $pdo, string $tenantId, array $input): void @@ -306,7 +505,7 @@ function survey_submit_answers(PDO $pdo, array $auth, array $survey): void throw new RuntimeException('Die Teilnahme konnte nicht zugeordnet werden.'); } - if (!in_array((string) ($survey['status'] ?? 'draft'), ['active'], true)) { + if (!in_array((string) ($survey['status'] ?? 'draft'), ['active', 'published'], true)) { throw new RuntimeException('Diese Umfrage ist aktuell nicht für Teilnahmen freigegeben.'); } @@ -383,9 +582,12 @@ $surveys = []; $selectedSurvey = null; $selectedSurveyAnswers = []; $selectedSurveyResults = []; +$selectedSurveyPublication = null; +$selectedSurveyPublications = []; $editingQuestion = null; $canManage = app_can_manage_surveys($auth); $surveyTablesReady = false; +$publicationTablesReady = false; $csrf = (string) ($_SESSION['survey_csrf'] ?? ''); try { @@ -403,6 +605,7 @@ try { $surveyTablesReady = scripts_table_exists($pdo, 'surveys') && scripts_table_exists($pdo, 'survey_questions') && scripts_table_exists($pdo, 'survey_answers'); + $publicationTablesReady = survey_publications_ready($pdo); } catch (Throwable $exception) { $dbError = $exception->getMessage(); } @@ -428,7 +631,7 @@ if ($pdo instanceof PDO && $surveyTablesReady && ($_SERVER['REQUEST_METHOD'] ?? if ($action === 'change-survey-status' && $canManage) { $status = (string) ($_POST['status'] ?? 'draft'); - if (!in_array($status, ['draft', 'active', 'closed'], true)) { + if (!in_array($status, ['draft', 'active', 'published', 'closed'], true)) { $status = 'draft'; } app_execute( @@ -444,6 +647,16 @@ if ($pdo instanceof PDO && $surveyTablesReady && ($_SERVER['REQUEST_METHOD'] ?? app_flash('Der Umfragestatus wurde aktualisiert.', 'success'); } + if ($action === 'publish-survey' && $canManage) { + $survey = survey_fetch_detail($pdo, $tenantId, (string) ($_POST['survey_id'] ?? '')); + if ($survey === null) { + throw new RuntimeException('Die ausgewählte Umfrage konnte nicht gefunden werden.'); + } + + $publication = survey_publish_snapshot($pdo, $tenantId, $auth, $survey, !empty($_POST['member_visible'])); + app_flash('Der Snapshot wurde freigegeben (Version ' . (string) ($publication['version_no'] ?? 1) . ').', 'success'); + } + if ($action === 'answer-survey') { $survey = survey_fetch_detail($pdo, $tenantId, (string) ($_POST['survey_id'] ?? '')); if ($survey === null) { @@ -471,6 +684,8 @@ if ($pdo instanceof PDO && $surveyTablesReady) { $selectedSurvey = survey_fetch_detail($pdo, $tenantId, $selectedSurveyId); $selectedSurveyAnswers = survey_fetch_answer_map($pdo, $selectedSurveyId, (string) ($auth['tenant_user_id'] ?? '')); $selectedSurveyResults = $canManage && $selectedSurvey !== null ? survey_fetch_results($pdo, $selectedSurveyId) : []; + $selectedSurveyPublication = $selectedSurvey !== null ? survey_latest_publication($pdo, $selectedSurveyId) : null; + $selectedSurveyPublications = $selectedSurvey !== null ? survey_fetch_publications($pdo, $selectedSurveyId) : []; if ($selectedSurvey !== null && isset($_GET['question']) && $_GET['question'] !== '') { foreach (($selectedSurvey['questions'] ?? []) as $question) { @@ -671,7 +886,32 @@ $themeCss = app_tenant_theme_root_css($tenantSettings); + +
+
Freigegebener Snapshot
+ Version +

Freigegeben am von .

+

+
+ + + +
+ + + + +
+
+ +
+
Freigabe
+ Freigabe noch nicht verfügbar +

Bitte die neue Publication-Migration ausführen, dann kann der Snapshot veröffentlicht werden.

+
+ +
@@ -701,10 +941,28 @@ $themeCss = app_tenant_theme_root_css($tenantSettings); + + +
+ + + + + + + + + + + + +
VersionFreigegebenSichtbarkeitRückmeldungen
v
von
+
+
- +

@@ -742,9 +1000,39 @@ $themeCss = app_tenant_theme_root_css($tenantSettings);
+ +
+
Freigegebene Ergebnisse
+ +

Snapshot v mit Rückmeldungen.

+ +
+ + +
+

+ +
  • ()
+ +
  • :
+ +
+ +
+ +

Dieser Stand ist für Mitglieder noch nicht sichtbar.

+ +
+ +
+
Freigegebene Ergebnisse
+ Noch nicht veröffentlicht +

Mitglieder sehen nur Ergebnisse aus einem freigegebenen Snapshot.

+
+

Diese Umfrage ist derzeit nicht freigegeben

-

Mitglieder können nur Umfragen mit Status active beantworten.

+

Mitglieder können nur Umfragen mit Status active oder published beantworten.

diff --git a/saas-app/public/year-end/index.php b/saas-app/public/year-end/index.php new file mode 100644 index 0000000..032d2ba --- /dev/null +++ b/saas-app/public/year-end/index.php @@ -0,0 +1,7 @@ +

Benachrichtigungen

-

Benachrichtigungen

+

Massenkommunikation pro Tenant

- Die alte Sammelmail-Funktion geht in ein Modul ueber, das Versandregeln, - Cron-Ausfuehrung und Ergebnisprotokolle sauber trennt. Damit werden - Schuldenhinweise und Service-Meldungen tenantfaehig. + Hier entstehen Hinweise, Erinnerungen und Service-Meldungen fuer alle oder + eine Teilmenge der Mitglieder. Entweder als Entwurf, geplant oder direkt + als Versandprotokoll.

- Mail templates - Cron dispatch - Audit logs + Kampagnen: {{ number_format((int) ($notificationSummary['total'] ?? 0), 0, ',', '.') }} + Empfaenger: {{ number_format((int) ($notificationSummary['recipient_count'] ?? 0), 0, ',', '.') }} + {{ $tenantLicense['plan_name'] ?? 'Free' }}
+ @if(!$notificationStorageReady) +
+

Fallback aktiv

+

+ Die Datenbanktabelle fuer Benachrichtigungen ist noch nicht vorhanden. + Solange die Migration nicht gelaufen ist, werden Demo-Daten angezeigt. +

+
+ @endif + + @if(!empty($dbError)) +
+

Datenbankhinweis

+

{{ $dbError }}

+
+ @endif +
-

Benachrichtigungsarten

-
-
-

Saldo-Erinnerung

-

Automatisch bei negativem Kontostand oder Schwellwerten.

+

Neue Benachrichtigung

+

Damit lassen sich Erinnerungen und Hinweise direkt als Entwurf oder Sofortversand anlegen.

+ +
+ + + + + + + +
+ + +
-
-

Import- und Exportstatus

-

Backoffice meldet Erfolge, Warnungen und Fehler an Admins.

+ + + +
+ +
-
-

Service-Kommunikation

-

Hinweise zu Preisen, Wartung oder Monatsabschluss zentral ausspielen.

-
-
+
+
+

Empfängervorschau

+

Die Vorschau folgt dem aktuellen Filter und dient als schnelle Kontrolle vor dem Versand.

+ +
+ @foreach($notificationScopeOptions as $option) + {{ $option['label'] }} + @endforeach +
+ +
+ @forelse($notificationPreview as $member) +
+

{{ $member['display_name'] ?? 'Mitglied' }}

+

+ {{ number_format((int) ($member['recent_strokes'] ?? 0), 0, ',', '.') }} Striche in 100 Tagen +

+
+ @empty +
+

Keine aktiven Mitglieder

+

Fuer die gewaehlte Zielgruppe stehen gerade keine Empfaenger zur Verfuegung.

+
+ @endforelse +
+
+
+ +
-

Versandprotokoll

-

Letzte Benachrichtigungen

+

Kampagnen

+

Geplante und gesendete Nachrichten

Tenant scoped
@@ -51,34 +124,82 @@ - + + - + + + + + + @forelse($notificationMessages as $message) + + + + + + + + @empty + + + + @endforelse + +
TypTitelKanal EmpfaengerAusloeserStatusAktion
+ {{ $message['title'] ?? '' }}
+ {{ $message['message'] ?? '' }} +
{{ $message['channel_label'] ?? 'E-Mail' }} + {{ $message['scope_label'] ?? 'Alle Mitglieder' }}
+ {{ number_format((int) ($message['recipient_count'] ?? 0), 0, ',', '.') }} Personen +
{{ $message['status_label'] ?? 'Entwurf' }} + Bearbeiten +
Noch keine Benachrichtigungen gespeichert.
+ +
+ +
+
+
+

Versandprotokoll

+

Letzte Logeintraege

+
+ Audit trail +
+
+ + + + + + - - - - - - - - - - - - - - - - - - + @forelse($notificationLogs as $log) + + + + + + + @empty + + + + @endforelse
KanalEmpfaengerTyp Status
Saldo-Erinnerung6 Mitgliedernegativer KontostandGesendet
Import-ReportTenant AdminImportjob fertigBereitgestellt
Service-Hinweisalle MitgliederPreisanpassungEingeplant
{{ $log['channel_label'] ?? 'E-Mail' }}{{ $log['recipient'] ?? '-' }}{{ $log['template_key'] ?? '-' }}{{ $log['status_label'] ?? 'Geplant' }}
Noch keine Versandprotokolle vorhanden.
+ +
+

Einordnung

+

+ Das Modul ist bewusst schlank gehalten: Kampagne anlegen, Empfaenger pruefen, speichern oder sofort versenden. + Die eigentliche Auslieferung kann spaeter ueber den Cronjob mit der vorhandenen Dispositionslogik erweitert werden. +

+
@endsection