'private_block', 'name' => 'Privat', 'color' => '#6B7280', 'default_duration' => 60, 'category' => 'private', 'sort_order' => 900], ['system_key' => 'work_vehicle_care', 'name' => 'Fahrzeugpflege', 'color' => '#0E5D80', 'default_duration' => 60, 'category' => 'work', 'sort_order' => 910], ['system_key' => 'work_office', 'name' => 'Buero', 'color' => '#8B5A2B', 'default_duration' => 60, 'category' => 'work', 'sort_order' => 920], ['system_key' => 'work_transfer_drive', 'name' => 'Wechselfahrt', 'color' => '#3B7A57', 'default_duration' => 45, 'category' => 'work', 'sort_order' => 930], ['system_key' => 'work_other', 'name' => 'Sonstige Taetigkeit', 'color' => '#7C3AED', 'default_duration' => 60, 'category' => 'work', 'sort_order' => 940], ['system_key' => 'theory_a', 'name' => 'Theorie A', 'color' => '#C2410C', 'default_duration' => 90, 'category' => 'theory', 'sort_order' => 950], ['system_key' => 'theory_b', 'name' => 'Theorie B', 'color' => '#A74826', 'default_duration' => 90, 'category' => 'theory', 'sort_order' => 960], ['system_key' => 'theory_c', 'name' => 'Theorie C', 'color' => '#B45309', 'default_duration' => 90, 'category' => 'theory', 'sort_order' => 970], ['system_key' => 'theory_d', 'name' => 'Theorie D', 'color' => '#BE123C', 'default_duration' => 90, 'category' => 'theory', 'sort_order' => 980], ['system_key' => 'theory_t', 'name' => 'Theorie T', 'color' => '#047857', 'default_duration' => 90, 'category' => 'theory', 'sort_order' => 990], ['system_key' => 'theory_bkf', 'name' => 'Theorie BKF', 'color' => '#1D4ED8', 'default_duration' => 90, 'category' => 'theory', 'sort_order' => 1000], ]; public function requirementKeyForLessonType(array $lessonType): string { $key = trim((string) ($lessonType['requirement_key'] ?? '')); return $key !== '' ? $key : 'lesson_type_' . (int) $lessonType['id']; } public function lessonTypes(int $tenantId): array { $this->ensureDefaultPlanningTypes($tenantId); $statement = $this->db->prepare('SELECT * FROM lesson_types WHERE tenant_id = :tenant_id ORDER BY sort_order, name'); $statement->execute(['tenant_id' => $tenantId]); $lessonTypes = $statement->fetchAll(); $visibilityMap = $this->lessonTypeVisibilityMap($tenantId); foreach ($lessonTypes as &$lessonType) { $lessonType['visible_template_ids'] = $visibilityMap[(int) $lessonType['id']] ?? []; $lessonType['effective_requirement_key'] = $this->requirementKeyForLessonType($lessonType); } return $lessonTypes; } public function createLessonType(int $tenantId, array $data): array { $timestamp = $this->now(); $statement = $this->db->prepare( 'INSERT INTO lesson_types (tenant_id, name, color, default_duration, category, requirement_key, is_billable, is_counted, is_rest_relevant, fixed_duration, sort_order, created_at, updated_at) VALUES (:tenant_id, :name, :color, :default_duration, :category, :requirement_key, :is_billable, :is_counted, :is_rest_relevant, :fixed_duration, :sort_order, :created_at, :updated_at)' ); $statement->execute([ 'tenant_id' => $tenantId, 'name' => $data['name'], 'color' => $data['color'], 'default_duration' => (int) $data['default_duration'], 'category' => $data['category'], 'requirement_key' => $data['requirement_key'] ?: null, 'is_billable' => !empty($data['is_billable']) ? 1 : 0, 'is_counted' => !empty($data['is_counted']) ? 1 : 0, 'is_rest_relevant' => !empty($data['is_rest_relevant']) ? 1 : 0, 'fixed_duration' => !empty($data['fixed_duration']) ? 1 : 0, 'sort_order' => (int) ($data['sort_order'] ?? 0), 'created_at' => $timestamp, 'updated_at' => $timestamp, ]); $lessonTypeId = (int) $this->lastInsertId(); $this->replaceLessonTypeVisibility($tenantId, $lessonTypeId, $data['visible_template_ids'] ?? null); return $this->findLessonType($tenantId, $lessonTypeId); } public function updateLessonType(int $tenantId, int $id, array $data): ?array { $statement = $this->db->prepare( 'UPDATE lesson_types SET name = :name, color = :color, default_duration = :default_duration, category = :category, requirement_key = :requirement_key, is_billable = :is_billable, is_counted = :is_counted, is_rest_relevant = :is_rest_relevant, fixed_duration = :fixed_duration, sort_order = :sort_order, updated_at = :updated_at WHERE id = :id AND tenant_id = :tenant_id' ); $statement->execute([ 'id' => $id, 'tenant_id' => $tenantId, 'name' => $data['name'], 'color' => $data['color'], 'default_duration' => (int) $data['default_duration'], 'category' => $data['category'], 'requirement_key' => $data['requirement_key'] ?: null, 'is_billable' => !empty($data['is_billable']) ? 1 : 0, 'is_counted' => !empty($data['is_counted']) ? 1 : 0, 'is_rest_relevant' => !empty($data['is_rest_relevant']) ? 1 : 0, 'fixed_duration' => !empty($data['fixed_duration']) ? 1 : 0, 'sort_order' => (int) ($data['sort_order'] ?? 0), 'updated_at' => $this->now(), ]); $this->replaceLessonTypeVisibility($tenantId, $id, $data['visible_template_ids'] ?? null); return $this->findLessonType($tenantId, $id); } public function deleteLessonType(int $tenantId, int $id): bool { $lessonType = $this->findLessonTypeRecord($tenantId, $id); if ($lessonType === null) { return false; } if (($lessonType['system_key'] ?? null) === 'private_block') { throw new \RuntimeException('Der feste Privat-Baustein kann nicht geloescht werden.'); } $usage = $this->db->prepare('SELECT COUNT(*) AS count FROM appointments WHERE tenant_id = :tenant_id AND lesson_type_id = :lesson_type_id'); $usage->execute(['tenant_id' => $tenantId, 'lesson_type_id' => $id]); if ((int) ($usage->fetch()['count'] ?? 0) > 0) { throw new \RuntimeException('Diese Stundenart wird bereits in Terminen verwendet und kann nicht geloescht werden.'); } $this->db->beginTransaction(); try { $visibility = $this->db->prepare('DELETE FROM lesson_type_template_visibility WHERE lesson_type_id = :lesson_type_id'); $visibility->execute(['lesson_type_id' => $id]); $delete = $this->db->prepare('DELETE FROM lesson_types WHERE tenant_id = :tenant_id AND id = :id'); $delete->execute(['tenant_id' => $tenantId, 'id' => $id]); $this->db->commit(); return true; } catch (\Throwable $exception) { $this->db->rollBack(); throw $exception; } } public function reorderLessonTypes(int $tenantId, array $lessonTypeIds): array { $allowedIds = array_map( static fn (array $lessonType): int => (int) $lessonType['id'], $this->lessonTypes($tenantId) ); $statement = $this->db->prepare( 'UPDATE lesson_types SET sort_order = :sort_order, updated_at = :updated_at WHERE tenant_id = :tenant_id AND id = :id' ); $sortOrder = 1; foreach ($lessonTypeIds as $lessonTypeId) { $lessonTypeId = (int) $lessonTypeId; if (!in_array($lessonTypeId, $allowedIds, true)) { continue; } $statement->execute([ 'sort_order' => $sortOrder, 'updated_at' => $this->now(), 'tenant_id' => $tenantId, 'id' => $lessonTypeId, ]); $sortOrder++; } return $this->lessonTypes($tenantId); } public function findLessonType(int $tenantId, int $id): ?array { $statement = $this->db->prepare('SELECT * FROM lesson_types WHERE tenant_id = :tenant_id AND id = :id'); $statement->execute(['tenant_id' => $tenantId, 'id' => $id]); $lessonType = $statement->fetch() ?: null; if ($lessonType === null) { return null; } $lessonType['visible_template_ids'] = $this->visibleTemplateIdsForLessonType($tenantId, $id); $lessonType['effective_requirement_key'] = $this->requirementKeyForLessonType($lessonType); return $lessonType; } public function templates(int $tenantId): array { $statement = $this->db->prepare( 'SELECT * FROM license_class_templates WHERE tenant_id = :tenant_id ORDER BY name' ); $statement->execute(['tenant_id' => $tenantId]); $templates = $statement->fetchAll(); foreach ($templates as &$template) { $template['requirements'] = $this->templateRequirements((int) $template['id']); $template['lesson_type_ids'] = $this->lessonTypeIdsForTemplate((int) $template['id']); } return $templates; } public function templateRequirements(int $templateId): array { $statement = $this->db->prepare( 'SELECT * FROM license_template_requirements WHERE template_id = :template_id ORDER BY sort_order, phase_label, label' ); $statement->execute(['template_id' => $templateId]); return $statement->fetchAll(); } public function createTemplate(int $tenantId, array $data): array { $timestamp = $this->now(); $statement = $this->db->prepare( 'INSERT INTO license_class_templates (tenant_id, code, name, is_combination, created_at, updated_at) VALUES (:tenant_id, :code, :name, :is_combination, :created_at, :updated_at)' ); $statement->execute([ 'tenant_id' => $tenantId, 'code' => $data['code'], 'name' => $data['name'], 'is_combination' => !empty($data['is_combination']) ? 1 : 0, 'created_at' => $timestamp, 'updated_at' => $timestamp, ]); $templateId = (int) $this->lastInsertId(); $this->replaceTemplateRequirements($templateId, $data['requirements'] ?? []); $this->attachTemplateToExistingStudentLessonTypes($tenantId, $templateId); return $this->findTemplate($tenantId, $templateId); } public function updateTemplate(int $tenantId, int $id, array $data): ?array { $statement = $this->db->prepare( 'UPDATE license_class_templates SET code = :code, name = :name, is_combination = :is_combination, updated_at = :updated_at WHERE id = :id AND tenant_id = :tenant_id' ); $statement->execute([ 'id' => $id, 'tenant_id' => $tenantId, 'code' => $data['code'], 'name' => $data['name'], 'is_combination' => !empty($data['is_combination']) ? 1 : 0, 'updated_at' => $this->now(), ]); $this->replaceTemplateRequirements($id, $data['requirements'] ?? []); return $this->findTemplate($tenantId, $id); } public function updateLessonTypeMatrix(int $tenantId, array $rows): array { $templates = $this->templates($tenantId); $templateIds = array_map(static fn (array $template): int => (int) $template['id'], $templates); $allowedLessonTypeIds = array_map( static fn (array $lessonType): int => (int) $lessonType['id'], array_filter($this->lessonTypes($tenantId), static fn (array $lessonType): bool => $lessonType['category'] === 'student') ); $delete = $this->db->prepare( 'DELETE FROM lesson_type_template_visibility WHERE template_id = :template_id AND lesson_type_id IN ( SELECT id FROM lesson_types WHERE tenant_id = :tenant_id AND category = \'student\' )' ); $insert = $this->db->prepare( 'INSERT OR IGNORE INTO lesson_type_template_visibility (lesson_type_id, template_id) VALUES (:lesson_type_id, :template_id)' ); foreach ($rows as $row) { $templateId = (int) ($row['template_id'] ?? 0); if (!in_array($templateId, $templateIds, true)) { continue; } $delete->execute([ 'template_id' => $templateId, 'tenant_id' => $tenantId, ]); foreach ((array) ($row['lesson_type_ids'] ?? []) as $lessonTypeId) { $lessonTypeId = (int) $lessonTypeId; if (!in_array($lessonTypeId, $allowedLessonTypeIds, true)) { continue; } $insert->execute([ 'lesson_type_id' => $lessonTypeId, 'template_id' => $templateId, ]); } } return $this->templates($tenantId); } public function isLessonTypeVisibleForTemplate(int $tenantId, int $lessonTypeId, int $templateId): bool { $lessonType = $this->findLessonType($tenantId, $lessonTypeId); if ($lessonType === null) { return false; } if ($lessonType['category'] !== 'student') { return true; } return in_array($templateId, array_map('intval', $lessonType['visible_template_ids'] ?? []), true); } private function replaceTemplateRequirements(int $templateId, array $requirements): void { $delete = $this->db->prepare('DELETE FROM license_template_requirements WHERE template_id = :template_id'); $delete->execute(['template_id' => $templateId]); $insert = $this->db->prepare( 'INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label) VALUES (:template_id, :requirement_key, :label, :required_units, :sort_order, :phase_label)' ); foreach ($requirements as $index => $requirement) { if (($requirement['label'] ?? '') === '') { continue; } $insert->execute([ 'template_id' => $templateId, 'requirement_key' => $requirement['requirement_key'], 'label' => $requirement['label'], 'required_units' => (int) $requirement['required_units'], 'sort_order' => (int) ($requirement['sort_order'] ?? $index), 'phase_label' => trim((string) ($requirement['phase_label'] ?? '')), ]); } } public function findTemplate(int $tenantId, int $id): ?array { $statement = $this->db->prepare( 'SELECT * FROM license_class_templates WHERE tenant_id = :tenant_id AND id = :id' ); $statement->execute(['tenant_id' => $tenantId, 'id' => $id]); $template = $statement->fetch() ?: null; if ($template !== null) { $template['requirements'] = $this->templateRequirements($id); $template['lesson_type_ids'] = $this->lessonTypeIdsForTemplate($id); } return $template; } private function replaceLessonTypeVisibility(int $tenantId, int $lessonTypeId, ?array $visibleTemplateIds): void { $lessonType = $this->findLessonTypeRecord($tenantId, $lessonTypeId); if ($lessonType === null) { return; } $delete = $this->db->prepare('DELETE FROM lesson_type_template_visibility WHERE lesson_type_id = :lesson_type_id'); $delete->execute(['lesson_type_id' => $lessonTypeId]); if ($lessonType['category'] !== 'student') { return; } $templateIds = $visibleTemplateIds ?? $this->templateIdsForTenant($tenantId); $templateIds = array_values(array_unique(array_map('intval', $templateIds))); if ($templateIds === []) { return; } $allowedTemplateIds = $this->templateIdsForTenant($tenantId); $insert = $this->db->prepare( 'INSERT OR IGNORE INTO lesson_type_template_visibility (lesson_type_id, template_id) VALUES (:lesson_type_id, :template_id)' ); foreach ($templateIds as $templateId) { if (!in_array($templateId, $allowedTemplateIds, true)) { continue; } $insert->execute([ 'lesson_type_id' => $lessonTypeId, 'template_id' => $templateId, ]); } } private function lessonTypeVisibilityMap(int $tenantId): array { $statement = $this->db->prepare( 'SELECT lt.id AS lesson_type_id, vis.template_id FROM lesson_types lt LEFT JOIN lesson_type_template_visibility vis ON vis.lesson_type_id = lt.id WHERE lt.tenant_id = :tenant_id AND lt.category = \'student\' ORDER BY vis.template_id' ); $statement->execute(['tenant_id' => $tenantId]); $map = []; foreach ($statement->fetchAll() as $row) { $lessonTypeId = (int) $row['lesson_type_id']; $map[$lessonTypeId] ??= []; if ($row['template_id'] !== null) { $map[$lessonTypeId][] = (int) $row['template_id']; } } return $map; } private function visibleTemplateIdsForLessonType(int $tenantId, int $lessonTypeId): array { $lessonType = $this->findLessonTypeRecord($tenantId, $lessonTypeId); if ($lessonType === null || $lessonType['category'] !== 'student') { return []; } $statement = $this->db->prepare( 'SELECT template_id FROM lesson_type_template_visibility WHERE lesson_type_id = :lesson_type_id ORDER BY template_id' ); $statement->execute(['lesson_type_id' => $lessonTypeId]); return array_map( static fn (array $row): int => (int) $row['template_id'], $statement->fetchAll() ); } private function lessonTypeIdsForTemplate(int $templateId): array { $statement = $this->db->prepare( 'SELECT lesson_type_id FROM lesson_type_template_visibility WHERE template_id = :template_id ORDER BY lesson_type_id' ); $statement->execute(['template_id' => $templateId]); return array_map( static fn (array $row): int => (int) $row['lesson_type_id'], $statement->fetchAll() ); } private function templateIdsForTenant(int $tenantId): array { $statement = $this->db->prepare('SELECT id FROM license_class_templates WHERE tenant_id = :tenant_id ORDER BY id'); $statement->execute(['tenant_id' => $tenantId]); return array_map( static fn (array $row): int => (int) $row['id'], $statement->fetchAll() ); } private function attachTemplateToExistingStudentLessonTypes(int $tenantId, int $templateId): void { $statement = $this->db->prepare( 'INSERT OR IGNORE INTO lesson_type_template_visibility (lesson_type_id, template_id) SELECT id, :template_id FROM lesson_types WHERE tenant_id = :tenant_id AND category = \'student\'' ); $statement->execute([ 'template_id' => $templateId, 'tenant_id' => $tenantId, ]); } private function findLessonTypeRecord(int $tenantId, int $id): ?array { $statement = $this->db->prepare('SELECT * FROM lesson_types WHERE tenant_id = :tenant_id AND id = :id'); $statement->execute(['tenant_id' => $tenantId, 'id' => $id]); return $statement->fetch() ?: null; } private function ensureDefaultPlanningTypes(int $tenantId): void { $statement = $this->db->prepare( 'INSERT OR IGNORE INTO lesson_types (tenant_id, name, color, default_duration, category, requirement_key, is_billable, is_counted, is_rest_relevant, fixed_duration, sort_order, system_key, created_at, updated_at) VALUES (:tenant_id, :name, :color, :default_duration, :category, NULL, 0, 0, :is_rest_relevant, 0, :sort_order, :system_key, :created_at, :updated_at)' ); $timestamp = $this->now(); foreach (self::DEFAULT_PLANNING_TYPES as $row) { $statement->execute([ 'tenant_id' => $tenantId, 'name' => $row['name'], 'color' => $row['color'], 'default_duration' => $row['default_duration'], 'category' => $row['category'], 'is_rest_relevant' => $row['category'] === 'private' ? 0 : 1, 'sort_order' => $row['sort_order'], 'system_key' => $row['system_key'], 'created_at' => $timestamp, 'updated_at' => $timestamp, ]); } } }