diff --git a/saas-app/database/migrations/2026_04_08_000001_add_options_json_to_survey_questions_table.php b/saas-app/database/migrations/2026_04_08_000001_add_options_json_to_survey_questions_table.php new file mode 100644 index 0000000..2d80649 --- /dev/null +++ b/saas-app/database/migrations/2026_04_08_000001_add_options_json_to_survey_questions_table.php @@ -0,0 +1,6 @@ + $tenantId] + ); +} + +function app_bulk_stroke_entries(array $input): array +{ + $entries = []; + + foreach ($input as $memberId => $value) { + $strokes = max(0, (int) $value); + if ($strokes > 0) { + $entries[(string) $memberId] = $strokes; + } + } + + return $entries; +} + +function app_bulk_payment_entries(array $input): array +{ + $entries = []; + + foreach ($input as $memberId => $value) { + $amount = is_numeric($value) ? (float) $value : 0.0; + if ($amount > 0) { + $entries[(string) $memberId] = $amount; + } + } + + return $entries; +} + +if (!function_exists('app_create_coffee_booking')) { + function app_create_coffee_booking(PDO $pdo, string $tenantId, string $memberId, int $strokes, float $unitPrice, string $bookedAt, string $source = 'manual'): void + { + $entryId = app_uuid(); + $ledgerId = app_uuid(); + $totalCost = round($strokes * $unitPrice, 2); + $now = date('Y-m-d H:i:s'); + + app_execute( + $pdo, + 'INSERT INTO coffee_entries (id, tenant_id, member_id, strokes, unit_price, total_cost, booking_source, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :strokes, :unit_price, :total_cost, :booking_source, :booked_at, :created_at, :updated_at)', + [ + 'id' => $entryId, + 'tenant_id' => $tenantId, + 'member_id' => $memberId, + 'strokes' => $strokes, + 'unit_price' => number_format($unitPrice, 2, '.', ''), + 'total_cost' => number_format($totalCost, 2, '.', ''), + 'booking_source' => $source, + 'booked_at' => $bookedAt, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + + app_execute( + $pdo, + '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, + 'member_id' => $memberId, + 'entry_type' => 'coffee_charge', + 'amount' => number_format($totalCost * -1, 2, '.', ''), + 'reference_type' => 'coffee_entry', + 'reference_id' => $entryId, + 'booked_at' => $bookedAt, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + } +} + +if (!function_exists('app_create_payment_booking')) { + function app_create_payment_booking(PDO $pdo, string $tenantId, string $memberId, float $amount, string $bookedAt, string $method = 'manual'): void + { + $entryId = app_uuid(); + $ledgerId = app_uuid(); + $now = date('Y-m-d H:i:s'); + + app_execute( + $pdo, + 'INSERT INTO payment_entries (id, tenant_id, member_id, amount, payment_method, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :amount, :payment_method, :booked_at, :created_at, :updated_at)', + [ + 'id' => $entryId, + 'tenant_id' => $tenantId, + 'member_id' => $memberId, + 'amount' => number_format($amount, 2, '.', ''), + 'payment_method' => $method, + 'booked_at' => $bookedAt, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + + app_execute( + $pdo, + '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, + 'member_id' => $memberId, + 'entry_type' => 'payment_credit', + 'amount' => number_format($amount, 2, '.', ''), + 'reference_type' => 'payment_entry', + 'reference_id' => $entryId, + 'booked_at' => $bookedAt, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + } +} + +function app_handle_bulk_finance_action(PDO $pdo, array $auth): void +{ + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST' || !app_can_manage_finance($auth)) { + return; + } + + $action = (string) ($_POST['action'] ?? ''); + $tenantId = (string) ($auth['tenant_id'] ?? ''); + $bookedAt = app_normalize_datetime_input((string) ($_POST['booked_at'] ?? '')); + + if (!in_array($action, ['bulk-record-coffee', 'bulk-record-payments'], true) || $tenantId === '') { + return; + } + + try { + $pdo->beginTransaction(); + + if ($action === 'bulk-record-coffee') { + $unitPrice = (float) ($_POST['unit_price'] ?? 0); + $entries = app_bulk_stroke_entries((array) ($_POST['bulk_strokes'] ?? [])); + + if ($unitPrice <= 0 || $entries === []) { + throw new RuntimeException('Bitte gib mindestens einen Sammel-Stricheintrag und einen gültigen Preis an.'); + } + + foreach ($entries as $memberId => $strokes) { + app_create_coffee_booking($pdo, $tenantId, $memberId, $strokes, $unitPrice, $bookedAt, 'bulk'); + } + + $pdo->commit(); + app_flash(count($entries) . ' Sammel-Stricheinträge wurden verbucht.', 'success'); + app_redirect('/ledger/'); + } + + $entries = app_bulk_payment_entries((array) ($_POST['bulk_amounts'] ?? [])); + $method = trim((string) ($_POST['payment_method'] ?? 'manual')); + $method = $method !== '' ? $method : 'manual'; + + if ($entries === []) { + throw new RuntimeException('Bitte gib mindestens eine Sammel-Einzahlung an.'); + } + + foreach ($entries as $memberId => $amount) { + app_create_payment_booking($pdo, $tenantId, $memberId, $amount, $bookedAt, $method); + } + + $pdo->commit(); + app_flash(count($entries) . ' Sammel-Einzahlungen wurden verbucht.', 'success'); + app_redirect('/payments/'); + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + app_flash($exception->getMessage(), 'error'); + app_redirect(app_request_path()); + } +} + +function app_handle_csv_import(PDO $pdo, array $auth): array +{ + $result = ['rows' => [], 'summary' => ['imported' => 0, 'duplicates' => 0, 'unmatched' => 0]]; + + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST' || (string) ($_POST['action'] ?? '') !== 'import-paypal-csv') { + return $result; + } + + if (!app_can_manage_finance($auth)) { + app_flash('Für den CSV-Import brauchst du Finanz- oder Tenant-Rechte.', 'warning'); + app_redirect('/imports/'); + } + + if (!isset($_FILES['csv_file']) || (int) ($_FILES['csv_file']['error'] ?? 1) !== 0) { + app_flash('Bitte lade eine CSV-Datei hoch.', 'error'); + app_redirect('/imports/'); + } + + $tenantId = (string) ($auth['tenant_id'] ?? ''); + $handle = fopen((string) $_FILES['csv_file']['tmp_name'], 'rb'); + if ($handle === false) { + app_flash('Die CSV-Datei konnte nicht gelesen werden.', 'error'); + app_redirect('/imports/'); + } + + fgetcsv($handle, 0, ','); + $members = app_members_for_tenant($pdo, $tenantId); + $memberMap = []; + foreach ($members as $member) { + $display = strtolower(trim((string) ($member['display_name'] ?? ''))); + $email = strtolower(trim((string) ($member['email'] ?? ''))); + $reference = strtolower(trim((string) ($member['payment_reference'] ?? ''))); + + foreach ([$display, $email, $reference] as $key) { + if ($key !== '') { + $memberMap[$key] = $member; + } + } + } + + $pdo->beginTransaction(); + + try { + while (($row = fgetcsv($handle, 0, ',')) !== false) { + $bookedAt = app_normalize_datetime_input((string) ($row[0] ?? '')); + $name = strtolower(trim((string) ($row[3] ?? ''))); + $amount = (float) str_replace(',', '.', (string) ($row[7] ?? '0')); + + if ($name === '' || $amount <= 0) { + continue; + } + + $member = $memberMap[$name] ?? null; + if (!is_array($member)) { + $result['rows'][] = ['date' => $bookedAt, 'name' => $name, 'amount' => $amount, 'status' => 'unmatched']; + $result['summary']['unmatched']++; + continue; + } + + $duplicate = app_query_one( + $pdo, + 'SELECT id FROM payment_entries WHERE tenant_id = :tenant_id AND member_id = :member_id AND amount = :amount AND DATE(booked_at) = DATE(:booked_at) LIMIT 1', + [ + 'tenant_id' => $tenantId, + 'member_id' => (string) ($member['id'] ?? ''), + 'amount' => number_format($amount, 2, '.', ''), + 'booked_at' => $bookedAt, + ] + ); + + if ($duplicate !== null) { + $result['rows'][] = ['date' => $bookedAt, 'name' => (string) ($member['display_name'] ?? $name), 'amount' => $amount, 'status' => 'duplicate']; + $result['summary']['duplicates']++; + continue; + } + + app_create_payment_booking($pdo, $tenantId, (string) ($member['id'] ?? ''), $amount, $bookedAt, 'paypal_csv'); + $result['rows'][] = ['date' => $bookedAt, 'name' => (string) ($member['display_name'] ?? $name), 'amount' => $amount, 'status' => 'imported']; + $result['summary']['imported']++; + } + + fclose($handle); + $pdo->commit(); + app_flash('Der CSV-Import wurde verarbeitet.', 'success'); + } catch (Throwable $exception) { + fclose($handle); + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + app_flash($exception->getMessage(), 'error'); + app_redirect('/imports/'); + } + + return $result; +} diff --git a/saas-app/public/app-support.php b/saas-app/public/app-support.php index f1dcfbd..f4214cc 100644 --- a/saas-app/public/app-support.php +++ b/saas-app/public/app-support.php @@ -132,6 +132,19 @@ function app_query_one(PDO $pdo, string $sql, array $params = []): ?array return is_array($row) ? $row : null; } +function app_query_value(PDO $pdo, string $sql, array $params = [], mixed $default = null): mixed +{ + $row = app_query_one($pdo, $sql, $params); + + if (!is_array($row) || $row === []) { + return $default; + } + + $value = array_shift($row); + + return $value ?? $default; +} + function app_execute(PDO $pdo, string $sql, array $params = []): void { $statement = $pdo->prepare($sql); @@ -404,10 +417,12 @@ function app_tenant_navigation_items(?array $auth, array $license = []): array if ($canFinance) { $items[] = ['key' => 'ledger', 'label' => 'Buchungen', 'href' => '/ledger/']; $items[] = ['key' => 'payments', 'label' => 'Zahlungen', 'href' => '/payments/']; + $items[] = ['key' => 'imports', 'label' => 'Importe', 'href' => '/imports/']; } if ($canManage) { $items[] = ['key' => 'members', 'label' => 'Mitglieder', 'href' => '/members/']; + $items[] = ['key' => 'reports', 'label' => 'Reporting', 'href' => '/reports/']; $items[] = ['key' => 'roles', 'label' => 'Rollen', 'href' => '/tenants/roles/']; if (!empty($features['tenant_settings'])) { @@ -684,11 +699,23 @@ function app_user_by_email(PDO $pdo, string $email): ?array ); } +function app_members_support_payment_reference(PDO $pdo): bool +{ + return app_table_has_column($pdo, 'members', 'payment_reference'); +} + function app_tenant_user_by_id(PDO $pdo, string $tenantUserId, string $tenantId): ?array { + $paymentReferenceSelect = app_members_support_payment_reference($pdo) + ? 'm.payment_reference AS payment_reference,' + : 'NULL AS payment_reference,'; + return app_query_one( $pdo, - <<<'SQL' + str_replace( + '__PAYMENT_REFERENCE__', + $paymentReferenceSelect, + <<<'SQL' SELECT tu.id, tu.tenant_id, @@ -700,6 +727,7 @@ SELECT m.id AS member_id, m.display_name AS member_display_name, m.email AS member_email, + __PAYMENT_REFERENCE__ m.status AS member_status, GROUP_CONCAT(DISTINCT r.role_key ORDER BY r.role_key SEPARATOR ',') AS role_keys FROM tenant_users tu @@ -712,18 +740,26 @@ WHERE tu.id = :tenant_user_id GROUP BY tu.id, tu.tenant_id, tu.user_id, tu.status, u.email, u.display_name, u.password_hash, - m.id, m.display_name, m.email, m.status + m.id, m.display_name, m.email, payment_reference, m.status LIMIT 1 SQL, + ), ['tenant_user_id' => $tenantUserId, 'tenant_id' => $tenantId] ); } function app_membership_row_for_user(PDO $pdo, string $tenantId, string $userId): ?array { + $paymentReferenceSelect = app_members_support_payment_reference($pdo) + ? 'm.payment_reference AS payment_reference,' + : 'NULL AS payment_reference,'; + return app_query_one( $pdo, - <<<'SQL' + str_replace( + '__PAYMENT_REFERENCE__', + $paymentReferenceSelect, + <<<'SQL' SELECT tu.id, tu.tenant_id, @@ -735,6 +771,7 @@ SELECT m.id AS member_id, m.display_name AS member_display_name, m.email AS member_email, + __PAYMENT_REFERENCE__ m.status AS member_status, GROUP_CONCAT(DISTINCT r.role_key ORDER BY r.role_key SEPARATOR ',') AS role_keys FROM tenant_users tu @@ -747,9 +784,10 @@ WHERE tu.tenant_id = :tenant_id GROUP BY tu.id, tu.tenant_id, tu.user_id, tu.status, u.email, u.display_name, u.password_hash, - m.id, m.display_name, m.email, m.status + m.id, m.display_name, m.email, payment_reference, m.status LIMIT 1 SQL, + ), ['tenant_id' => $tenantId, 'user_id' => $userId] ); } @@ -762,20 +800,82 @@ function app_member_form_defaults(?array $member = null): array 'tenant_user_id' => (string) ($member['id'] ?? ''), 'display_name' => (string) ($member['member_display_name'] ?? $member['display_name'] ?? ''), 'email' => (string) ($member['member_email'] ?? $member['email'] ?? ''), + 'payment_reference' => (string) ($member['payment_reference'] ?? ''), 'status' => (string) ($member['member_status'] ?? $member['status'] ?? 'active'), 'password' => '', 'is_tenant_admin' => in_array('tenant_admin', $roleKeys, true), + 'is_finance_admin' => in_array('finance_admin', $roleKeys, true), + 'is_support_contact' => in_array('support_contact', $roleKeys, true), + 'is_survey_manager' => in_array('survey_manager', $roleKeys, true), ]; } +/** + * @param array $targetRoleKeys + */ +function app_sync_tenant_user_roles(PDO $pdo, string $tenantUserId, array $targetRoleKeys): void +{ + scripts_ensure_core_roles($pdo); + $targetRoleKeys = array_values(array_unique(array_filter($targetRoleKeys))); + $existingAssignments = app_query_all( + $pdo, + <<<'SQL' +SELECT tur.id, r.role_key +FROM tenant_user_roles tur +INNER JOIN roles r ON r.id = tur.role_id +WHERE tur.tenant_user_id = :tenant_user_id +SQL, + ['tenant_user_id' => $tenantUserId] + ); + + $existingByRole = []; + foreach ($existingAssignments as $assignment) { + $existingByRole[(string) ($assignment['role_key'] ?? '')] = (string) ($assignment['id'] ?? ''); + } + + $now = date('Y-m-d H:i:s'); + + foreach (['tenant_admin', 'finance_admin', 'support_contact', 'survey_manager'] as $roleKey) { + $hasRole = in_array($roleKey, $targetRoleKeys, true); + $assignmentId = $existingByRole[$roleKey] ?? ''; + + if ($hasRole && $assignmentId === '') { + app_execute( + $pdo, + 'INSERT INTO tenant_user_roles (id, tenant_user_id, role_id, created_at) VALUES (:id, :tenant_user_id, :role_id, :created_at)', + [ + 'id' => app_uuid(), + 'tenant_user_id' => $tenantUserId, + 'role_id' => scripts_role_id($roleKey, 'tenant'), + 'created_at' => $now, + ] + ); + } + + if (!$hasRole && $assignmentId !== '') { + app_execute( + $pdo, + 'DELETE FROM tenant_user_roles WHERE id = :id', + ['id' => $assignmentId] + ); + } + } +} + function app_upsert_member(PDO $pdo, string $tenantId, array $data): void { $tenantUserId = trim((string) ($data['tenant_user_id'] ?? '')); $displayName = trim((string) ($data['display_name'] ?? '')); $email = strtolower(trim((string) ($data['email'] ?? ''))); + $paymentReference = trim((string) ($data['payment_reference'] ?? '')); $status = trim((string) ($data['status'] ?? 'active')); $password = (string) ($data['password'] ?? ''); - $isTenantAdmin = !empty($data['is_tenant_admin']); + $targetRoleKeys = []; + foreach (['tenant_admin', 'finance_admin', 'support_contact', 'survey_manager'] as $roleKey) { + if (!empty($data[$roleKey])) { + $targetRoleKeys[] = $roleKey; + } + } if ($displayName === '') { throw new RuntimeException('Bitte gib einen Namen für die Person an.'); @@ -845,8 +945,6 @@ function app_upsert_member(PDO $pdo, string $tenantId, array $data): void } } - scripts_ensure_core_roles($pdo); - $tenantAdminRoleId = scripts_role_id('tenant_admin', 'tenant'); $now = date('Y-m-d H:i:s'); $pdo->beginTransaction(); @@ -879,15 +977,18 @@ function app_upsert_member(PDO $pdo, string $tenantId, array $data): void app_execute( $pdo, - 'UPDATE members SET display_name = :display_name, email = :email, status = :status, updated_at = :updated_at WHERE tenant_user_id = :tenant_user_id AND tenant_id = :tenant_id', - [ + app_members_support_payment_reference($pdo) + ? 'UPDATE members SET display_name = :display_name, email = :email, payment_reference = :payment_reference, status = :status, updated_at = :updated_at WHERE tenant_user_id = :tenant_user_id AND tenant_id = :tenant_id' + : 'UPDATE members SET display_name = :display_name, email = :email, status = :status, updated_at = :updated_at WHERE tenant_user_id = :tenant_user_id AND tenant_id = :tenant_id', + array_filter([ 'display_name' => $displayName, 'email' => $email, + 'payment_reference' => app_members_support_payment_reference($pdo) ? $paymentReference : null, 'status' => $status, 'updated_at' => $now, 'tenant_user_id' => $tenantUserId, 'tenant_id' => $tenantId, - ] + ], static fn($value, $key): bool => $key !== 'payment_reference' || $value !== null, ARRAY_FILTER_USE_BOTH) ); } else { if ($existingUser !== null) { @@ -937,46 +1038,24 @@ function app_upsert_member(PDO $pdo, string $tenantId, array $data): void app_execute( $pdo, - 'INSERT INTO members (id, tenant_id, tenant_user_id, display_name, email, status, created_at, updated_at) VALUES (:id, :tenant_id, :tenant_user_id, :display_name, :email, :status, :created_at, :updated_at)', - [ + app_members_support_payment_reference($pdo) + ? 'INSERT INTO members (id, tenant_id, tenant_user_id, display_name, email, payment_reference, status, created_at, updated_at) VALUES (:id, :tenant_id, :tenant_user_id, :display_name, :email, :payment_reference, :status, :created_at, :updated_at)' + : 'INSERT INTO members (id, tenant_id, tenant_user_id, display_name, email, status, created_at, updated_at) VALUES (:id, :tenant_id, :tenant_user_id, :display_name, :email, :status, :created_at, :updated_at)', + array_filter([ 'id' => app_uuid(), 'tenant_id' => $tenantId, 'tenant_user_id' => $tenantUserId, 'display_name' => $displayName, 'email' => $email, + 'payment_reference' => app_members_support_payment_reference($pdo) ? $paymentReference : null, 'status' => $status, 'created_at' => $now, 'updated_at' => $now, - ] + ], static fn($value, $key): bool => $key !== 'payment_reference' || $value !== null, ARRAY_FILTER_USE_BOTH) ); } - $existingRoleAssignment = app_query_one( - $pdo, - 'SELECT id FROM tenant_user_roles WHERE tenant_user_id = :tenant_user_id AND role_id = :role_id LIMIT 1', - ['tenant_user_id' => $tenantUserId, 'role_id' => $tenantAdminRoleId] - ); - - if ($isTenantAdmin && $existingRoleAssignment === null) { - app_execute( - $pdo, - 'INSERT INTO tenant_user_roles (id, tenant_user_id, role_id, created_at) VALUES (:id, :tenant_user_id, :role_id, :created_at)', - [ - 'id' => app_uuid(), - 'tenant_user_id' => $tenantUserId, - 'role_id' => $tenantAdminRoleId, - 'created_at' => $now, - ] - ); - } - - if (!$isTenantAdmin && $existingRoleAssignment !== null) { - app_execute( - $pdo, - 'DELETE FROM tenant_user_roles WHERE id = :id', - ['id' => $existingRoleAssignment['id']] - ); - } + app_sync_tenant_user_roles($pdo, $tenantUserId, $targetRoleKeys); $pdo->commit(); } catch (Throwable $exception) { @@ -995,33 +1074,35 @@ function app_handle_member_action(PDO $pdo, array $auth): void } $action = (string) ($_POST['action'] ?? ''); + $tenantId = (string) ($auth['tenant_id'] ?? ''); - if ($action !== 'save-member') { - return; - } + if ($action === 'save-member') { + if (!app_can_manage_tenant($auth)) { + app_flash('Für diese Aktion brauchst du Tenant-Admin-Rechte.', 'warning'); + app_redirect('/members/'); + } + + try { + app_upsert_member($pdo, $tenantId, [ + 'tenant_user_id' => (string) ($_POST['tenant_user_id'] ?? ''), + 'display_name' => (string) ($_POST['display_name'] ?? ''), + 'email' => (string) ($_POST['email'] ?? ''), + 'payment_reference' => (string) ($_POST['payment_reference'] ?? ''), + 'status' => (string) ($_POST['status'] ?? 'active'), + 'password' => (string) ($_POST['password'] ?? ''), + 'tenant_admin' => isset($_POST['tenant_admin']), + 'finance_admin' => isset($_POST['finance_admin']), + 'support_contact' => isset($_POST['support_contact']), + 'survey_manager' => isset($_POST['survey_manager']), + ]); + app_flash('Die Person wurde im Mandanten gespeichert.', 'success'); + } catch (Throwable $exception) { + app_flash($exception->getMessage(), 'error'); + } - if (!app_can_manage_tenant($auth)) { - app_flash('Für diese Aktion brauchst du Tenant-Admin-Rechte.', 'warning'); app_redirect('/members/'); } - $tenantId = (string) ($auth['tenant_id'] ?? ''); - - try { - app_upsert_member($pdo, $tenantId, [ - 'tenant_user_id' => (string) ($_POST['tenant_user_id'] ?? ''), - 'display_name' => (string) ($_POST['display_name'] ?? ''), - 'email' => (string) ($_POST['email'] ?? ''), - 'status' => (string) ($_POST['status'] ?? 'active'), - 'password' => (string) ($_POST['password'] ?? ''), - 'is_tenant_admin' => isset($_POST['is_tenant_admin']), - ]); - app_flash('Die Person wurde im Mandanten gespeichert.', 'success'); - } catch (Throwable $exception) { - app_flash($exception->getMessage(), 'error'); - } - - app_redirect('/members/'); } function app_handle_settings_action(PDO $pdo, array $auth): void @@ -1056,6 +1137,204 @@ function app_handle_settings_action(PDO $pdo, array $auth): void app_redirect('/settings/'); } +function app_handle_profile_action(PDO $pdo, array $auth): void +{ + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST' || (string) ($_POST['action'] ?? '') !== 'save-profile') { + return; + } + + $tenantId = (string) ($auth['tenant_id'] ?? ''); + $tenantUserId = (string) ($auth['tenant_user_id'] ?? ''); + $displayName = trim((string) ($_POST['display_name'] ?? '')); + $password = (string) ($_POST['password'] ?? ''); + + if ($tenantId === '' || $tenantUserId === '') { + app_flash('Das Profil konnte nicht aktualisiert werden.', 'error'); + app_redirect('/dashboard/'); + } + + if ($displayName === '') { + app_flash('Bitte gib einen Anzeigenamen an.', 'error'); + app_redirect('/dashboard/'); + } + + $membership = app_tenant_user_by_id($pdo, $tenantUserId, $tenantId); + + if ($membership === null) { + app_flash('Die zugehörige Person konnte nicht gefunden werden.', 'error'); + app_redirect('/dashboard/'); + } + + $now = date('Y-m-d H:i:s'); + $params = [ + 'display_name' => $displayName, + 'updated_at' => $now, + 'id' => (string) $membership['user_id'], + ]; + $sql = 'UPDATE users SET display_name = :display_name, updated_at = :updated_at'; + + if ($password !== '') { + $sql .= ', password_hash = :password_hash'; + $params['password_hash'] = password_hash($password, PASSWORD_BCRYPT); + } + + $sql .= ' WHERE id = :id'; + + app_execute($pdo, $sql, $params); + app_execute( + $pdo, + 'UPDATE members SET display_name = :display_name, updated_at = :updated_at WHERE tenant_user_id = :tenant_user_id AND tenant_id = :tenant_id', + [ + 'display_name' => $displayName, + 'updated_at' => $now, + 'tenant_user_id' => $tenantUserId, + 'tenant_id' => $tenantId, + ] + ); + + app_set_auth_user(array_merge($auth, ['display_name' => $displayName])); + app_flash('Dein Profil wurde aktualisiert.', 'success'); + app_redirect('/dashboard/'); +} + +function app_handle_content_action(PDO $pdo, array $auth): void +{ + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + return; + } + + $action = (string) ($_POST['action'] ?? ''); + + if (!in_array($action, ['save-announcement', 'save-faq', 'archive-announcement', 'archive-faq'], true)) { + return; + } + + if (!app_can_manage_tenant($auth)) { + app_flash('Für diese Aktion brauchst du Tenant-Admin-Rechte.', 'warning'); + app_redirect('/content/'); + } + + $tenantId = (string) ($auth['tenant_id'] ?? ''); + $now = date('Y-m-d H:i:s'); + + try { + if ($action === 'save-announcement') { + $id = trim((string) ($_POST['announcement_id'] ?? '')); + $title = trim((string) ($_POST['title'] ?? '')); + $message = trim((string) ($_POST['message'] ?? '')); + $visibleUntil = trim((string) ($_POST['visible_until'] ?? '')); + $visibleUntil = $visibleUntil !== '' ? str_replace('T', ' ', $visibleUntil) . ':00' : null; + + if ($title === '' || $message === '') { + throw new RuntimeException('Bitte gib Titel und Hinweistext an.'); + } + + if ($id !== '') { + app_execute( + $pdo, + 'UPDATE announcements SET title = :title, message = :message, visible_until = :visible_until, is_active = 1, updated_at = :updated_at WHERE id = :id AND tenant_id = :tenant_id', + [ + 'title' => $title, + 'message' => $message, + 'visible_until' => $visibleUntil, + 'updated_at' => $now, + 'id' => $id, + 'tenant_id' => $tenantId, + ] + ); + } else { + app_execute( + $pdo, + 'INSERT INTO announcements (id, tenant_id, title, message, visible_until, is_active, created_at, updated_at) VALUES (:id, :tenant_id, :title, :message, :visible_until, 1, :created_at, :updated_at)', + [ + 'id' => app_uuid(), + 'tenant_id' => $tenantId, + 'title' => $title, + 'message' => $message, + 'visible_until' => $visibleUntil, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + } + + app_flash('Der Hinweis wurde gespeichert.', 'success'); + } + + if ($action === 'save-faq') { + $id = trim((string) ($_POST['faq_id'] ?? '')); + $question = trim((string) ($_POST['question'] ?? '')); + $answer = trim((string) ($_POST['answer'] ?? '')); + $sortOrder = max(0, (int) ($_POST['sort_order'] ?? 0)); + + if ($question === '' || $answer === '') { + throw new RuntimeException('Bitte gib Frage und Antwort an.'); + } + + if ($id !== '') { + app_execute( + $pdo, + 'UPDATE faq_items SET question = :question, answer = :answer, sort_order = :sort_order, is_active = 1, updated_at = :updated_at WHERE id = :id AND tenant_id = :tenant_id', + [ + 'question' => $question, + 'answer' => $answer, + 'sort_order' => $sortOrder, + 'updated_at' => $now, + 'id' => $id, + 'tenant_id' => $tenantId, + ] + ); + } else { + app_execute( + $pdo, + 'INSERT INTO faq_items (id, tenant_id, question, answer, sort_order, is_active, created_at, updated_at) VALUES (:id, :tenant_id, :question, :answer, :sort_order, 1, :created_at, :updated_at)', + [ + 'id' => app_uuid(), + 'tenant_id' => $tenantId, + 'question' => $question, + 'answer' => $answer, + 'sort_order' => $sortOrder, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + } + + app_flash('Der FAQ-Eintrag wurde gespeichert.', 'success'); + } + + if ($action === 'archive-announcement') { + app_execute( + $pdo, + 'UPDATE announcements SET is_active = 0, updated_at = :updated_at WHERE id = :id AND tenant_id = :tenant_id', + [ + 'updated_at' => $now, + 'id' => (string) ($_POST['announcement_id'] ?? ''), + 'tenant_id' => $tenantId, + ] + ); + app_flash('Der Hinweis wurde archiviert.', 'success'); + } + + if ($action === 'archive-faq') { + app_execute( + $pdo, + 'UPDATE faq_items SET is_active = 0, updated_at = :updated_at WHERE id = :id AND tenant_id = :tenant_id', + [ + 'updated_at' => $now, + 'id' => (string) ($_POST['faq_id'] ?? ''), + 'tenant_id' => $tenantId, + ] + ); + app_flash('Der FAQ-Eintrag wurde archiviert.', 'success'); + } + } catch (Throwable $exception) { + app_flash($exception->getMessage(), 'error'); + } + + app_redirect('/content/'); +} + function app_handle_export_action(PDO $pdo, array $auth, array $settings): void { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { @@ -1198,6 +1477,39 @@ function app_handle_export_download(PDO $pdo, array $auth): void ); } + if ($download === 'member-report-csv') { + if (!app_tenant_has_feature($pdo, $tenantId, 'basic_exports')) { + app_flash('Die Basis-Exporte sind in deiner Lizenz nicht freigeschaltet.', 'warning'); + app_redirect('/exports/'); + } + + $memberId = trim((string) ($_GET['member'] ?? '')); + $report = app_member_report($pdo, $tenantId, $memberId); + + if ($report === null) { + app_flash('Bitte wähle zuerst ein Mitglied für die Detailauswertung aus.', 'warning'); + app_redirect('/exports/'); + } + + $rows = []; + foreach ($report['recent_entries'] ?? [] as $entry) { + $rows[] = [ + (string) ($report['display_name'] ?? ''), + (string) ($report['email'] ?? ''), + (string) ($entry['booked_at'] ?? ''), + (string) ($entry['entry_type'] ?? ''), + (string) ($entry['reference_type'] ?? ''), + number_format((float) ($entry['amount'] ?? 0), 2, '.', ''), + ]; + } + + app_send_csv_download( + $tenantKey . '-mitglied-' . preg_replace('/[^A-Za-z0-9._-]+/', '-', strtolower((string) ($report['display_name'] ?? 'report'))) . '-' . $dateSuffix . '.csv', + ['Mitglied', 'E-Mail', 'Buchungszeit', 'Typ', 'Referenz', 'Betrag'], + $rows + ); + } + if ($download === 'print-list-pdf') { if (!app_tenant_has_feature($pdo, $tenantId, 'pdf_export')) { app_flash('Der PDF-Export ist in deiner Lizenz nicht freigeschaltet.', 'warning'); @@ -2023,8 +2335,95 @@ SQL; return $summary; } +function app_member_report(PDO $pdo, string $tenantId, string $memberId): ?array +{ + if ($memberId === '') { + return null; + } + + $member = app_query_one( + $pdo, + 'SELECT id, display_name, email, status FROM members WHERE tenant_id = :tenant_id AND id = :id LIMIT 1', + ['tenant_id' => $tenantId, 'id' => $memberId] + ); + + if ($member === null) { + return null; + } + + $member['summary'] = app_query_one( + $pdo, + <<<'SQL' +SELECT + COALESCE(SUM(le.amount), 0) AS balance, + COALESCE(( + SELECT SUM(ce.strokes) + FROM coffee_entries ce + WHERE ce.tenant_id = :tenant_id + AND ce.member_id = :member_id + ), 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 + ), 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 + ), 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 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 ce.member_id = :member_id + AND YEAR(ce.booked_at) = YEAR(CURRENT_DATE()) + ), 0) AS year_coffee_cost, + COALESCE(( + SELECT SUM(pe.amount) + FROM payment_entries pe + WHERE pe.tenant_id = :tenant_id + AND pe.member_id = :member_id + 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] + ) ?? []; + + $member['recent_entries'] = app_query_all( + $pdo, + <<<'SQL' +SELECT booked_at, entry_type, amount, reference_type +FROM ledger_entries +WHERE tenant_id = :tenant_id + AND member_id = :member_id +ORDER BY booked_at DESC +LIMIT 20 +SQL, + ['tenant_id' => $tenantId, 'member_id' => $memberId] + ); + + return $member; +} + function app_members_for_tenant(PDO $pdo, string $tenantId): array { + $paymentReferenceSelect = app_members_support_payment_reference($pdo) + ? 'm.payment_reference,' + : 'NULL AS payment_reference,'; + $sql = <<<'SQL' SELECT m.id, @@ -2032,31 +2431,180 @@ SELECT tu.user_id, m.display_name, m.email, + __PAYMENT_REFERENCE__ m.status, u.display_name AS user_display_name, GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS roles, - GROUP_CONCAT(DISTINCT r.role_key ORDER BY r.role_key SEPARATOR ',') AS role_keys + GROUP_CONCAT(DISTINCT r.role_key ORDER BY r.role_key SEPARATOR ',') AS role_keys, + COALESCE(( + SELECT SUM(le.amount) + FROM ledger_entries le + WHERE le.member_id = m.id + ), 0) AS current_balance FROM members m LEFT JOIN tenant_users tu ON tu.id = m.tenant_user_id LEFT JOIN users u ON u.id = tu.user_id LEFT JOIN tenant_user_roles tur ON tur.tenant_user_id = tu.id LEFT JOIN roles r ON r.id = tur.role_id WHERE m.tenant_id = :tenant_id -GROUP BY m.id, tu.id, tu.user_id, m.display_name, m.email, m.status, u.display_name +GROUP BY m.id, tu.id, tu.user_id, m.display_name, m.email, payment_reference, m.status, u.display_name ORDER BY m.display_name ASC SQL; - return app_query_all($pdo, $sql, ['tenant_id' => $tenantId]); + return app_query_all($pdo, str_replace('__PAYMENT_REFERENCE__', $paymentReferenceSelect, $sql), ['tenant_id' => $tenantId]); +} + +function app_normalize_datetime_input(?string $value): string +{ + $value = trim((string) $value); + + if ($value === '') { + return date('Y-m-d H:i:s'); + } + + $value = str_replace('T', ' ', $value); + + if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $value) === 1) { + return $value . ':00'; + } + + return $value; +} + +function app_member_detail(PDO $pdo, string $tenantId, string $memberId): ?array +{ + $member = app_member_exists($pdo, $tenantId, $memberId); + + if ($member === null) { + return null; + } + + $summary = app_query_one( + $pdo, + <<<'SQL' +SELECT + COALESCE(SUM(le.amount), 0) AS balance, + COALESCE(( + SELECT SUM(ce.total_cost) + FROM coffee_entries ce + WHERE ce.member_id = :member_id + AND ce.tenant_id = :tenant_id + ), 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 + ), 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 + ), 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] + ) ?? []; + + $member['summary'] = $summary; + $member['recent_entries'] = app_query_all( + $pdo, + <<<'SQL' +SELECT booked_at, entry_type, amount, reference_type +FROM ledger_entries +WHERE tenant_id = :tenant_id + AND member_id = :member_id +ORDER BY booked_at DESC +LIMIT 20 +SQL, + ['tenant_id' => $tenantId, 'member_id' => $memberId] + ); + + return $member; +} + +function app_members_for_scope(PDO $pdo, string $tenantId, string $scope = 'all'): array +{ + $rows = app_query_all( + $pdo, + <<<'SQL' +SELECT + m.id, + m.display_name, + COALESCE(( + SELECT SUM(ce.strokes) + FROM coffee_entries ce + WHERE ce.tenant_id = m.tenant_id + AND ce.member_id = m.id + AND ce.booked_at >= DATE_SUB(CURRENT_DATE(), INTERVAL 100 DAY) + ), 0) AS recent_strokes +FROM members m +WHERE m.tenant_id = :tenant_id + AND m.status = 'active' +ORDER BY m.display_name ASC +SQL, + ['tenant_id' => $tenantId] + ); + + if ($scope === 'front') { + return array_values(array_filter($rows, static fn(array $row): bool => (int) ($row['recent_strokes'] ?? 0) >= 10)); + } + + if ($scope === 'back') { + return array_values(array_filter($rows, static fn(array $row): bool => (int) ($row['recent_strokes'] ?? 0) < 10)); + } + + return $rows; +} + +function app_paypal_amount_links(array $settings, float $balance): array +{ + $baseUrl = trim((string) (($settings['paypal_me_link'] ?? '') !== '' ? $settings['paypal_me_link'] : ($settings['paypal_me_url'] ?? ''))); + + if ($baseUrl === '') { + return []; + } + + $baseUrl = rtrim($baseUrl, '/'); + $links = []; + $fixedAmounts = preg_split('/[\s,;]+/', (string) ($settings['paypal_fixed_amounts'] ?? '2,5,10')) ?: []; + + foreach ($fixedAmounts as $amount) { + if (!is_numeric($amount) || (float) $amount <= 0) { + continue; + } + + $normalized = rtrim(rtrim(number_format((float) $amount, 2, '.', ''), '0'), '.'); + $links[] = [ + 'label' => $normalized . ' EUR', + 'href' => $baseUrl . '/' . $normalized . 'EUR', + ]; + } + + if ($balance < 0) { + $openAmount = number_format(abs($balance), 2, '.', ''); + $links[] = [ + 'label' => 'Offenen Saldo zahlen', + 'href' => $baseUrl . '/' . rtrim(rtrim($openAmount, '0'), '.') . 'EUR', + ]; + } + + return $links; } function app_ledger_for_tenant(PDO $pdo, string $tenantId): array { $sql = <<<'SQL' SELECT + le.id, le.booked_at, le.entry_type, le.amount, le.reference_type, + le.reference_id, m.display_name AS member_name FROM ledger_entries le LEFT JOIN members m ON m.id = le.member_id @@ -2072,10 +2620,12 @@ function app_payments_for_tenant(PDO $pdo, string $tenantId): array { $sql = <<<'SQL' SELECT + pe.id, pe.booked_at, pe.amount, pe.payment_method, - m.display_name AS member_name + m.display_name AS member_name, + pe.member_id FROM payment_entries pe LEFT JOIN members m ON m.id = pe.member_id WHERE pe.tenant_id = :tenant_id @@ -2086,17 +2636,93 @@ SQL; return app_query_all($pdo, $sql, ['tenant_id' => $tenantId]); } +function app_delete_coffee_entry(PDO $pdo, string $tenantId, string $referenceId): void +{ + if ($referenceId === '') { + throw new RuntimeException('Der Kaffeeeintrag konnte nicht zugeordnet werden.'); + } + + $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; + } +} + +function app_delete_payment_entry(PDO $pdo, string $tenantId, string $paymentId): void +{ + if ($paymentId === '') { + throw new RuntimeException('Die Einzahlung konnte nicht zugeordnet werden.'); + } + + $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; + } +} + function app_content_for_tenant(PDO $pdo, string $tenantId): array { $announcements = app_query_all( $pdo, - 'SELECT title, message, visible_until FROM announcements WHERE tenant_id = :tenant_id AND is_active = 1 ORDER BY created_at DESC LIMIT 8', + 'SELECT id, title, message, visible_until, is_active FROM announcements WHERE tenant_id = :tenant_id ORDER BY is_active DESC, created_at DESC LIMIT 12', ['tenant_id' => $tenantId] ); $faq = app_query_all( $pdo, - 'SELECT question, answer FROM faq_items WHERE tenant_id = :tenant_id AND is_active = 1 ORDER BY sort_order ASC, created_at DESC LIMIT 8', + 'SELECT id, question, answer, sort_order, is_active FROM faq_items WHERE tenant_id = :tenant_id ORDER BY is_active DESC, sort_order ASC, created_at DESC LIMIT 12', ['tenant_id' => $tenantId] ); @@ -2106,6 +2732,432 @@ function app_content_for_tenant(PDO $pdo, string $tenantId): array ]; } +/** + * @return array + */ +function app_survey_question_options(PDO $pdo, array $row): array +{ + if (scripts_table_exists($pdo, 'survey_question_options') && isset($row['id'])) { + return array_map( + static fn(array $option): string => (string) ($option['option_label'] ?? ''), + app_query_all( + $pdo, + 'SELECT option_label FROM survey_question_options WHERE question_id = :question_id ORDER BY sort_order ASC, created_at ASC', + ['question_id' => (string) $row['id']] + ) + ); + } + + if (!app_table_has_column($pdo, 'survey_questions', 'options_json')) { + return []; + } + + $decoded = json_decode((string) ($row['options_json'] ?? '[]'), true); + + return is_array($decoded) ? array_values(array_filter(array_map('strval', $decoded))) : []; +} + +/** + * @return array> + */ +function app_surveys_for_tenant(PDO $pdo, string $tenantId): array +{ + if (!scripts_table_exists($pdo, 'surveys') || !scripts_table_exists($pdo, 'survey_questions')) { + return []; + } + + $surveys = app_query_all( + $pdo, + <<<'SQL' +SELECT + s.id, + s.title, + s.status, + s.starts_at, + s.ends_at, + s.created_at, + COUNT(DISTINCT sq.id) AS question_count, + COUNT(DISTINCT sa.tenant_user_id) AS response_count +FROM surveys s +LEFT JOIN survey_questions sq ON sq.survey_id = s.id +LEFT JOIN survey_answers sa ON sa.survey_id = s.id +WHERE s.tenant_id = :tenant_id +GROUP BY s.id, s.title, s.status, s.starts_at, s.ends_at, s.created_at +ORDER BY s.created_at DESC +SQL, + ['tenant_id' => $tenantId] + ); + + foreach ($surveys as &$survey) { + $survey['questions'] = app_query_all( + $pdo, + str_replace( + '__OPTIONS__', + app_table_has_column($pdo, 'survey_questions', 'options_json') ? 'sq.options_json' : 'NULL AS options_json', + <<<'SQL' +SELECT + sq.id, + sq.question, + sq.question_type, + sq.is_required, + sq.sort_order, + __OPTIONS__ +FROM survey_questions sq +WHERE sq.survey_id = :survey_id +ORDER BY sq.sort_order ASC, sq.created_at ASC +SQL + ), + ['survey_id' => $survey['id']] + ); + + foreach ($survey['questions'] as &$question) { + $question['options'] = app_survey_question_options($pdo, $question); + } + unset($question); + } + unset($survey); + + return $surveys; +} + +function app_survey_by_id(PDO $pdo, string $tenantId, string $surveyId): ?array +{ + foreach (app_surveys_for_tenant($pdo, $tenantId) as $survey) { + if ((string) ($survey['id'] ?? '') === $surveyId) { + return $survey; + } + } + + return null; +} + +function app_survey_results(PDO $pdo, string $surveyId): array +{ + $results = []; + $questions = app_query_all( + $pdo, + str_replace( + '__OPTIONS__', + app_table_has_column($pdo, 'survey_questions', 'options_json') ? 'options_json' : 'NULL AS options_json', + <<<'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] + ); + + foreach ($questions as $question) { + $answers = 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' => $question['id']] + ); + $options = app_survey_question_options($pdo, $question); + $summary = []; + $freeText = []; + + foreach ($answers as $answer) { + $answerText = trim((string) ($answer['answer_text'] ?? '')); + if ($answerText === '') { + continue; + } + + if (($question['question_type'] ?? 'text') === 'multi_select') { + foreach (array_filter(array_map('trim', explode("\n", str_replace(["\r\n", ','], "\n", $answerText)))) as $item) { + $summary[$item] = ($summary[$item] ?? 0) + 1; + } + continue; + } + + if (in_array((string) ($question['question_type'] ?? ''), ['scale', 'single_select'], true)) { + $summary[$answerText] = ($summary[$answerText] ?? 0) + 1; + continue; + } + + $freeText[] = $answerText; + } + + if ($options !== [] && $summary !== []) { + $sortedSummary = []; + foreach ($options as $option) { + $sortedSummary[$option] = $summary[$option] ?? 0; + } + foreach ($summary as $label => $count) { + if (!array_key_exists($label, $sortedSummary)) { + $sortedSummary[$label] = $count; + } + } + $summary = $sortedSummary; + } else { + ksort($summary); + } + + $results[] = [ + 'question' => $question, + 'summary' => $summary, + 'free_text' => array_slice($freeText, -6), + 'answer_count' => count($answers), + ]; + } + + return $results; +} + +function app_handle_survey_action(PDO $pdo, array $auth): void +{ + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + return; + } + + $action = (string) ($_POST['action'] ?? ''); + + if (!in_array($action, ['save-survey', 'submit-survey', 'set-survey-status'], true)) { + return; + } + + $tenantId = (string) ($auth['tenant_id'] ?? ''); + $now = date('Y-m-d H:i:s'); + + try { + if ($action === 'save-survey') { + if (!app_can_manage_surveys($auth)) { + throw new RuntimeException('Für diese Aktion brauchst du Umfrage-Rechte.'); + } + + $surveyId = trim((string) ($_POST['survey_id'] ?? '')); + $title = trim((string) ($_POST['title'] ?? '')); + $status = trim((string) ($_POST['status'] ?? 'draft')); + $questionTexts = $_POST['question_text'] ?? []; + $questionTypes = $_POST['question_type'] ?? []; + $requiredFlags = $_POST['question_required'] ?? []; + $optionValues = $_POST['question_options'] ?? []; + + if ($title === '') { + throw new RuntimeException('Bitte gib einen Titel für die Umfrage an.'); + } + + if (!in_array($status, ['draft', 'active', 'published', 'closed'], true)) { + $status = 'draft'; + } + + $questions = []; + foreach ($questionTexts as $index => $questionText) { + $text = trim((string) $questionText); + if ($text === '') { + continue; + } + + $type = (string) ($questionTypes[$index] ?? 'text'); + if (!in_array($type, ['text', 'scale', 'single_select', 'multi_select'], true)) { + $type = 'text'; + } + + $questions[] = [ + 'text' => $text, + 'type' => $type, + 'required' => !empty($requiredFlags[$index]), + 'options' => array_values(array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', (string) ($optionValues[$index] ?? '')) ?: []))), + ]; + } + + if ($questions === []) { + throw new RuntimeException('Bitte lege mindestens eine Frage an.'); + } + + $pdo->beginTransaction(); + + if ($surveyId === '') { + $surveyId = app_uuid(); + app_execute( + $pdo, + 'INSERT INTO surveys (id, tenant_id, title, status, starts_at, ends_at, created_at, updated_at) VALUES (:id, :tenant_id, :title, :status, NULL, NULL, :created_at, :updated_at)', + [ + 'id' => $surveyId, + 'tenant_id' => $tenantId, + 'title' => $title, + 'status' => $status, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + } else { + $existingResponses = (int) app_query_value( + $pdo, + 'SELECT COUNT(*) FROM survey_answers WHERE survey_id = :survey_id', + ['survey_id' => $surveyId], + 0 + ); + + if ($existingResponses > 0) { + throw new RuntimeException('Umfragen mit vorhandenen Antworten können nicht strukturell überschrieben werden. Bitte nur den Status ändern oder eine neue Umfrage anlegen.'); + } + + app_execute( + $pdo, + 'UPDATE surveys SET title = :title, status = :status, updated_at = :updated_at WHERE id = :id AND tenant_id = :tenant_id', + [ + 'title' => $title, + 'status' => $status, + 'updated_at' => $now, + 'id' => $surveyId, + 'tenant_id' => $tenantId, + ] + ); + if (scripts_table_exists($pdo, 'survey_question_options')) { + app_execute( + $pdo, + 'DELETE sqo FROM survey_question_options sqo INNER JOIN survey_questions sq ON sq.id = sqo.question_id WHERE sq.survey_id = :survey_id', + ['survey_id' => $surveyId] + ); + } + app_execute($pdo, 'DELETE FROM survey_questions WHERE survey_id = :survey_id', ['survey_id' => $surveyId]); + } + + foreach ($questions as $index => $question) { + $questionId = app_uuid(); + $params = [ + 'id' => $questionId, + 'survey_id' => $surveyId, + 'question' => $question['text'], + 'question_type' => $question['type'], + 'is_required' => $question['required'] ? 1 : 0, + 'sort_order' => $index + 1, + 'created_at' => $now, + 'updated_at' => $now, + ]; + + if (app_table_has_column($pdo, 'survey_questions', 'options_json')) { + $params['options_json'] = json_encode($question['options'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + app_execute( + $pdo, + 'INSERT INTO survey_questions (id, survey_id, question, question_type, is_required, sort_order, options_json, created_at, updated_at) VALUES (:id, :survey_id, :question, :question_type, :is_required, :sort_order, :options_json, :created_at, :updated_at)', + $params + ); + } else { + app_execute( + $pdo, + 'INSERT INTO survey_questions (id, survey_id, question, question_type, is_required, sort_order, created_at, updated_at) VALUES (:id, :survey_id, :question, :question_type, :is_required, :sort_order, :created_at, :updated_at)', + $params + ); + } + + if (scripts_table_exists($pdo, 'survey_question_options')) { + foreach ($question['options'] as $optionIndex => $optionLabel) { + app_execute( + $pdo, + 'INSERT INTO survey_question_options (id, question_id, option_label, sort_order, created_at, updated_at) VALUES (:id, :question_id, :option_label, :sort_order, :created_at, :updated_at)', + [ + 'id' => app_uuid(), + 'question_id' => $questionId, + 'option_label' => $optionLabel, + 'sort_order' => $optionIndex + 1, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + } + } + } + + $pdo->commit(); + app_flash('Die Umfrage wurde gespeichert.', 'success'); + app_redirect('/surveys/?survey=' . rawurlencode($surveyId)); + } + + if ($action === 'set-survey-status') { + if (!app_can_manage_surveys($auth)) { + throw new RuntimeException('Für diese Aktion brauchst du Umfrage-Rechte.'); + } + + $surveyId = trim((string) ($_POST['survey_id'] ?? '')); + $status = trim((string) ($_POST['status'] ?? 'draft')); + if (!in_array($status, ['draft', 'active', 'published', 'closed'], true)) { + $status = 'draft'; + } + + app_execute( + $pdo, + 'UPDATE surveys SET status = :status, updated_at = :updated_at WHERE id = :id AND tenant_id = :tenant_id', + ['status' => $status, 'updated_at' => $now, 'id' => $surveyId, 'tenant_id' => $tenantId] + ); + app_flash('Der Umfrage-Status wurde aktualisiert.', 'success'); + app_redirect('/surveys/?survey=' . rawurlencode($surveyId)); + } + + if ($action === 'submit-survey') { + $surveyId = trim((string) ($_POST['survey_id'] ?? '')); + $survey = app_survey_by_id($pdo, $tenantId, $surveyId); + + if ($survey === null || !in_array((string) ($survey['status'] ?? ''), ['active', 'published'], true)) { + throw new RuntimeException('Diese Umfrage ist aktuell nicht zur Teilnahme freigegeben.'); + } + + $tenantUserId = (string) ($auth['tenant_user_id'] ?? ''); + if ($tenantUserId === '') { + throw new RuntimeException('Die Teilnahme ist ohne aktives Mitgliedskonto nicht möglich.'); + } + + $existingCount = (int) app_query_value( + $pdo, + 'SELECT COUNT(*) FROM survey_answers WHERE survey_id = :survey_id AND tenant_user_id = :tenant_user_id', + ['survey_id' => $surveyId, 'tenant_user_id' => $tenantUserId], + 0 + ); + + if ($existingCount > 0) { + throw new RuntimeException('Du hast an dieser Umfrage bereits teilgenommen.'); + } + + $answers = $_POST['answers'] ?? []; + $pdo->beginTransaction(); + + foreach (($survey['questions'] ?? []) as $question) { + $questionId = (string) ($question['id'] ?? ''); + $value = $answers[$questionId] ?? null; + $questionType = (string) ($question['question_type'] ?? 'text'); + + if (is_array($value)) { + $value = implode("\n", array_values(array_filter(array_map('trim', $value)))); + } + + $value = trim((string) $value); + + if ((int) ($question['is_required'] ?? 0) === 1 && $value === '') { + throw new RuntimeException('Bitte beantworte alle Pflichtfragen.'); + } + + app_execute( + $pdo, + 'INSERT INTO survey_answers (id, survey_id, question_id, tenant_user_id, answer_text, created_at, updated_at) VALUES (:id, :survey_id, :question_id, :tenant_user_id, :answer_text, :created_at, :updated_at)', + [ + 'id' => app_uuid(), + 'survey_id' => $surveyId, + 'question_id' => $questionId, + 'tenant_user_id' => $tenantUserId, + 'answer_text' => $value !== '' ? $value : null, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + } + + $pdo->commit(); + app_flash('Danke, deine Antworten wurden gespeichert.', 'success'); + app_redirect('/surveys/?survey=' . rawurlencode($surveyId)); + } + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + app_flash($exception->getMessage(), 'error'); + app_redirect('/surveys/' . (isset($_POST['survey_id']) && $_POST['survey_id'] !== '' ? '?survey=' . rawurlencode((string) $_POST['survey_id']) : '')); + } +} + function app_supports_license_features(PDO $pdo): bool { return scripts_table_exists($pdo, 'tenant_licenses') @@ -2294,9 +3346,12 @@ function app_tenant_settings_defaults(): array 'back_page_label' => 'Rückseite', 'pdf_row_height_mm' => '4.00', 'support_email' => '', + 'paypal_me_url' => '', 'location_label' => '', 'allow_self_service_booking' => '1', 'payment_hint' => 'Bitte Einzahlungen zeitnah verbuchen.', + 'paypal_me_link' => '', + 'paypal_fixed_amounts' => '2,5,10', 'brand_color' => '#005e3f', 'brand_strong_color' => '#00452f', 'accent_color' => '#c18a00', @@ -2395,9 +3450,12 @@ function app_save_tenant_settings(PDO $pdo, string $tenantId, array $data): void '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'] ?? '')), + 'paypal_me_url' => trim((string) ($data['paypal_me_url'] ?? '')), 'location_label' => trim((string) ($data['location_label'] ?? '')), 'allow_self_service_booking' => !empty($data['allow_self_service_booking']) ? '1' : '0', 'payment_hint' => trim((string) ($data['payment_hint'] ?? '')), + 'paypal_me_link' => trim((string) ($data['paypal_me_link'] ?? '')), + 'paypal_fixed_amounts' => trim((string) ($data['paypal_fixed_amounts'] ?? '2,5,10')), 'brand_color' => strtoupper(trim((string) ($data['brand_color'] ?? '#005e3f'))), 'brand_strong_color' => strtoupper(trim((string) ($data['brand_strong_color'] ?? '#00452f'))), 'accent_color' => strtoupper(trim((string) ($data['accent_color'] ?? '#c18a00'))), @@ -2417,6 +3475,14 @@ function app_save_tenant_settings(PDO $pdo, string $tenantId, array $data): void throw new RuntimeException('Bitte gib eine gültige Support-E-Mail-Adresse an.'); } + if ($values['paypal_me_url'] !== '' && !filter_var($values['paypal_me_url'], FILTER_VALIDATE_URL)) { + throw new RuntimeException('Bitte gib einen gültigen PayPal-Link an.'); + } + + if ($values['paypal_me_link'] !== '' && !filter_var($values['paypal_me_link'], FILTER_VALIDATE_URL)) { + throw new RuntimeException('Bitte gib für PayPal einen vollständigen Link an.'); + } + foreach (['brand_color', 'brand_strong_color', 'accent_color', 'background_color', 'card_color'] as $colorKey) { if (!app_is_valid_hex_color($values[$colorKey])) { throw new RuntimeException('Bitte gib für die Farbfelder gültige HEX-Werte an.'); @@ -2689,6 +3755,193 @@ function app_member_exists(PDO $pdo, string $tenantId, string $memberId): ?array ); } +function app_create_coffee_booking(PDO $pdo, string $tenantId, string $memberId, int $strokes, float $unitPrice, string $bookedAt, string $source = 'manual'): void +{ + if ($strokes < 1 || $unitPrice <= 0) { + throw new RuntimeException('Bitte gib mindestens einen Strich und einen gültigen Preis an.'); + } + + $entryId = app_uuid(); + $ledgerId = app_uuid(); + $now = date('Y-m-d H:i:s'); + $totalCost = round($strokes * $unitPrice, 2); + + app_execute( + $pdo, + 'INSERT INTO coffee_entries (id, tenant_id, member_id, strokes, unit_price, total_cost, booking_source, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :strokes, :unit_price, :total_cost, :booking_source, :booked_at, :created_at, :updated_at)', + [ + 'id' => $entryId, + 'tenant_id' => $tenantId, + 'member_id' => $memberId, + 'strokes' => $strokes, + 'unit_price' => number_format($unitPrice, 2, '.', ''), + 'total_cost' => number_format($totalCost, 2, '.', ''), + 'booking_source' => $source, + 'booked_at' => $bookedAt, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + + app_execute( + $pdo, + '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, + 'member_id' => $memberId, + 'entry_type' => 'coffee_charge', + 'amount' => number_format($totalCost * -1, 2, '.', ''), + 'reference_type' => 'coffee_entry', + 'reference_id' => $entryId, + 'booked_at' => $bookedAt, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); +} + +function app_create_payment_booking(PDO $pdo, string $tenantId, string $memberId, float $amount, string $bookedAt, string $method = 'manual'): void +{ + if ($amount <= 0) { + throw new RuntimeException('Bitte gib einen gültigen Einzahlungsbetrag an.'); + } + + $entryId = app_uuid(); + $ledgerId = app_uuid(); + $now = date('Y-m-d H:i:s'); + + app_execute( + $pdo, + 'INSERT INTO payment_entries (id, tenant_id, member_id, amount, payment_method, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :amount, :payment_method, :booked_at, :created_at, :updated_at)', + [ + 'id' => $entryId, + 'tenant_id' => $tenantId, + 'member_id' => $memberId, + 'amount' => number_format($amount, 2, '.', ''), + 'payment_method' => $method !== '' ? $method : 'manual', + 'booked_at' => $bookedAt, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + + app_execute( + $pdo, + '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, + 'member_id' => $memberId, + 'entry_type' => 'payment_credit', + 'amount' => number_format($amount, 2, '.', ''), + 'reference_type' => 'payment_entry', + 'reference_id' => $entryId, + 'booked_at' => $bookedAt, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); +} + +function app_find_member_for_payment_import(PDO $pdo, string $tenantId, string $name, string $paymentReference = ''): ?array +{ + $paymentReference = trim($paymentReference); + + if ($paymentReference !== '' && app_members_support_payment_reference($pdo)) { + $row = app_query_one( + $pdo, + 'SELECT id, display_name FROM members WHERE tenant_id = :tenant_id AND status = :status AND LOWER(COALESCE(payment_reference, "")) = LOWER(:payment_reference) LIMIT 1', + ['tenant_id' => $tenantId, 'status' => 'active', 'payment_reference' => $paymentReference] + ); + + if ($row !== null) { + return $row; + } + } + + return app_query_one( + $pdo, + 'SELECT id, display_name FROM members WHERE tenant_id = :tenant_id AND status = :status AND LOWER(display_name) = LOWER(:display_name) LIMIT 1', + ['tenant_id' => $tenantId, 'status' => 'active', 'display_name' => trim($name)] + ); +} + +function app_import_payment_csv(PDO $pdo, string $tenantId, array $file): array +{ + $tmpName = (string) ($file['tmp_name'] ?? ''); + + if ($tmpName === '' || !is_uploaded_file($tmpName)) { + throw new RuntimeException('Bitte wähle eine CSV-Datei aus.'); + } + + $handle = fopen($tmpName, 'rb'); + + if ($handle === false) { + throw new RuntimeException('Die CSV-Datei konnte nicht gelesen werden.'); + } + + $header = fgetcsv($handle, 0, ';'); + if (!is_array($header) || $header === []) { + fclose($handle); + throw new RuntimeException('Die CSV-Datei enthält keinen lesbaren Header.'); + } + + $headerMap = []; + foreach ($header as $index => $column) { + $headerMap[strtolower(trim((string) $column))] = $index; + } + + $created = 0; + $duplicates = 0; + $missing = 0; + + while (($row = fgetcsv($handle, 0, ';')) !== false) { + $name = trim((string) ($row[$headerMap['name'] ?? $headerMap['mitarbeiter'] ?? -1] ?? '')); + $paymentReference = trim((string) ($row[$headerMap['paypalname'] ?? $headerMap['payment_reference'] ?? -1] ?? '')); + $amountRaw = str_replace(',', '.', trim((string) ($row[$headerMap['betrag'] ?? $headerMap['amount'] ?? -1] ?? '0'))); + $bookedAtRaw = trim((string) ($row[$headerMap['datum'] ?? $headerMap['booked_at'] ?? -1] ?? '')); + $method = trim((string) ($row[$headerMap['payment_method'] ?? $headerMap['methode'] ?? -1] ?? 'import_csv')) ?: 'import_csv'; + $amount = (float) $amountRaw; + + if ($name === '' || $amount <= 0) { + continue; + } + + $member = app_find_member_for_payment_import($pdo, $tenantId, $name, $paymentReference); + if ($member === null) { + $missing++; + continue; + } + + $bookedAt = $bookedAtRaw !== '' ? app_normalize_datetime_input($bookedAtRaw) : date('Y-m-d H:i:s'); + $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', + [ + 'tenant_id' => $tenantId, + 'member_id' => (string) $member['id'], + 'amount' => number_format($amount, 2, '.', ''), + 'booked_at' => $bookedAt, + 'payment_method' => $method, + ], + 0 + ); + + if ($duplicateCount > 0) { + $duplicates++; + continue; + } + + app_create_payment_booking($pdo, $tenantId, (string) $member['id'], $amount, $bookedAt, $method); + $created++; + } + + fclose($handle); + + return ['created' => $created, 'duplicates' => $duplicates, 'missing' => $missing]; +} + function app_handle_tenant_action(PDO $pdo, array $auth): void { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { @@ -2698,133 +3951,147 @@ function app_handle_tenant_action(PDO $pdo, array $auth): void $action = (string) ($_POST['action'] ?? ''); $tenantId = (string) ($auth['tenant_id'] ?? ''); - if (!in_array($action, ['record-coffee', 'record-payment'], true) || $tenantId === '') { + if (!in_array($action, ['record-coffee', 'record-payment', 'delete-coffee', 'delete-payment', 'bulk-record-coffee', 'bulk-record-payment', 'import-payments-csv'], true) || $tenantId === '') { return; } - if (!app_can_manage_finance($auth)) { + $settings = app_tenant_settings($pdo, $tenantId); + $canManageFinance = app_can_manage_finance($auth); + if (in_array($action, ['delete-coffee', 'delete-payment'], true)) { + if (!$canManageFinance) { + app_flash('Für diese Aktion brauchst du Finanz- oder Tenant-Rechte.', 'warning'); + app_redirect('/dashboard/'); + } + + try { + if ($action === 'delete-coffee') { + app_delete_coffee_entry($pdo, $tenantId, (string) ($_POST['reference_id'] ?? '')); + app_flash('Der Stricheintrag wurde entfernt.', 'success'); + app_redirect('/ledger/'); + } + + app_delete_payment_entry($pdo, $tenantId, (string) ($_POST['payment_id'] ?? '')); + app_flash('Die Einzahlung wurde entfernt.', 'success'); + app_redirect('/payments/'); + } catch (Throwable $exception) { + app_flash($exception->getMessage(), 'error'); + app_redirect(app_request_path()); + } + } + + $isSelfServiceCoffee = $action === 'record-coffee' + && !$canManageFinance + && ($settings['allow_self_service_booking'] ?? '1') === '1'; + + if (!$canManageFinance && !$isSelfServiceCoffee) { app_flash('Für diese Aktion brauchst du Finanz- oder Tenant-Rechte.', 'warning'); app_redirect('/dashboard/'); } - $memberId = trim((string) ($_POST['member_id'] ?? '')); - $member = app_member_exists($pdo, $tenantId, $memberId); - - if ($member === null) { - app_flash('Das ausgewählte Mitglied konnte in diesem Tenant nicht gefunden werden.', 'error'); - app_redirect(app_request_path()); - } - - $bookedAt = trim((string) ($_POST['booked_at'] ?? '')); - $bookedAt = $bookedAt !== '' ? str_replace('T', ' ', $bookedAt) . ':00' : date('Y-m-d H:i:s'); - $now = date('Y-m-d H:i:s'); - try { - $pdo->beginTransaction(); - if ($action === 'record-coffee') { + $memberId = trim((string) ($_POST['member_id'] ?? '')); $strokes = max(0, (int) ($_POST['strokes'] ?? 0)); - $unitPrice = (float) ($_POST['unit_price'] ?? 0); + $member = app_member_exists($pdo, $tenantId, $memberId); - if ($strokes < 1 || $unitPrice <= 0) { - throw new RuntimeException('Bitte gib mindestens einen Strich und einen gültigen Preis an.'); + if ($member === null) { + throw new RuntimeException('Das ausgewählte Mitglied konnte in diesem Tenant nicht gefunden werden.'); } - $entryId = app_uuid(); - $ledgerId = app_uuid(); - $totalCost = round($strokes * $unitPrice, 2); - $source = trim((string) ($_POST['booking_source'] ?? 'manual')); - $source = $source !== '' ? $source : 'manual'; + if ($isSelfServiceCoffee && (string) ($auth['member_id'] ?? '') !== $memberId) { + throw new RuntimeException('Im Self-Service kannst du nur deine eigenen Striche buchen.'); + } - app_execute( + app_create_coffee_booking( $pdo, - 'INSERT INTO coffee_entries (id, tenant_id, member_id, strokes, unit_price, total_cost, booking_source, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :strokes, :unit_price, :total_cost, :booking_source, :booked_at, :created_at, :updated_at)', - [ - 'id' => $entryId, - 'tenant_id' => $tenantId, - 'member_id' => $memberId, - 'strokes' => $strokes, - 'unit_price' => number_format($unitPrice, 2, '.', ''), - 'total_cost' => number_format($totalCost, 2, '.', ''), - 'booking_source' => $source, - 'booked_at' => $bookedAt, - 'created_at' => $now, - 'updated_at' => $now, - ] + $tenantId, + $memberId, + $strokes, + (float) ($_POST['unit_price'] ?? 0), + app_normalize_datetime_input((string) ($_POST['booked_at'] ?? '')), + trim((string) ($_POST['booking_source'] ?? 'manual')) ?: 'manual' ); - - app_execute( - $pdo, - '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, - 'member_id' => $memberId, - 'entry_type' => 'coffee_charge', - 'amount' => number_format($totalCost * -1, 2, '.', ''), - 'reference_type' => 'coffee_entry', - 'reference_id' => $entryId, - 'booked_at' => $bookedAt, - 'created_at' => $now, - 'updated_at' => $now, - ] - ); - - $pdo->commit(); app_flash($strokes . ' Strich(e) für ' . $member['display_name'] . ' wurden eingetragen.', 'success'); - app_redirect('/ledger/'); + app_redirect($isSelfServiceCoffee ? '/dashboard/' : '/ledger/'); } if ($action === 'record-payment') { - $amount = (float) ($_POST['amount'] ?? 0); - $method = trim((string) ($_POST['payment_method'] ?? 'manual')); - $method = $method !== '' ? $method : 'manual'; + $memberId = trim((string) ($_POST['member_id'] ?? '')); + $member = app_member_exists($pdo, $tenantId, $memberId); - if ($amount <= 0) { - throw new RuntimeException('Bitte gib einen gültigen Einzahlungsbetrag an.'); + if ($member === null) { + throw new RuntimeException('Das ausgewählte Mitglied konnte in diesem Tenant nicht gefunden werden.'); } - $entryId = app_uuid(); - $ledgerId = app_uuid(); - - app_execute( + app_create_payment_booking( $pdo, - 'INSERT INTO payment_entries (id, tenant_id, member_id, amount, payment_method, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :amount, :payment_method, :booked_at, :created_at, :updated_at)', - [ - 'id' => $entryId, - 'tenant_id' => $tenantId, - 'member_id' => $memberId, - 'amount' => number_format($amount, 2, '.', ''), - 'payment_method' => $method, - 'booked_at' => $bookedAt, - 'created_at' => $now, - 'updated_at' => $now, - ] + $tenantId, + $memberId, + (float) ($_POST['amount'] ?? 0), + app_normalize_datetime_input((string) ($_POST['booked_at'] ?? '')), + trim((string) ($_POST['payment_method'] ?? 'manual')) ?: 'manual' ); - - app_execute( - $pdo, - '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, - 'member_id' => $memberId, - 'entry_type' => 'payment_credit', - 'amount' => number_format($amount, 2, '.', ''), - 'reference_type' => 'payment_entry', - 'reference_id' => $entryId, - 'booked_at' => $bookedAt, - 'created_at' => $now, - 'updated_at' => $now, - ] - ); - - $pdo->commit(); app_flash('Die Einzahlung für ' . $member['display_name'] . ' wurde gebucht.', 'success'); app_redirect('/payments/'); } - $pdo->commit(); + if ($action === 'bulk-record-coffee') { + $unitPrice = (float) ($_POST['unit_price'] ?? 0); + $bookedAt = app_normalize_datetime_input((string) ($_POST['booked_at'] ?? '')); + $entries = $_POST['strokes'] ?? []; + $created = 0; + + $pdo->beginTransaction(); + foreach ($entries as $memberId => $strokes) { + $strokeCount = max(0, (int) $strokes); + if ($strokeCount < 1) { + continue; + } + + if (app_member_exists($pdo, $tenantId, (string) $memberId) === null) { + continue; + } + + app_create_coffee_booking($pdo, $tenantId, (string) $memberId, $strokeCount, $unitPrice, $bookedAt, 'bulk'); + $created++; + } + $pdo->commit(); + app_flash($created . ' Kaffee-Buchung(en) wurden erfasst.', 'success'); + app_redirect('/ledger/?scope=' . rawurlencode((string) ($_POST['scope'] ?? 'all'))); + } + + if ($action === 'bulk-record-payment') { + $bookedAt = app_normalize_datetime_input((string) ($_POST['booked_at'] ?? '')); + $method = trim((string) ($_POST['payment_method'] ?? 'manual')) ?: 'manual'; + $entries = $_POST['amounts'] ?? []; + $created = 0; + + $pdo->beginTransaction(); + foreach ($entries as $memberId => $amount) { + $paymentAmount = (float) str_replace(',', '.', (string) $amount); + if ($paymentAmount <= 0) { + continue; + } + + if (app_member_exists($pdo, $tenantId, (string) $memberId) === null) { + continue; + } + + app_create_payment_booking($pdo, $tenantId, (string) $memberId, $paymentAmount, $bookedAt, $method); + $created++; + } + $pdo->commit(); + app_flash($created . ' Einzahlung(en) wurden erfasst.', 'success'); + app_redirect('/payments/?scope=' . rawurlencode((string) ($_POST['scope'] ?? 'all'))); + } + + if ($action === 'import-payments-csv') { + $pdo->beginTransaction(); + $result = app_import_payment_csv($pdo, $tenantId, $_FILES['csv_file'] ?? []); + $pdo->commit(); + app_flash($result['created'] . ' Zahlungen importiert, ' . $result['duplicates'] . ' Dubletten übersprungen, ' . $result['missing'] . ' ohne Zuordnung.', 'success'); + app_redirect('/payments/'); + } } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); @@ -2852,3 +4119,5 @@ function app_h(string $value): string { return htmlspecialchars($value, ENT_QUOTES); } + +require_once __DIR__ . '/app-extensions.php'; diff --git a/saas-app/public/imports/index.php b/saas-app/public/imports/index.php new file mode 100644 index 0000000..3894001 --- /dev/null +++ b/saas-app/public/imports/index.php @@ -0,0 +1,7 @@ + 'ledger', '/payments' => 'payments', '/content' => 'content', + '/imports' => 'imports', + '/reports' => 'reports', '/support' => 'support', '/surveys' => 'surveys', '/settings' => 'settings', @@ -63,7 +65,7 @@ if ($requestedPage === null) { $page = (string) $requestedPage; $requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET'; -$tenantPages = ['dashboard', 'members', 'ledger', 'payments', 'content', 'support', 'surveys', 'settings', 'exports']; +$tenantPages = ['dashboard', 'members', 'ledger', 'payments', 'content', 'imports', 'reports', 'support', 'surveys', 'settings', 'exports']; if ($page === 'logout' && $requestMethod === 'POST') { app_logout(); @@ -82,8 +84,20 @@ $ledger = []; $payments = []; $content = ['announcements' => [], 'faq' => []]; $memberSummary = null; +$memberReport = null; +$ledgerScope = 'all'; +$paymentScope = 'all'; +$ledgerMembers = []; +$paymentMembers = []; +$reportRows = []; +$importResult = ['rows' => [], 'summary' => ['imported' => 0, 'duplicates' => 0, 'unmatched' => 0]]; +$surveyBoard = ['all' => [], 'published' => []]; +$activeSurvey = null; +$surveyResults = []; $loginFlow = ['state' => app_login_state(), 'message' => null, 'error' => null]; $editingMember = null; +$editingAnnouncement = null; +$editingFaq = null; $memberForm = app_member_form_defaults(); $tenantLicense = ['plan_key' => 'free', 'plan_name' => 'Free', 'member_limit' => 10, 'features' => app_feature_defaults()]; $tenantSettings = app_tenant_settings_defaults(); @@ -111,10 +125,6 @@ if ($page === 'support') { app_redirect('/support/'); } -if ($page === 'surveys') { - app_redirect('/surveys/'); -} - if ($page === 'login' && $pdo instanceof PDO) { try { $loginFlow = app_handle_login($pdo); @@ -137,12 +147,26 @@ if ($auth !== null && $pdo instanceof PDO) { if (in_array($page, ['dashboard', 'ledger', 'payments'], true)) { app_handle_tenant_action($pdo, $auth); + app_handle_bulk_finance_action($pdo, $auth); + app_handle_profile_action($pdo, $auth); } if ($page === 'members') { app_handle_member_action($pdo, $auth); } + if ($page === 'content') { + app_handle_content_action($pdo, $auth); + } + + if ($page === 'imports') { + $importResult = app_handle_csv_import($pdo, $auth); + } + + if ($page === 'surveys') { + app_handle_survey_action($pdo, $auth); + } + if ($page === 'settings') { app_handle_settings_action($pdo, $auth); } @@ -177,8 +201,40 @@ if ($auth !== null && $pdo instanceof PDO) { $payments = app_payments_for_tenant($pdo, (string) $auth['tenant_id']); } + if ($page === 'ledger') { + $ledgerScope = in_array((string) ($_GET['scope'] ?? 'all'), ['all', 'front', 'back'], true) + ? (string) ($_GET['scope'] ?? 'all') + : 'all'; + $ledgerMembers = app_members_for_scope($pdo, (string) $auth['tenant_id'], $ledgerScope); + } + + if ($page === 'payments') { + $paymentScope = in_array((string) ($_GET['scope'] ?? 'all'), ['all', 'front', 'back'], true) + ? (string) ($_GET['scope'] ?? 'all') + : 'all'; + $paymentMembers = app_members_for_scope($pdo, (string) $auth['tenant_id'], $paymentScope); + } + if ($page === 'content') { $content = app_content_for_tenant($pdo, (string) $auth['tenant_id']); + + if (isset($_GET['edit_announcement']) && $_GET['edit_announcement'] !== '') { + foreach (($content['announcements'] ?? []) as $announcement) { + if ((string) ($announcement['id'] ?? '') === (string) $_GET['edit_announcement']) { + $editingAnnouncement = $announcement; + break; + } + } + } + + if (isset($_GET['edit_faq']) && $_GET['edit_faq'] !== '') { + foreach (($content['faq'] ?? []) as $faq) { + if ((string) ($faq['id'] ?? '') === (string) $_GET['edit_faq']) { + $editingFaq = $faq; + break; + } + } + } } if ($page === 'exports') { @@ -199,6 +255,27 @@ if ($auth !== null && $pdo instanceof PDO) { $editingMember = app_tenant_user_by_id($pdo, (string) $_GET['edit'], (string) $auth['tenant_id']); $memberForm = app_member_form_defaults($editingMember); } + + if ($page === 'members' && isset($_GET['member']) && $_GET['member'] !== '') { + $memberReport = app_member_report($pdo, (string) $auth['tenant_id'], (string) $_GET['member']); + } + + if ($page === 'reports') { + $reportRows = app_reporting_rows($pdo, (string) $auth['tenant_id']); + if ($memberReport === null && isset($_GET['member']) && $_GET['member'] !== '') { + $memberReport = app_member_report($pdo, (string) $auth['tenant_id'], (string) $_GET['member']); + } + } + + if ($page === 'surveys') { + $surveyBoard = app_surveys_for_tenant($pdo, (string) $auth['tenant_id']); + if (isset($_GET['survey']) && $_GET['survey'] !== '') { + $activeSurvey = app_survey_by_id($pdo, (string) $auth['tenant_id'], (string) $_GET['survey']); + if (is_array($activeSurvey) && app_can_manage_surveys($auth)) { + $surveyResults = app_survey_results($pdo, (string) $activeSurvey['id']); + } + } + } } catch (Throwable $exception) { $dbError = $exception->getMessage(); } @@ -211,6 +288,8 @@ $restrictedPages = [ 'members' => static fn(array $user): bool => app_can_manage_tenant($user), 'ledger' => static fn(array $user): bool => app_can_manage_finance($user), '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), 'settings' => static fn(array $user): bool => app_can_manage_tenant($user), 'exports' => static fn(array $user): bool => app_can_manage_tenant($user), ]; @@ -266,6 +345,7 @@ $landingProof = [ ['title' => 'Im Browser und mobil', 'copy' => 'Die Oberfläche funktioniert ohne Installation auf Desktop und Handy.'], ['title' => 'Für den Alltag gebaut', 'copy' => 'Kaffeeliste, Support, Buchungen und Verwaltung greifen sinnvoll ineinander.'], ]; +$marketing = app_marketing_messages(); ?> @@ -816,18 +896,52 @@ $landingProof = [
-
Dein Bereich
-

Dein Bereich

-
    -
  • Hier siehst du deinen Kontostand, letzte Buchungen und aktuelle Hinweise.
  • -
  • Weitere Bereiche findest du direkt im Menü.
  • -
  • Wenn dir etwas fehlt, wende dich an den Betreiber deines Bereichs.
  • -
+
Self-Service
+

Mein Bereich

+ +
+ + + + + + +
+
+ +
    +
  • Hier siehst du deinen Kontostand, letzte Buchungen und aktuelle Hinweise.
  • +
  • Self-Service-Striche sind in diesem Mandanten aktuell deaktiviert.
  • +
  • Wenn dir etwas fehlt, wende dich an den Betreiber deines Bereichs.
  • +
+
-
Nächster Schritt
-

Direkt weiter

-

Nutze das Menü für den nächsten Bereich.

+
Profil
+

Name und Passwort pflegen

+
+ + + + +
+
+ + +
+

PayPal-Einzahlungen

+

Feste Beträge und der offene Saldo lassen sich direkt öffnen.

+
+ + + EUR ausgleichen + + + EUR + +
+
+
@@ -836,8 +950,8 @@ $landingProof = [

Letzte Buchungen

- - + +
ZeitMitgliedTypReferenzBetrag
ZeitMitgliedTypReferenzBetrag
@@ -845,7 +959,7 @@ $landingProof = [
Mitgliederverwaltung

Mitglieder

-

Hier legst du neue Personen an, gibst Zugänge frei und weist bei Bedarf die Rolle als Mandanten-Admin zu.

+

Hier legst du neue Personen an, gibst Zugänge frei und weist Rollen für Verwaltung, Finanzen, Support und Umfragen zu.

Zur zentralen Verwaltung @@ -861,12 +975,13 @@ $landingProof = [ + - + + + +
@@ -876,33 +991,55 @@ $landingProof = [
-

Was der Global-Admin hier sehen kann

-
    -
  • Alle Personen des Mandanten inklusive Rollen und Aktivstatus.
  • -
  • Ob ein Zugang nur Mitglied ist oder als Mandanten-Admin arbeiten darf.
  • -
  • Den Mandanten aus Sicht der operativen Verwaltung, ohne den Global-Admin als Mitglied anlegen zu müssen.
  • -
+ +

Mitgliedsdetail

+
+

+

Saldo

+

Dieses Jahr

Striche, Einzahlungen

+ +
+ +

Mitglied auswählen

+
    +
  • Öffne eine Person aus der Tabelle, um Jahreswerte und letzte Buchungen zu sehen.
  • +
  • Rollen können hier direkt differenziert nach Verwaltung, Finanzen, Support und Umfragen vergeben werden.
  • +
  • So bildet die neue Webseite die Rollenmatrix der Roadmap sichtbar im Tenant ab.
  • +
+
- + + - - - + + +
NameE-MailStatusBenutzerRollenAktion
NameE-MailReferenzStatusSaldoRollenAktion
Bearbeiten
+ +
+

Letzte Buchungen der ausgewählten Person

+
+ + + +
ZeitTypReferenzBetrag
+
+
+
Buchungen

Striche und Buchungen

Kaffeeeinträge und alle zugehörigen Buchungen bleiben tenantweise nachvollziehbar.

@@ -919,7 +1056,7 @@ $landingProof = [

Was dieser Bereich abdeckt

  • Einzel- und Sammelbuchungen laufen in einem gemeinsamen Ablauf zusammen.
  • Jeder Verbrauch erzeugt automatisch den passenden Ledger-Eintrag.
  • Die letzten Buchungen bleiben je Tenant nachvollziehbar.
-
ZeitMitgliedTypReferenzBetrag
+
ZeitMitgliedTypReferenzBetragAktion
-
Einzahlungen

Zahlungen

Einzahlungen werden direkt in Zahlungstabelle und Ledger geschrieben.

@@ -934,14 +1071,40 @@ $landingProof = [
-

Wofür dieser Bereich da ist

  • Manuelle Einzahlungen sind direkt erfasst.
  • PayPal oder Bank können getrennt ausgewiesen werden.
  • Jede Zahlung erscheint sofort im Ledger.
+

Wofür dieser Bereich da ist

  • Manuelle Einzahlungen sind direkt erfasst.
  • PayPal oder Bank können getrennt ausgewiesen werden.
  • Jede Zahlung erscheint sofort im Ledger.

-
ZeitMitgliedMethodeBetrag
+
ZeitMitgliedMethodeBetragAktion
Hinweise und FAQ

Hinweise und FAQ

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

-

Hinweise

Aktuell sind keine Hinweise vorhanden.

Sichtbar bis

-

FAQ

Aktuell keine FAQ vorhanden.

+
+

Hinweise

+ +
+ + + + + +
Neu anlegen
+
+ +

Aktuell sind keine Hinweise vorhanden.

Sichtbar bis

+
+
+

FAQ

+ +
+ + + + + +
Neu anlegen
+
+ +

Aktuell keine FAQ vorhanden.

+
Mandanten-Einstellungen

Einstellungen

Diese Einstellungen gelten nur für den aktuell geöffneten Mandanten und bleiben unabhängig von anderen Bereichen.

@@ -957,6 +1120,7 @@ $landingProof = [ + @@ -1044,6 +1208,17 @@ $landingProof = [ + +
+

Mitgliederauswertung exportieren

+

Die Detailauswertung eines einzelnen Mitglieds lässt sich direkt als CSV herunterladen.

+
+ + +
+
+
+
diff --git a/saas-app/public/install-support.php b/saas-app/public/install-support.php index f59d122..fadd499 100644 --- a/saas-app/public/install-support.php +++ b/saas-app/public/install-support.php @@ -1215,6 +1215,9 @@ function scripts_ensure_core_roles(PDO $pdo): array { return [ 'tenant_admin' => scripts_ensure_role($pdo, 'tenant_admin', 'Tenant Admin', 'tenant'), + 'finance_admin' => scripts_ensure_role($pdo, 'finance_admin', 'Finance Admin', 'tenant'), + 'support_contact' => scripts_ensure_role($pdo, 'support_contact', 'Support Contact', 'tenant'), + 'survey_manager' => scripts_ensure_role($pdo, 'survey_manager', 'Survey Manager', 'tenant'), 'platform_admin' => scripts_ensure_role($pdo, 'platform_admin', 'Platform Admin', 'platform'), ]; } diff --git a/saas-app/public/reports/index.php b/saas-app/public/reports/index.php new file mode 100644 index 0000000..e3e9da7 --- /dev/null +++ b/saas-app/public/reports/index.php @@ -0,0 +1,7 @@ + trim((string) $item), $decoded))); + } + } + + if ($type === 'scale') { + return ['1', '2', '3', '4', '5']; + } + + return []; +} + +function survey_option_text_to_json(string $raw, string $type): ?string +{ + $lines = preg_split('/\R+/', trim($raw)) ?: []; + $options = array_values(array_filter(array_map('trim', $lines))); + + if ($options === [] && $type === 'scale') { + $options = ['1', '2', '3', '4', '5']; + } + + return $options === [] ? null : json_encode($options, JSON_UNESCAPED_UNICODE); +} + +function survey_fetch_list(PDO $pdo, string $tenantId): array +{ + return app_query_all( + $pdo, + <<<'SQL' +SELECT + s.id, + s.title, + s.status, + s.starts_at, + s.ends_at, + s.created_at, + COUNT(DISTINCT sq.id) AS question_count, + COUNT(DISTINCT sa.id) AS answer_count +FROM surveys s +LEFT JOIN survey_questions sq ON sq.survey_id = s.id +LEFT JOIN survey_answers sa ON sa.survey_id = s.id +WHERE s.tenant_id = :tenant_id +GROUP BY s.id, s.title, s.status, s.starts_at, s.ends_at, s.created_at +ORDER BY + CASE s.status + WHEN 'active' THEN 0 + WHEN 'draft' THEN 1 + ELSE 2 + END, + s.created_at DESC +SQL, + ['tenant_id' => $tenantId] + ); +} + +function survey_fetch_detail(PDO $pdo, string $tenantId, string $surveyId): ?array +{ + if ($surveyId === '') { + return null; + } + + $survey = app_query_one( + $pdo, + 'SELECT id, tenant_id, title, status, starts_at, ends_at, created_at, updated_at FROM surveys WHERE tenant_id = :tenant_id AND id = :id LIMIT 1', + ['tenant_id' => $tenantId, 'id' => $surveyId] + ); + + if ($survey === null) { + 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] + ); + + return $survey; +} + +function survey_fetch_answer_map(PDO $pdo, string $surveyId, string $tenantUserId): array +{ + if ($surveyId === '' || $tenantUserId === '') { + return []; + } + + $answers = []; + foreach (app_query_all( + $pdo, + 'SELECT question_id, answer_text FROM survey_answers WHERE survey_id = :survey_id AND tenant_user_id = :tenant_user_id', + ['survey_id' => $surveyId, 'tenant_user_id' => $tenantUserId] + ) as $row) { + $answers[(string) ($row['question_id'] ?? '')] = (string) ($row['answer_text'] ?? ''); + } + + return $answers; +} + +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; +} + +function survey_save_survey(PDO $pdo, string $tenantId, array $input): void +{ + $surveyId = trim((string) ($input['survey_id'] ?? '')); + $title = trim((string) ($input['title'] ?? '')); + $startsAt = trim((string) ($input['starts_at'] ?? '')); + $endsAt = trim((string) ($input['ends_at'] ?? '')); + + if ($title === '') { + throw new RuntimeException('Bitte gib einen Titel für die Umfrage an.'); + } + + $startsAtValue = $startsAt !== '' ? str_replace('T', ' ', $startsAt) . ':00' : null; + $endsAtValue = $endsAt !== '' ? str_replace('T', ' ', $endsAt) . ':00' : null; + $now = date('Y-m-d H:i:s'); + + if ($surveyId !== '') { + app_execute( + $pdo, + 'UPDATE surveys SET title = :title, starts_at = :starts_at, ends_at = :ends_at, updated_at = :updated_at WHERE tenant_id = :tenant_id AND id = :id', + [ + 'title' => $title, + 'starts_at' => $startsAtValue, + 'ends_at' => $endsAtValue, + 'updated_at' => $now, + 'tenant_id' => $tenantId, + 'id' => $surveyId, + ] + ); + + return; + } + + app_execute( + $pdo, + 'INSERT INTO surveys (id, tenant_id, title, status, starts_at, ends_at, created_at, updated_at) VALUES (:id, :tenant_id, :title, :status, :starts_at, :ends_at, :created_at, :updated_at)', + [ + 'id' => app_uuid(), + 'tenant_id' => $tenantId, + 'title' => $title, + 'status' => 'draft', + 'starts_at' => $startsAtValue, + 'ends_at' => $endsAtValue, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); +} + +function survey_save_question(PDO $pdo, string $surveyId, array $input): void +{ + $questionId = trim((string) ($input['question_id'] ?? '')); + $question = trim((string) ($input['question'] ?? '')); + $questionType = trim((string) ($input['question_type'] ?? 'text')); + $sortOrder = max(0, (int) ($input['sort_order'] ?? 10)); + $isRequired = !empty($input['is_required']) ? 1 : 0; + $optionsJson = survey_option_text_to_json((string) ($input['options_text'] ?? ''), $questionType); + + if ($question === '') { + throw new RuntimeException('Bitte gib einen Fragetext an.'); + } + + if (!in_array($questionType, ['text', 'scale', 'single_select', 'multi_select'], true)) { + $questionType = 'text'; + } + + $hasOptions = app_table_has_column($pdo, 'survey_questions', 'options_json'); + $now = date('Y-m-d H:i:s'); + + if ($questionId !== '') { + $sql = $hasOptions + ? 'UPDATE survey_questions SET question = :question, question_type = :question_type, is_required = :is_required, sort_order = :sort_order, options_json = :options_json, updated_at = :updated_at WHERE survey_id = :survey_id AND id = :id' + : 'UPDATE survey_questions SET question = :question, question_type = :question_type, is_required = :is_required, sort_order = :sort_order, updated_at = :updated_at WHERE survey_id = :survey_id AND id = :id'; + $params = [ + 'question' => $question, + 'question_type' => $questionType, + 'is_required' => $isRequired, + 'sort_order' => $sortOrder, + 'updated_at' => $now, + 'survey_id' => $surveyId, + 'id' => $questionId, + ]; + if ($hasOptions) { + $params['options_json'] = $optionsJson; + } + app_execute($pdo, $sql, $params); + + return; + } + + $sql = $hasOptions + ? 'INSERT INTO survey_questions (id, survey_id, question, question_type, options_json, is_required, sort_order, created_at, updated_at) VALUES (:id, :survey_id, :question, :question_type, :options_json, :is_required, :sort_order, :created_at, :updated_at)' + : 'INSERT INTO survey_questions (id, survey_id, question, question_type, is_required, sort_order, created_at, updated_at) VALUES (:id, :survey_id, :question, :question_type, :is_required, :sort_order, :created_at, :updated_at)'; + $params = [ + 'id' => app_uuid(), + 'survey_id' => $surveyId, + 'question' => $question, + 'question_type' => $questionType, + 'is_required' => $isRequired, + 'sort_order' => $sortOrder, + 'created_at' => $now, + 'updated_at' => $now, + ]; + if ($hasOptions) { + $params['options_json'] = $optionsJson; + } + app_execute($pdo, $sql, $params); +} + +function survey_submit_answers(PDO $pdo, array $auth, array $survey): void +{ + $tenantUserId = (string) ($auth['tenant_user_id'] ?? ''); + $surveyId = (string) ($survey['id'] ?? ''); + $questions = is_array($survey['questions'] ?? null) ? $survey['questions'] : []; + + if ($tenantUserId === '' || $surveyId === '') { + throw new RuntimeException('Die Teilnahme konnte nicht zugeordnet werden.'); + } + + if (!in_array((string) ($survey['status'] ?? 'draft'), ['active'], true)) { + throw new RuntimeException('Diese Umfrage ist aktuell nicht für Teilnahmen freigegeben.'); + } + + $answersInput = $_POST['answers'] ?? []; + $normalized = []; + + foreach ($questions as $question) { + $questionId = (string) ($question['id'] ?? ''); + $rawAnswer = $answersInput[$questionId] ?? ''; + $answerText = is_array($rawAnswer) + ? implode("\n", array_values(array_filter(array_map('trim', $rawAnswer)))) + : trim((string) $rawAnswer); + + if ((int) ($question['is_required'] ?? 0) === 1 && $answerText === '') { + throw new RuntimeException('Bitte beantworte alle Pflichtfragen.'); + } + + $normalized[$questionId] = $answerText; + } + + $pdo->beginTransaction(); + + try { + app_execute( + $pdo, + 'DELETE FROM survey_answers WHERE survey_id = :survey_id AND tenant_user_id = :tenant_user_id', + ['survey_id' => $surveyId, 'tenant_user_id' => $tenantUserId] + ); + + $now = date('Y-m-d H:i:s'); + foreach ($normalized as $questionId => $answerText) { + if ($answerText === '') { + continue; + } + + app_execute( + $pdo, + 'INSERT INTO survey_answers (id, survey_id, question_id, tenant_user_id, answer_text, created_at, updated_at) VALUES (:id, :survey_id, :question_id, :tenant_user_id, :answer_text, :created_at, :updated_at)', + [ + 'id' => app_uuid(), + 'survey_id' => $surveyId, + 'question_id' => $questionId, + 'tenant_user_id' => $tenantUserId, + 'answer_text' => $answerText, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + } + + $pdo->commit(); + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + throw $exception; } } $auth = app_require_auth(); -$tenantId = (string) ($auth['tenant_id'] ?? 'tenant-demo'); -$tenantName = (string) ($auth['tenant_name'] ?? 'Demo Tenant'); -$tenantUser = (string) ($auth['display_name'] ?? 'Survey-Verantwortliche'); +$tenantId = (string) ($auth['tenant_id'] ?? ''); $tenantLicense = ['plan_name' => 'Free', 'features' => app_feature_defaults()]; $tenantSettings = app_tenant_settings_defaults(); +$tenantNavItems = []; +$flash = app_flash(); +$dbError = null; +$pdo = null; +$surveys = []; +$selectedSurvey = null; +$selectedSurveyAnswers = []; +$selectedSurveyResults = []; +$editingQuestion = null; +$canManage = app_can_manage_surveys($auth); +$surveyTablesReady = false; +$csrf = (string) ($_SESSION['survey_csrf'] ?? ''); try { $pdo = app_pdo(); $tenantLicense = app_tenant_license($pdo, $tenantId); $tenantSettings = app_tenant_settings($pdo, $tenantId); -} catch (\Throwable $ignored) { + $tenantNavItems = app_tenant_navigation_items($auth, $tenantLicense); + $surveyTablesReady = scripts_table_exists($pdo, 'surveys') + && scripts_table_exists($pdo, 'survey_questions') + && scripts_table_exists($pdo, 'survey_answers'); +} catch (Throwable $exception) { + $dbError = $exception->getMessage(); +} + +if ($pdo instanceof PDO && $surveyTablesReady && ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { + if (!hash_equals($csrf, (string) ($_POST['csrf'] ?? ''))) { + app_flash('Die Sitzung ist abgelaufen. Bitte lade die Seite neu.', 'error'); + app_redirect('/surveys/'); + } + + $action = (string) ($_POST['action'] ?? ''); + + try { + if ($action === 'save-survey' && $canManage) { + survey_save_survey($pdo, $tenantId, $_POST); + app_flash('Die Umfrage wurde gespeichert.', 'success'); + } + + if ($action === 'save-question' && $canManage) { + survey_save_question($pdo, (string) ($_POST['survey_id'] ?? ''), $_POST); + app_flash('Die Frage wurde gespeichert.', 'success'); + } + + if ($action === 'change-survey-status' && $canManage) { + $status = (string) ($_POST['status'] ?? 'draft'); + if (!in_array($status, ['draft', 'active', 'closed'], true)) { + $status = 'draft'; + } + app_execute( + $pdo, + 'UPDATE surveys SET status = :status, updated_at = :updated_at WHERE tenant_id = :tenant_id AND id = :id', + [ + 'status' => $status, + 'updated_at' => date('Y-m-d H:i:s'), + 'tenant_id' => $tenantId, + 'id' => (string) ($_POST['survey_id'] ?? ''), + ] + ); + app_flash('Der Umfragestatus wurde aktualisiert.', 'success'); + } + + if ($action === 'answer-survey') { + $survey = survey_fetch_detail($pdo, $tenantId, (string) ($_POST['survey_id'] ?? '')); + if ($survey === null) { + throw new RuntimeException('Die ausgewählte Umfrage konnte nicht gefunden werden.'); + } + survey_submit_answers($pdo, $auth, $survey); + app_flash('Deine Antworten wurden gespeichert.', 'success'); + } + } catch (Throwable $exception) { + app_flash($exception->getMessage(), 'error'); + } + + $redirectSurvey = trim((string) ($_POST['survey_id'] ?? '')); + app_redirect('/surveys/' . ($redirectSurvey !== '' ? '?survey=' . rawurlencode($redirectSurvey) : '')); +} + +if ($pdo instanceof PDO && $surveyTablesReady) { + $surveys = survey_fetch_list($pdo, $tenantId); + $selectedSurveyId = trim((string) ($_GET['survey'] ?? '')); + + if ($selectedSurveyId === '' && $surveys !== []) { + $selectedSurveyId = (string) ($surveys[0]['id'] ?? ''); + } + + $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) : []; + + if ($selectedSurvey !== null && isset($_GET['question']) && $_GET['question'] !== '') { + foreach (($selectedSurvey['questions'] ?? []) as $question) { + if ((string) ($question['id'] ?? '') === (string) $_GET['question']) { + $editingQuestion = $question; + break; + } + } + } } -$tenantNavItems = app_tenant_navigation_items($auth, $tenantLicense); -$controller = new SurveyController(new SurveyService()); -$payload = $controller->index($tenantId); -$data = $payload['data']; -$board = $data['board']; -$memberBoard = $data['memberBoard']; $themeCss = app_tenant_theme_root_css($tenantSettings); - ?> @@ -61,437 +479,222 @@ $themeCss = app_tenant_theme_root_css($tenantSettings); Die Kaffeeliste -
- - -
-
-
-

Umfragen

-

Umfragen

-

- Entwürfe bleiben intern bearbeitbar. Mitglieder sehen nur veröffentlichte Stände. -

-
- Administration - Mitglieder-Sicht - Snapshot -
-
-
- -
-
-
-
-
+ + +
+ +
-
- -
-
-
-
-

Verwaltung

-

Entwürfe und Freigaben

-
- Draft -> Review -> Snapshot -
- - - - - - - - - - - - - - - - - - - -
UmfrageFragenStatusKommentar
- title()) ?> -
ID id()) ?>
-
questionCount() ?> - isDraft()): ?> - Entwurf - status() === 'in_review'): ?> - Prüfung - - Freigegeben - - - isDraft() ? 'Live bearbeitbar, noch nicht freigegeben.' : 'Bereit für Freigabe oder Snapshot.') ?> -
-
- Die Fachseite pflegt Entwürfe live. Mitglieder sehen später nur den veröffentlichten Snapshot. -
-
- -
-

Freigabe-Workflow

-
- -
-

-

-
- -
-
- Freigaben können tenantweit konfigurierbar bleiben. Snapshot und Entwurf sind sauber getrennt. -
-
-
- -
-
-
-
-

Veröffentlicht

-

Snapshots für Mitglieder

-
- Read only -
- - - - - - - - - - - - - - - - - - - -
SnapshotStandAntwortenFreigabe
- surveyTitle()) ?> -
versionLabel()) ?>
-
-
publishedAt())) ?>
-
von publishedBy()) ?>
-
responseCount() ?> - memberVisible()): ?> - Sichtbar - - Gesperrt - -
-
- -
-

Mitglieder-Sicht

-
- -
-
S
-
-

-
-
- -
-
- Mitglieder sehen nur freigegebene Snapshots. Entwürfe bleiben für Tenant-Admins und Survey-Manager editierbar. -
-
-
- -
-
-

Rollen im Modul

-
- -
-
-
-

-

-
-
- -
-
- -
-

Publishing-Regeln

-
    - -
  • Regel
  • - -
-
- Tenant-Verantwortliche können Freigaben steuern und bei Bedarf den Vier-Augen-Mechanismus tenantweit deaktivieren. -
-
-
-
+ + + +
+ + +
+
Umfragen
+

Tenantbezogene Umfragen mit Teilnahme und Auswertung

+

Entwürfe, aktive Umfragen und Ergebnisse laufen in einer Seite zusammen. Mitglieder beantworten freigegebene Umfragen direkt im Tenant, Verantwortliche pflegen Fragen und sehen die Auswertung.

+
+ + +
Die Datenbank konnte nicht geladen werden:
+ +
Die Survey-Tabellen sind noch nicht verfügbar. Bitte Migrationen und Updates ausführen.
+ +
+

Umfragen

Alle Entwürfe und aktiven Umfragen des Tenants.

+
($survey['status'] ?? '') === 'active'))) ?>

Aktiv

Aktuell für Mitglieder freigegebene Umfragen.

+
(int) ($survey['answer_count'] ?? 0), $surveys))) ?>

Antworten

Erfasste Rückmeldungen über alle Umfragen.

+
+ +
+
+

Umfragen im Tenant

+
+ + + + + + + + + + + + + +
TitelStatusFragenAntwortenAktion

Öffnen
+
+
+ + +
+

+
+ + + + + + +
Neu
+
+
+ +
+

Teilnahme

+
    +
  • Du kannst aktive Umfragen direkt im Tenant beantworten.
  • +
  • Deine letzte Antwort pro Frage wird gespeichert.
  • +
  • Geschlossene Umfragen bleiben nur noch zur Einsicht für Verantwortliche sichtbar.
  • +
+
+ +
+ + +
+
+
+
+
Aktive Auswahl
+

+

Status:

+
+ +
+ 'Entwurf', 'active' => 'Aktivieren', 'closed' => 'Schließen'] as $statusKey => $statusLabel): ?> +
+ + + + + +
+ +
+ +
+ + +
+ + + + + + + + + +
Neue Frage
+
+ + +
+ + + + + + + + + + + + +
FrageTypPflichtAktion
Bearbeiten-
+
+
+ +
+ +

+
+ + + + + +
+

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

Diese Umfrage ist derzeit nicht freigegeben

+

Mitglieder können nur Umfragen mit Status active beantworten.

+ +
+
+ + +
+

Auswertung

+
+ + +
+

+ +
  • ()
+ +
  • :
+ +
+ +
+
+ + + + diff --git a/saas-app/resources/views/support/index.blade.php b/saas-app/resources/views/support/index.blade.php index b58ad19..1299fa3 100644 --- a/saas-app/resources/views/support/index.blade.php +++ b/saas-app/resources/views/support/index.blade.php @@ -347,7 +347,7 @@