From 22206d361a8a8542c85477816f5105d56254700b Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 18 May 2026 22:12:40 +0200 Subject: [PATCH] Initial commit: sync from server - all domains and code --- .gitignore | 24 + www/api.fahrschuldesk.de/public/index.php | 2 + www/api.fahrschultermin.de/public/index.php | 2 + www/fahrschuldesk.de/public/index.html | 27 + .../Controllers/AppointmentsController.php | 187 +++++++ .../app/Http/Controllers/AuthController.php | 49 ++ .../Http/Controllers/BootstrapController.php | 76 +++ .../Controllers/InstructorsController.php | 33 ++ .../Controllers/ReferenceDataController.php | 90 +++ .../Http/Controllers/StudentsController.php | 78 +++ .../Http/Controllers/TenantsController.php | 40 ++ .../app/Http/Controllers/UsersController.php | 51 ++ .../Repositories/AppointmentRepository.php | 290 ++++++++++ .../app/Repositories/BaseRepository.php | 23 + .../app/Repositories/InstructorRepository.php | 82 +++ .../Repositories/ReferenceDataRepository.php | 515 ++++++++++++++++++ .../app/Repositories/StudentRepository.php | 294 ++++++++++ .../app/Repositories/TenantRepository.php | 86 +++ .../app/Repositories/UserRepository.php | 112 ++++ .../app/Services/CalendarService.php | 93 ++++ .../app/Services/HolidayService.php | 109 ++++ .../app/Services/RequirementService.php | 56 ++ .../app/Services/SunTimesService.php | 55 ++ www/fahrschultermin.de/app/Support/Auth.php | 42 ++ .../app/Support/Database.php | 37 ++ .../app/Support/Request.php | 40 ++ .../app/Support/Response.php | 22 + www/fahrschultermin.de/app/Support/Router.php | 63 +++ www/fahrschultermin.de/app/bootstrap.php | 44 ++ www/fahrschultermin.de/bin/migrate.php | 27 + www/fahrschultermin.de/bin/seed.php | 208 +++++++ www/fahrschultermin.de/config/app.php | 13 + .../database/migrations/001_initial.sql | 145 +++++ .../migrations/002_instructors_user_link.sql | 1 + .../003_backfill_instructor_user_links.sql | 11 + .../004_lesson_type_template_visibility.sql | 13 + .../migrations/005_tenant_location_sun.sql | 3 + .../migrations/006_tenant_federal_state.sql | 1 + .../007_student_prior_completed_units.sql | 1 + .../migrations/008_lesson_type_system_key.sql | 2 + .../009_requirement_phase_labels.sql | 2 + .../database/migrations/010_student_email.sql | 1 + .../migrations/010_students_contact_flags.sql | 2 + .../migrations/011_student_needs_glasses.sql | 1 + .../012_split_combination_templates.sql | 183 +++++++ .../013_combo_generic_key_cleanup.sql | 42 ++ ...tudent_key_aliases_to_lesson_type_keys.sql | 170 ++++++ .../015_instructor_pre_start_buffer.sql | 1 + www/fahrschultermin.de/public/.htaccess | 4 + .../public/assets/.vite/manifest.json | 11 + .../public/assets/assets/main-C54NZrM5.css | 1 + .../public/assets/assets/main-F76uNDS1.js | 44 ++ www/fahrschultermin.de/public/index.html | 27 + www/fahrschultermin.de/public/index.php | 100 ++++ 54 files changed, 3636 insertions(+) create mode 100644 .gitignore create mode 100644 www/api.fahrschuldesk.de/public/index.php create mode 100644 www/api.fahrschultermin.de/public/index.php create mode 100644 www/fahrschuldesk.de/public/index.html create mode 100644 www/fahrschultermin.de/app/Http/Controllers/AppointmentsController.php create mode 100644 www/fahrschultermin.de/app/Http/Controllers/AuthController.php create mode 100644 www/fahrschultermin.de/app/Http/Controllers/BootstrapController.php create mode 100644 www/fahrschultermin.de/app/Http/Controllers/InstructorsController.php create mode 100644 www/fahrschultermin.de/app/Http/Controllers/ReferenceDataController.php create mode 100644 www/fahrschultermin.de/app/Http/Controllers/StudentsController.php create mode 100644 www/fahrschultermin.de/app/Http/Controllers/TenantsController.php create mode 100644 www/fahrschultermin.de/app/Http/Controllers/UsersController.php create mode 100644 www/fahrschultermin.de/app/Repositories/AppointmentRepository.php create mode 100644 www/fahrschultermin.de/app/Repositories/BaseRepository.php create mode 100644 www/fahrschultermin.de/app/Repositories/InstructorRepository.php create mode 100644 www/fahrschultermin.de/app/Repositories/ReferenceDataRepository.php create mode 100644 www/fahrschultermin.de/app/Repositories/StudentRepository.php create mode 100644 www/fahrschultermin.de/app/Repositories/TenantRepository.php create mode 100644 www/fahrschultermin.de/app/Repositories/UserRepository.php create mode 100644 www/fahrschultermin.de/app/Services/CalendarService.php create mode 100644 www/fahrschultermin.de/app/Services/HolidayService.php create mode 100644 www/fahrschultermin.de/app/Services/RequirementService.php create mode 100644 www/fahrschultermin.de/app/Services/SunTimesService.php create mode 100644 www/fahrschultermin.de/app/Support/Auth.php create mode 100644 www/fahrschultermin.de/app/Support/Database.php create mode 100644 www/fahrschultermin.de/app/Support/Request.php create mode 100644 www/fahrschultermin.de/app/Support/Response.php create mode 100644 www/fahrschultermin.de/app/Support/Router.php create mode 100644 www/fahrschultermin.de/app/bootstrap.php create mode 100644 www/fahrschultermin.de/bin/migrate.php create mode 100644 www/fahrschultermin.de/bin/seed.php create mode 100644 www/fahrschultermin.de/config/app.php create mode 100644 www/fahrschultermin.de/database/migrations/001_initial.sql create mode 100644 www/fahrschultermin.de/database/migrations/002_instructors_user_link.sql create mode 100644 www/fahrschultermin.de/database/migrations/003_backfill_instructor_user_links.sql create mode 100644 www/fahrschultermin.de/database/migrations/004_lesson_type_template_visibility.sql create mode 100644 www/fahrschultermin.de/database/migrations/005_tenant_location_sun.sql create mode 100644 www/fahrschultermin.de/database/migrations/006_tenant_federal_state.sql create mode 100644 www/fahrschultermin.de/database/migrations/007_student_prior_completed_units.sql create mode 100644 www/fahrschultermin.de/database/migrations/008_lesson_type_system_key.sql create mode 100644 www/fahrschultermin.de/database/migrations/009_requirement_phase_labels.sql create mode 100644 www/fahrschultermin.de/database/migrations/010_student_email.sql create mode 100644 www/fahrschultermin.de/database/migrations/010_students_contact_flags.sql create mode 100644 www/fahrschultermin.de/database/migrations/011_student_needs_glasses.sql create mode 100644 www/fahrschultermin.de/database/migrations/012_split_combination_templates.sql create mode 100644 www/fahrschultermin.de/database/migrations/013_combo_generic_key_cleanup.sql create mode 100644 www/fahrschultermin.de/database/migrations/014_legacy_student_key_aliases_to_lesson_type_keys.sql create mode 100644 www/fahrschultermin.de/database/migrations/015_instructor_pre_start_buffer.sql create mode 100644 www/fahrschultermin.de/public/.htaccess create mode 100644 www/fahrschultermin.de/public/assets/.vite/manifest.json create mode 100644 www/fahrschultermin.de/public/assets/assets/main-C54NZrM5.css create mode 100644 www/fahrschultermin.de/public/assets/assets/main-F76uNDS1.js create mode 100644 www/fahrschultermin.de/public/index.html create mode 100644 www/fahrschultermin.de/public/index.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2fe4b8d --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Ignore session files (thousands of them) +storage/sessions/ + +# Ignore database files +storage/database/*.sqlite +storage/database/*.sqlite.bak* + +# Ignore IDE / OS +.idea/ +.DS_Store +*.swp +*~ + +# Ignore environment files with secrets +.env +.env.* +*.pem +*.key + +# Ignore composer/vendor (if PHP) +vendor/ + +# Ignore node_modules (if frontend) +node_modules/ \ No newline at end of file diff --git a/www/api.fahrschuldesk.de/public/index.php b/www/api.fahrschuldesk.de/public/index.php new file mode 100644 index 0000000..959015f --- /dev/null +++ b/www/api.fahrschuldesk.de/public/index.php @@ -0,0 +1,2 @@ + + + + + +fahrschuldesk.de – Erfolgreich eingerichtet + + + +

✅ fahrschuldesk.de ist betriebsbereit

+

Diese Seite wurde automatisch vom Webhosting‑Stack erzeugt.

+
+ Nächste Schritte: + +
+

Server: core

+ + \ No newline at end of file diff --git a/www/fahrschultermin.de/app/Http/Controllers/AppointmentsController.php b/www/fahrschultermin.de/app/Http/Controllers/AppointmentsController.php new file mode 100644 index 0000000..e07234f --- /dev/null +++ b/www/fahrschultermin.de/app/Http/Controllers/AppointmentsController.php @@ -0,0 +1,187 @@ +query('from'); + $to = (string) $request->query('to'); + $instructorId = null; + if ($user['role'] === 'instructor') { + $instructor = (new InstructorRepository())->findByUserId((int) $user['tenant_id'], (int) $user['id']); + $instructorId = $instructor ? (int) $instructor['id'] : null; + } + $data = (new AppointmentRepository())->listForTenant( + (int) $user['tenant_id'], + $from, + $to, + $instructorId + ); + Response::json(['data' => $data]); + } + + public function validate(Request $request): void + { + $user = Auth::requireUser(); + $payload = $this->normalizePayload((int) $user['tenant_id'], $request->input()); + $warnings = (new CalendarService())->validate((int) $user['tenant_id'], $payload); + Response::json(['warnings' => $warnings, 'payload' => $payload]); + } + + public function store(Request $request): void + { + $user = Auth::requireRole(['tenant_admin', 'dispatcher', 'instructor']); + $payload = $this->normalizePayload((int) $user['tenant_id'], $request->input()); + $warnings = (new CalendarService())->validate((int) $user['tenant_id'], $payload); + + if ($this->hasWarnings($warnings) && empty($payload['warning_acknowledged'])) { + Response::json(['message' => 'Warnings require acknowledgement', 'warnings' => $warnings], 409); + } + + $record = (new AppointmentRepository())->create((int) $user['tenant_id'], $payload, (int) $user['id']); + Response::json([ + 'data' => $record, + 'warnings' => $warnings, + 'progress' => $payload['student_id'] ? (new RequirementService())->progressForStudent((int) $payload['student_id']) : [], + ], 201); + } + + public function update(Request $request, array $params): void + { + $user = Auth::requireRole(['tenant_admin', 'dispatcher', 'instructor']); + $payload = $this->normalizePayload((int) $user['tenant_id'], $request->input()); + $warnings = (new CalendarService())->validate((int) $user['tenant_id'], $payload, (int) $params['id']); + + if ($this->hasWarnings($warnings) && empty($payload['warning_acknowledged'])) { + Response::json(['message' => 'Warnings require acknowledgement', 'warnings' => $warnings], 409); + } + + $record = (new AppointmentRepository())->update((int) $user['tenant_id'], (int) $params['id'], $payload, (int) $user['id']); + Response::json([ + 'data' => $record, + 'warnings' => $warnings, + 'progress' => $payload['student_id'] ? (new RequirementService())->progressForStudent((int) $payload['student_id']) : [], + ]); + } + + public function destroy(Request $request, array $params): void + { + $user = Auth::requireRole(['tenant_admin', 'dispatcher']); + (new AppointmentRepository())->delete((int) $user['tenant_id'], (int) $params['id']); + Response::noContent(); + } + + private function normalizePayload(int $tenantId, array $payload): array + { + $referenceRepository = new ReferenceDataRepository(); + $lessonType = $referenceRepository->findLessonType($tenantId, (int) $payload['lesson_type_id']); + if ($lessonType === null) { + Response::json(['message' => 'Unbekannte Stundenart'], 422); + } + + $student = null; + if (!empty($payload['student_id'])) { + $student = (new StudentRepository())->find($tenantId, (int) $payload['student_id']); + if ($student === null) { + Response::json(['message' => 'Unbekannter Fahrschueler'], 422); + } + + if ( + $lessonType['category'] === 'student' + && !$referenceRepository->isLessonTypeVisibleForTemplate($tenantId, (int) $lessonType['id'], (int) $student['template_id']) + ) { + Response::json(['message' => 'Diese Stundenart ist fuer die Fuehrerscheinklasse nicht freigegeben'], 422); + } + } + + $startAt = new \DateTimeImmutable((string) $payload['start_at']); + $baseDuration = max(1, (int) $lessonType['default_duration']); + $providedEndAt = !empty($payload['end_at']) ? new \DateTimeImmutable((string) $payload['end_at']) : null; + + if ($providedEndAt instanceof \DateTimeImmutable && $providedEndAt > $startAt) { + $endAt = $providedEndAt; + $duration = max(1, (int) round(($endAt->getTimestamp() - $startAt->getTimestamp()) / 60)); + $units = (int) $lessonType['fixed_duration'] === 1 + ? 1 + : max(1, (int) round($duration / $baseDuration)); + } else { + $units = max(1, (int) ($payload['units'] ?? 1)); + $duration = (int) $lessonType['fixed_duration'] === 1 + ? $baseDuration + : $baseDuration * $units; + $endAt = $startAt->modify(sprintf('+%d minutes', $duration)); + } + + $title = $payload['title'] ?? $lessonType['name'] ?? 'Termin'; + $unitsBreakdown = []; + + if ($lessonType && (int) $lessonType['is_counted'] === 1 && !empty($payload['student_id'])) { + $requirementKey = $lessonType['effective_requirement_key'] ?? $lessonType['requirement_key']; + if ($student !== null && (int) ($student['template_is_combination'] ?? 0) === 1) { + $requirementKey = $this->requirementKeyForPhase($student['requirements'] ?? [], $requirementKey, (string) ($payload['requirement_phase'] ?? '')); + } + + $unitsBreakdown[] = [ + 'requirement_key' => $requirementKey, + 'label' => $lessonType['name'], + 'units_counted' => $units, + ]; + } + + return [ + 'student_id' => $payload['student_id'] ?? null, + 'instructor_id' => (int) $payload['instructor_id'], + 'lesson_type_id' => (int) $payload['lesson_type_id'], + 'title' => $title, + 'category' => $lessonType['category'] ?? ($payload['category'] ?? 'student'), + 'start_at' => $startAt->format(DATE_ATOM), + 'end_at' => $endAt->format(DATE_ATOM), + 'units' => $units, + 'status' => $payload['status'] ?? 'planned', + 'notes' => $payload['notes'] ?? '', + 'warning_acknowledged' => !empty($payload['warning_acknowledged']), + 'units_breakdown' => $unitsBreakdown, + ]; + } + + private function hasWarnings(array $warnings): bool + { + return !empty($warnings['conflicts']) || !empty($warnings['rest_period_warnings']); + } + + private function requirementKeyForPhase(array $requirements, string $baseRequirementKey, string $phaseLabel): string + { + $matchingRows = array_values(array_filter( + $requirements, + static fn (array $row): bool => str_ends_with((string) $row['requirement_key'], '::' . $baseRequirementKey) + || (string) $row['requirement_key'] === $baseRequirementKey + )); + + if ($matchingRows === []) { + return $baseRequirementKey; + } + + foreach ($matchingRows as $row) { + if ((string) ($row['phase_label'] ?? '') === $phaseLabel) { + return (string) $row['requirement_key']; + } + } + + return (string) $matchingRows[0]['requirement_key']; + } +} diff --git a/www/fahrschultermin.de/app/Http/Controllers/AuthController.php b/www/fahrschultermin.de/app/Http/Controllers/AuthController.php new file mode 100644 index 0000000..a71b131 --- /dev/null +++ b/www/fahrschultermin.de/app/Http/Controllers/AuthController.php @@ -0,0 +1,49 @@ +input(); + $user = (new UserRepository())->findByEmail(trim((string) ($payload['email'] ?? ''))); + + if ($user === null || !password_verify((string) ($payload['password'] ?? ''), $user['password_hash'])) { + Response::json(['message' => 'Ungueltige Zugangsdaten'], 422); + } + + if (!(int) $user['is_active'] || ((int) ($user['tenant_is_active'] ?? 1) !== 1 && $user['role'] !== 'superadmin')) { + Response::json(['message' => 'Benutzer oder Mandant ist deaktiviert'], 403); + } + + $_SESSION['user_id'] = (int) $user['id']; + + Response::json(['user' => $this->sanitizeUser($user)]); + } + + public function logout(): void + { + session_destroy(); + Response::noContent(); + } + + public function me(): void + { + $user = Auth::user(); + Response::json(['user' => $user ? $this->sanitizeUser($user) : null]); + } + + private function sanitizeUser(array $user): array + { + unset($user['password_hash']); + return $user; + } +} diff --git a/www/fahrschultermin.de/app/Http/Controllers/BootstrapController.php b/www/fahrschultermin.de/app/Http/Controllers/BootstrapController.php new file mode 100644 index 0000000..0a9bf53 --- /dev/null +++ b/www/fahrschultermin.de/app/Http/Controllers/BootstrapController.php @@ -0,0 +1,76 @@ + ['user' => $user]]; + + if ($user['role'] === 'superadmin') { + $payload['tenants'] = (new TenantRepository())->all(); + $payload['users'] = (new UserRepository())->allUsers(); + Response::json($payload); + } + + $tenantId = (int) $user['tenant_id']; + $studentRepository = new StudentRepository(); + $students = $studentRepository->allForTenant($tenantId); + $requirementService = new RequirementService(); + + foreach ($students as &$student) { + $student['progress'] = $requirementService->progressForStudent((int) $student['id']); + } + + $payload['tenant'] = (new TenantRepository())->find($tenantId); + $payload['students'] = $students; + $payload['templates'] = (new ReferenceDataRepository())->templates($tenantId); + $payload['lessonTypes'] = (new ReferenceDataRepository())->lessonTypes($tenantId); + $payload['instructors'] = (new InstructorRepository())->allForTenant($tenantId); + $payload['users'] = $user['role'] === 'tenant_admin' + ? (new UserRepository())->allForTenant($tenantId) + : []; + $instructorId = null; + if ($user['role'] === 'instructor') { + $instructor = (new InstructorRepository())->findByUserId($tenantId, (int) $user['id']); + $instructorId = $instructor ? (int) $instructor['id'] : null; + } + + $from = $request->query('from', gmdate('Y-m-d\T00:00:00\Z', strtotime('monday this week'))); + $to = $request->query('to', gmdate('Y-m-d\T00:00:00\Z', strtotime('+7 day', strtotime($from)))); + $payload['sunTimes'] = (new SunTimesService())->forRange($payload['tenant'], (string) $from, (string) $to); + $payload['dayMeta'] = (new HolidayService())->forRange($payload['tenant'], (string) $from, (string) $to); + $payload['appointments'] = (new AppointmentRepository())->listForTenant( + $tenantId, + (string) $from, + (string) $to, + $instructorId + ); + $payload['nextAppointment'] = (new AppointmentRepository())->nextForTenant( + $tenantId, + gmdate(DATE_ATOM), + $instructorId + ); + + Response::json($payload); + } +} diff --git a/www/fahrschultermin.de/app/Http/Controllers/InstructorsController.php b/www/fahrschultermin.de/app/Http/Controllers/InstructorsController.php new file mode 100644 index 0000000..b72c60a --- /dev/null +++ b/www/fahrschultermin.de/app/Http/Controllers/InstructorsController.php @@ -0,0 +1,33 @@ + (new InstructorRepository())->allForTenant((int) $user['tenant_id'])]); + } + + public function store(Request $request): void + { + $user = Auth::requireRole('tenant_admin'); + $record = (new InstructorRepository())->create((int) $user['tenant_id'], $request->input()); + Response::json(['data' => $record], 201); + } + + public function update(Request $request, array $params): void + { + $user = Auth::requireRole('tenant_admin'); + $record = (new InstructorRepository())->update((int) $user['tenant_id'], (int) $params['id'], $request->input()); + Response::json(['data' => $record]); + } +} diff --git a/www/fahrschultermin.de/app/Http/Controllers/ReferenceDataController.php b/www/fahrschultermin.de/app/Http/Controllers/ReferenceDataController.php new file mode 100644 index 0000000..71b091c --- /dev/null +++ b/www/fahrschultermin.de/app/Http/Controllers/ReferenceDataController.php @@ -0,0 +1,90 @@ + (new ReferenceDataRepository())->lessonTypes((int) $user['tenant_id'])]); + } + + public function storeLessonType(Request $request): void + { + $user = Auth::requireRole('tenant_admin'); + $record = (new ReferenceDataRepository())->createLessonType((int) $user['tenant_id'], $request->input()); + Response::json(['data' => $record], 201); + } + + public function updateLessonType(Request $request, array $params): void + { + $user = Auth::requireRole('tenant_admin'); + $record = (new ReferenceDataRepository())->updateLessonType((int) $user['tenant_id'], (int) $params['id'], $request->input()); + Response::json(['data' => $record]); + } + + public function destroyLessonType(Request $request, array $params): void + { + $user = Auth::requireRole('tenant_admin'); + + try { + $deleted = (new ReferenceDataRepository())->deleteLessonType((int) $user['tenant_id'], (int) $params['id']); + } catch (\RuntimeException $exception) { + Response::json(['message' => $exception->getMessage()], 409); + } + + if (!$deleted) { + Response::json(['message' => 'Not found'], 404); + } + + Response::noContent(); + } + + public function reorderLessonTypes(Request $request): void + { + $user = Auth::requireRole('tenant_admin'); + $records = (new ReferenceDataRepository())->reorderLessonTypes( + (int) $user['tenant_id'], + (array) ($request->input()['lesson_type_ids'] ?? []) + ); + Response::json(['data' => $records]); + } + + public function updateLessonTypeMatrix(Request $request): void + { + $user = Auth::requireRole('tenant_admin'); + $records = (new ReferenceDataRepository())->updateLessonTypeMatrix( + (int) $user['tenant_id'], + (array) ($request->input()['rows'] ?? []) + ); + Response::json(['data' => $records]); + } + + public function templates(): void + { + $user = Auth::requireUser(); + Response::json(['data' => (new ReferenceDataRepository())->templates((int) $user['tenant_id'])]); + } + + public function storeTemplate(Request $request): void + { + $user = Auth::requireRole('tenant_admin'); + $record = (new ReferenceDataRepository())->createTemplate((int) $user['tenant_id'], $request->input()); + Response::json(['data' => $record], 201); + } + + public function updateTemplate(Request $request, array $params): void + { + $user = Auth::requireRole('tenant_admin'); + $record = (new ReferenceDataRepository())->updateTemplate((int) $user['tenant_id'], (int) $params['id'], $request->input()); + Response::json(['data' => $record]); + } +} diff --git a/www/fahrschultermin.de/app/Http/Controllers/StudentsController.php b/www/fahrschultermin.de/app/Http/Controllers/StudentsController.php new file mode 100644 index 0000000..8323549 --- /dev/null +++ b/www/fahrschultermin.de/app/Http/Controllers/StudentsController.php @@ -0,0 +1,78 @@ +allForTenant((int) $user['tenant_id']); + $requirements = new RequirementService(); + + foreach ($students as &$student) { + $student['progress'] = $requirements->progressForStudent((int) $student['id']); + } + + Response::json(['data' => $students]); + } + + public function store(Request $request): void + { + $user = Auth::requireRole(['tenant_admin', 'dispatcher']); + $student = (new StudentRepository())->create((int) $user['tenant_id'], $request->input()); + $student['progress'] = (new RequirementService())->progressForStudent((int) $student['id']); + + Response::json(['data' => $student], 201); + } + + public function update(Request $request, array $params): void + { + $user = Auth::requireRole(['tenant_admin', 'dispatcher']); + $student = (new StudentRepository())->update((int) $user['tenant_id'], (int) $params['id'], $request->input()); + + if ($student === null) { + Response::json(['message' => 'Not found'], 404); + } + + $student['progress'] = (new RequirementService())->progressForStudent((int) $student['id']); + Response::json(['data' => $student]); + } + + public function updateTraining(Request $request, array $params): void + { + $user = Auth::requireRole(['tenant_admin', 'dispatcher']); + $student = (new StudentRepository())->updatePriorCompletedUnits( + (int) $user['tenant_id'], + (int) $params['id'], + $request->input()['rows'] ?? [] + ); + + if ($student === null) { + Response::json(['message' => 'Not found'], 404); + } + + $student['progress'] = (new RequirementService())->progressForStudent((int) $student['id']); + Response::json(['data' => $student]); + } + + public function destroy(Request $request, array $params): void + { + $user = Auth::requireRole(['tenant_admin', 'dispatcher']); + $deleted = (new StudentRepository())->delete((int) $user['tenant_id'], (int) $params['id']); + + if (!$deleted) { + Response::json(['message' => 'Not found'], 404); + } + + Response::noContent(); + } +} diff --git a/www/fahrschultermin.de/app/Http/Controllers/TenantsController.php b/www/fahrschultermin.de/app/Http/Controllers/TenantsController.php new file mode 100644 index 0000000..cf238c6 --- /dev/null +++ b/www/fahrschultermin.de/app/Http/Controllers/TenantsController.php @@ -0,0 +1,40 @@ + (new TenantRepository())->all()]); + } + + public function store(Request $request): void + { + Auth::requireRole('superadmin'); + $tenant = (new TenantRepository())->create($request->input()); + Response::json(['data' => $tenant], 201); + } + + public function update(Request $request, array $params): void + { + Auth::requireRole('superadmin'); + $tenant = (new TenantRepository())->update((int) $params['id'], $request->input()); + Response::json(['data' => $tenant]); + } + + public function updateOwn(Request $request): void + { + $user = Auth::requireRole('tenant_admin'); + $tenant = (new TenantRepository())->updateForTenantAdmin((int) $user['tenant_id'], $request->input()); + Response::json(['data' => $tenant]); + } +} diff --git a/www/fahrschultermin.de/app/Http/Controllers/UsersController.php b/www/fahrschultermin.de/app/Http/Controllers/UsersController.php new file mode 100644 index 0000000..364acc5 --- /dev/null +++ b/www/fahrschultermin.de/app/Http/Controllers/UsersController.php @@ -0,0 +1,51 @@ + (new UserRepository())->allUsers()]); + } + + Response::json(['data' => (new UserRepository())->allForTenant((int) $user['tenant_id'])]); + } + + public function store(Request $request): void + { + $user = Auth::requireRole(['tenant_admin', 'superadmin']); + $payload = $request->input(); + if ($user['role'] !== 'superadmin') { + $payload['tenant_id'] = (int) $user['tenant_id']; + } + $record = (new UserRepository())->create($payload); + unset($record['password_hash']); + Response::json(['data' => $record], 201); + } + + public function update(Request $request, array $params): void + { + $user = Auth::requireRole(['tenant_admin', 'superadmin']); + $payload = $request->input(); + if ($user['role'] !== 'superadmin') { + $payload['tenant_id'] = (int) $user['tenant_id']; + } + $record = (new UserRepository())->update((int) $params['id'], $payload); + if ($record === null) { + Response::json(['message' => 'Not found'], 404); + } + unset($record['password_hash']); + Response::json(['data' => $record]); + } +} diff --git a/www/fahrschultermin.de/app/Repositories/AppointmentRepository.php b/www/fahrschultermin.de/app/Repositories/AppointmentRepository.php new file mode 100644 index 0000000..e2f15aa --- /dev/null +++ b/www/fahrschultermin.de/app/Repositories/AppointmentRepository.php @@ -0,0 +1,290 @@ + :from'; + $params = [ + 'tenant_id' => $tenantId, + 'from' => $from, + 'to' => $to, + ]; + + if ($instructorId !== null) { + $sql .= ' AND a.instructor_id = :instructor_id'; + $params['instructor_id'] = $instructorId; + } + + $sql .= ' ORDER BY a.start_at'; + $statement = $this->db->prepare($sql); + $statement->execute($params); + + return $statement->fetchAll(); + } + + public function find(int $tenantId, int $id): ?array + { + $statement = $this->db->prepare('SELECT * FROM appointments WHERE tenant_id = :tenant_id AND id = :id'); + $statement->execute(['tenant_id' => $tenantId, 'id' => $id]); + $appointment = $statement->fetch() ?: null; + + if ($appointment !== null) { + $appointment['units_breakdown'] = $this->units((int) $appointment['id']); + } + + return $appointment; + } + + public function create(int $tenantId, array $data, int $actorId): array + { + $timestamp = $this->now(); + $statement = $this->db->prepare( + 'INSERT INTO appointments + (tenant_id, student_id, instructor_id, lesson_type_id, title, category, start_at, end_at, units, status, notes, warning_acknowledged, created_by, updated_by, created_at, updated_at) + VALUES + (:tenant_id, :student_id, :instructor_id, :lesson_type_id, :title, :category, :start_at, :end_at, :units, :status, :notes, :warning_acknowledged, :created_by, :updated_by, :created_at, :updated_at)' + ); + $statement->execute([ + 'tenant_id' => $tenantId, + 'student_id' => $data['student_id'] ?: null, + 'instructor_id' => $data['instructor_id'], + 'lesson_type_id' => $data['lesson_type_id'], + 'title' => $data['title'], + 'category' => $data['category'], + 'start_at' => $data['start_at'], + 'end_at' => $data['end_at'], + 'units' => (int) $data['units'], + 'status' => $data['status'], + 'notes' => $data['notes'] ?? '', + 'warning_acknowledged' => !empty($data['warning_acknowledged']) ? 1 : 0, + 'created_by' => $actorId, + 'updated_by' => $actorId, + 'created_at' => $timestamp, + 'updated_at' => $timestamp, + ]); + + $appointmentId = (int) $this->db->lastInsertId(); + $this->replaceUnits($appointmentId, $data['units_breakdown'] ?? []); + + return $this->find($tenantId, $appointmentId); + } + + public function update(int $tenantId, int $id, array $data, int $actorId): ?array + { + $statement = $this->db->prepare( + 'UPDATE appointments + SET student_id = :student_id, instructor_id = :instructor_id, lesson_type_id = :lesson_type_id, + title = :title, category = :category, start_at = :start_at, end_at = :end_at, units = :units, + status = :status, notes = :notes, warning_acknowledged = :warning_acknowledged, + updated_by = :updated_by, updated_at = :updated_at + WHERE tenant_id = :tenant_id AND id = :id' + ); + $statement->execute([ + 'tenant_id' => $tenantId, + 'id' => $id, + 'student_id' => $data['student_id'] ?: null, + 'instructor_id' => $data['instructor_id'], + 'lesson_type_id' => $data['lesson_type_id'], + 'title' => $data['title'], + 'category' => $data['category'], + 'start_at' => $data['start_at'], + 'end_at' => $data['end_at'], + 'units' => (int) $data['units'], + 'status' => $data['status'], + 'notes' => $data['notes'] ?? '', + 'warning_acknowledged' => !empty($data['warning_acknowledged']) ? 1 : 0, + 'updated_by' => $actorId, + 'updated_at' => $this->now(), + ]); + + $this->replaceUnits($id, $data['units_breakdown'] ?? []); + + return $this->find($tenantId, $id); + } + + public function delete(int $tenantId, int $id): void + { + $statement = $this->db->prepare('DELETE FROM appointments WHERE tenant_id = :tenant_id AND id = :id'); + $statement->execute(['tenant_id' => $tenantId, 'id' => $id]); + } + + public function units(int $appointmentId): array + { + $statement = $this->db->prepare('SELECT * FROM appointment_units WHERE appointment_id = :appointment_id'); + $statement->execute(['appointment_id' => $appointmentId]); + + return $statement->fetchAll(); + } + + public function overlappingForInstructor(int $tenantId, int $instructorId, string $from, string $to, ?int $ignoreId = null): array + { + $sql = 'SELECT * FROM appointments + WHERE tenant_id = :tenant_id AND instructor_id = :instructor_id + AND start_at < :to AND end_at > :from'; + $params = [ + 'tenant_id' => $tenantId, + 'instructor_id' => $instructorId, + 'from' => $from, + 'to' => $to, + ]; + + if ($ignoreId !== null) { + $sql .= ' AND id != :ignore_id'; + $params['ignore_id'] = $ignoreId; + } + + $statement = $this->db->prepare($sql); + $statement->execute($params); + + return $statement->fetchAll(); + } + + public function overlappingForStudent(int $tenantId, int $studentId, string $from, string $to, ?int $ignoreId = null): array + { + $sql = 'SELECT * FROM appointments + WHERE tenant_id = :tenant_id AND student_id = :student_id + AND start_at < :to AND end_at > :from'; + $params = [ + 'tenant_id' => $tenantId, + 'student_id' => $studentId, + 'from' => $from, + 'to' => $to, + ]; + + if ($ignoreId !== null) { + $sql .= ' AND id != :ignore_id'; + $params['ignore_id'] = $ignoreId; + } + + $statement = $this->db->prepare($sql); + $statement->execute($params); + + return $statement->fetchAll(); + } + + public function lastRestRelevantForInstructor(int $tenantId, int $instructorId, string $beforeStart, ?int $ignoreId = null): ?array + { + $sql = 'SELECT a.*, lt.name AS lesson_type_name + FROM appointments a + JOIN lesson_types lt ON lt.id = a.lesson_type_id + WHERE a.tenant_id = :tenant_id + AND a.instructor_id = :instructor_id + AND lt.is_rest_relevant = 1 + AND a.end_at <= :before_start'; + $params = [ + 'tenant_id' => $tenantId, + 'instructor_id' => $instructorId, + 'before_start' => $beforeStart, + ]; + + if ($ignoreId !== null) { + $sql .= ' AND a.id != :ignore_id'; + $params['ignore_id'] = $ignoreId; + } + + $sql .= ' ORDER BY a.end_at DESC LIMIT 1'; + $statement = $this->db->prepare($sql); + $statement->execute($params); + + return $statement->fetch() ?: null; + } + + public function hasRestRelevantAfterStartOnDay(int $tenantId, int $instructorId, string $dayStart, string $dayEnd, string $startAt): bool + { + $statement = $this->db->prepare( + 'SELECT 1 + FROM appointments a + JOIN lesson_types lt ON lt.id = a.lesson_type_id + WHERE a.tenant_id = :tenant_id + AND a.instructor_id = :instructor_id + AND lt.is_rest_relevant = 1 + AND a.start_at < :day_end + AND a.end_at > :day_start + AND a.end_at > :start_at + LIMIT 1' + ); + $statement->execute([ + 'tenant_id' => $tenantId, + 'instructor_id' => $instructorId, + 'day_start' => $dayStart, + 'day_end' => $dayEnd, + 'start_at' => $startAt, + ]); + + return (bool) $statement->fetchColumn(); + } + + public function nextForTenant(int $tenantId, string $from, ?int $instructorId = null): ?array + { + $sql = 'SELECT a.*, s.first_name AS student_first_name, s.last_name AS student_last_name, + i.first_name AS instructor_first_name, i.last_name AS instructor_last_name, + lt.name AS lesson_type_name + FROM appointments a + JOIN instructors i ON i.id = a.instructor_id + LEFT JOIN students s ON s.id = a.student_id + JOIN lesson_types lt ON lt.id = a.lesson_type_id + WHERE a.tenant_id = :tenant_id + AND a.start_at >= :from'; + $params = [ + 'tenant_id' => $tenantId, + 'from' => $from, + ]; + + if ($instructorId !== null) { + $sql .= ' AND a.instructor_id = :instructor_id'; + $params['instructor_id'] = $instructorId; + } + + $sql .= ' ORDER BY a.start_at ASC LIMIT 1'; + $statement = $this->db->prepare($sql); + $statement->execute($params); + + return $statement->fetch() ?: null; + } + + private function replaceUnits(int $appointmentId, array $rows): void + { + $delete = $this->db->prepare('DELETE FROM appointment_units WHERE appointment_id = :appointment_id'); + $delete->execute(['appointment_id' => $appointmentId]); + + $insert = $this->db->prepare( + 'INSERT INTO appointment_units (appointment_id, requirement_key, label, units_counted, created_at) + VALUES (:appointment_id, :requirement_key, :label, :units_counted, :created_at)' + ); + + foreach ($rows as $row) { + if ((int) ($row['units_counted'] ?? 0) <= 0) { + continue; + } + + $insert->execute([ + 'appointment_id' => $appointmentId, + 'requirement_key' => $row['requirement_key'] ?? null, + 'label' => $row['label'] ?? null, + 'units_counted' => (int) $row['units_counted'], + 'created_at' => $this->now(), + ]); + } + } +} diff --git a/www/fahrschultermin.de/app/Repositories/BaseRepository.php b/www/fahrschultermin.de/app/Repositories/BaseRepository.php new file mode 100644 index 0000000..6d46ea8 --- /dev/null +++ b/www/fahrschultermin.de/app/Repositories/BaseRepository.php @@ -0,0 +1,23 @@ +db = Database::connection(); + } + + protected function now(): string + { + return gmdate('c'); + } +} diff --git a/www/fahrschultermin.de/app/Repositories/InstructorRepository.php b/www/fahrschultermin.de/app/Repositories/InstructorRepository.php new file mode 100644 index 0000000..7b13a06 --- /dev/null +++ b/www/fahrschultermin.de/app/Repositories/InstructorRepository.php @@ -0,0 +1,82 @@ +db->prepare('SELECT * FROM instructors WHERE tenant_id = :tenant_id ORDER BY last_name, first_name'); + $statement->execute(['tenant_id' => $tenantId]); + + return $statement->fetchAll(); + } + + public function findByUserId(int $tenantId, int $userId): ?array + { + $statement = $this->db->prepare( + 'SELECT * FROM instructors WHERE tenant_id = :tenant_id AND user_id = :user_id LIMIT 1' + ); + $statement->execute(['tenant_id' => $tenantId, 'user_id' => $userId]); + + return $statement->fetch() ?: null; + } + + public function create(int $tenantId, array $data): array + { + $timestamp = $this->now(); + $statement = $this->db->prepare( + 'INSERT INTO instructors + (tenant_id, user_id, first_name, last_name, color, notes, is_active, pre_start_buffer_minutes, created_at, updated_at) + VALUES (:tenant_id, :user_id, :first_name, :last_name, :color, :notes, :is_active, :pre_start_buffer_minutes, :created_at, :updated_at)' + ); + $statement->execute([ + 'tenant_id' => $tenantId, + 'user_id' => $data['user_id'] ?? null, + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'], + 'color' => $data['color'], + 'notes' => $data['notes'] ?? '', + 'is_active' => !empty($data['is_active']) ? 1 : 0, + 'pre_start_buffer_minutes' => max(0, (int) ($data['pre_start_buffer_minutes'] ?? 0)), + 'created_at' => $timestamp, + 'updated_at' => $timestamp, + ]); + + return $this->find($tenantId, (int) $this->db->lastInsertId()); + } + + public function update(int $tenantId, int $id, array $data): ?array + { + $statement = $this->db->prepare( + 'UPDATE instructors + SET user_id = :user_id, first_name = :first_name, last_name = :last_name, color = :color, + notes = :notes, is_active = :is_active, pre_start_buffer_minutes = :pre_start_buffer_minutes, updated_at = :updated_at + WHERE tenant_id = :tenant_id AND id = :id' + ); + $statement->execute([ + 'tenant_id' => $tenantId, + 'id' => $id, + 'user_id' => $data['user_id'] ?? null, + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'], + 'color' => $data['color'], + 'notes' => $data['notes'] ?? '', + 'is_active' => !empty($data['is_active']) ? 1 : 0, + 'pre_start_buffer_minutes' => max(0, (int) ($data['pre_start_buffer_minutes'] ?? 0)), + 'updated_at' => $this->now(), + ]); + + return $this->find($tenantId, $id); + } + + public function find(int $tenantId, int $id): ?array + { + $statement = $this->db->prepare('SELECT * FROM instructors WHERE tenant_id = :tenant_id AND id = :id'); + $statement->execute(['tenant_id' => $tenantId, 'id' => $id]); + + return $statement->fetch() ?: null; + } +} diff --git a/www/fahrschultermin.de/app/Repositories/ReferenceDataRepository.php b/www/fahrschultermin.de/app/Repositories/ReferenceDataRepository.php new file mode 100644 index 0000000..e1d72f0 --- /dev/null +++ b/www/fahrschultermin.de/app/Repositories/ReferenceDataRepository.php @@ -0,0 +1,515 @@ + '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->db->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->db->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, + ]); + } + } +} diff --git a/www/fahrschultermin.de/app/Repositories/StudentRepository.php b/www/fahrschultermin.de/app/Repositories/StudentRepository.php new file mode 100644 index 0000000..4ad7894 --- /dev/null +++ b/www/fahrschultermin.de/app/Repositories/StudentRepository.php @@ -0,0 +1,294 @@ +db->prepare( + 'SELECT s.*, t.code AS template_code, t.name AS template_name, t.is_combination AS template_is_combination + FROM students s + JOIN license_class_templates t ON t.id = s.template_id + WHERE s.tenant_id = :tenant_id + ORDER BY s.last_name, s.first_name' + ); + $statement->execute(['tenant_id' => $tenantId]); + + return $statement->fetchAll(); + } + + public function find(int $tenantId, int $id): ?array + { + $statement = $this->db->prepare( + 'SELECT s.*, t.code AS template_code, t.name AS template_name, t.is_combination AS template_is_combination + FROM students s + JOIN license_class_templates t ON t.id = s.template_id + WHERE s.tenant_id = :tenant_id AND s.id = :id' + ); + $statement->execute(['tenant_id' => $tenantId, 'id' => $id]); + $student = $statement->fetch() ?: null; + + if ($student !== null) { + $student['requirements'] = $this->requirements($id); + } + + return $student; + } + + public function create(int $tenantId, array $data): array + { + $timestamp = $this->now(); + $statement = $this->db->prepare( + 'INSERT INTO students + (tenant_id, template_id, first_name, last_name, phone, email, needs_glasses, notes, status, created_at, updated_at) + VALUES (:tenant_id, :template_id, :first_name, :last_name, :phone, :email, :needs_glasses, :notes, :status, :created_at, :updated_at)' + ); + $statement->execute([ + 'tenant_id' => $tenantId, + 'template_id' => $data['template_id'], + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'], + 'phone' => $data['phone'] ?? '', + 'email' => $data['email'] ?? '', + 'needs_glasses' => !empty($data['needs_glasses']) ? 1 : 0, + 'notes' => $data['notes'] ?? '', + 'status' => $data['status'] ?? 'active', + 'created_at' => $timestamp, + 'updated_at' => $timestamp, + ]); + + $studentId = (int) $this->db->lastInsertId(); + $this->snapshotRequirements($studentId, (int) $data['template_id']); + + return $this->find($tenantId, $studentId); + } + + public function update(int $tenantId, int $id, array $data): ?array + { + $statement = $this->db->prepare( + 'UPDATE students + SET template_id = :template_id, first_name = :first_name, last_name = :last_name, phone = :phone, email = :email, needs_glasses = :needs_glasses, + notes = :notes, status = :status, updated_at = :updated_at + WHERE tenant_id = :tenant_id AND id = :id' + ); + $statement->execute([ + 'tenant_id' => $tenantId, + 'id' => $id, + 'template_id' => $data['template_id'], + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'], + 'phone' => $data['phone'] ?? '', + 'email' => $data['email'] ?? '', + 'needs_glasses' => !empty($data['needs_glasses']) ? 1 : 0, + 'notes' => $data['notes'] ?? '', + 'status' => $data['status'] ?? 'active', + 'updated_at' => $this->now(), + ]); + + return $this->find($tenantId, $id); + } + + public function delete(int $tenantId, int $id): bool + { + $student = $this->find($tenantId, $id); + if ($student === null) { + return false; + } + + $this->db->beginTransaction(); + try { + $updateAppointments = $this->db->prepare( + 'UPDATE appointments SET student_id = NULL, updated_at = :updated_at WHERE tenant_id = :tenant_id AND student_id = :student_id' + ); + $updateAppointments->execute([ + 'tenant_id' => $tenantId, + 'student_id' => $id, + 'updated_at' => $this->now(), + ]); + + $deleteSnapshots = $this->db->prepare('DELETE FROM student_requirement_snapshots WHERE student_id = :student_id'); + $deleteSnapshots->execute(['student_id' => $id]); + + $deleteStudent = $this->db->prepare('DELETE FROM students WHERE tenant_id = :tenant_id AND id = :id'); + $deleteStudent->execute(['tenant_id' => $tenantId, 'id' => $id]); + + $this->db->commit(); + return true; + } catch (\Throwable $exception) { + $this->db->rollBack(); + throw $exception; + } + } + + public function requirements(int $studentId): array + { + $this->syncRequirementSnapshots($studentId); + + $statement = $this->db->prepare( + 'SELECT * FROM student_requirement_snapshots WHERE student_id = :student_id ORDER BY sort_order, phase_label, label' + ); + $statement->execute(['student_id' => $studentId]); + + return $statement->fetchAll(); + } + + public function updatePriorCompletedUnits(int $tenantId, int $studentId, array $rows): ?array + { + $student = $this->find($tenantId, $studentId); + if ($student === null) { + return null; + } + + $statement = $this->db->prepare( + 'UPDATE student_requirement_snapshots + SET prior_completed_units = :prior_completed_units + WHERE student_id = :student_id AND requirement_key = :requirement_key' + ); + + foreach ($rows as $row) { + $statement->execute([ + 'student_id' => $studentId, + 'requirement_key' => (string) ($row['requirement_key'] ?? ''), + 'prior_completed_units' => max(0, (int) ($row['prior_completed_units'] ?? 0)), + ]); + } + + return $this->find($tenantId, $studentId); + } + + private function snapshotRequirements(int $studentId, int $templateId): void + { + $this->syncRequirementSnapshots($studentId, $templateId); + } + + private function syncRequirementSnapshots(int $studentId, ?int $templateId = null): void + { + if ($templateId === null) { + $student = $this->db->prepare('SELECT template_id FROM students WHERE id = :student_id'); + $student->execute(['student_id' => $studentId]); + $templateId = (int) ($student->fetch()['template_id'] ?? 0); + } + + if ($templateId <= 0) { + return; + } + + $requirements = $this->db->prepare( + 'SELECT requirement_key, label, required_units, sort_order, phase_label + FROM license_template_requirements WHERE template_id = :template_id' + ); + $requirements->execute(['template_id' => $templateId]); + $rows = []; + + foreach ($requirements->fetchAll() as $row) { + $rows[$row['requirement_key']] = $row; + } + $hasPhasedRequirements = array_reduce( + $rows, + static fn (bool $carry, array $row): bool => $carry || trim((string) ($row['phase_label'] ?? '')) !== '', + false + ); + + $lessonTypes = $this->db->prepare( + 'SELECT COALESCE(NULLIF(TRIM(lt.requirement_key), \'\'), \'lesson_type_\' || lt.id) AS requirement_key, + lt.name AS label, + lt.sort_order + FROM lesson_types lt + JOIN lesson_type_template_visibility vis ON vis.lesson_type_id = lt.id + WHERE vis.template_id = :template_id + AND lt.category = \'student\' + AND lt.is_counted = 1 + ORDER BY lt.sort_order, lt.name' + ); + $lessonTypes->execute(['template_id' => $templateId]); + + foreach ($lessonTypes->fetchAll() as $row) { + if (isset($rows[$row['requirement_key']])) { + continue; + } + + if ($hasPhasedRequirements) { + $hasMatchingPhasedRequirement = false; + foreach ($rows as $existingRow) { + $phaseLabel = trim((string) ($existingRow['phase_label'] ?? '')); + if ($phaseLabel === '') { + continue; + } + + if (str_ends_with((string) $existingRow['requirement_key'], '::' . $row['requirement_key'])) { + $hasMatchingPhasedRequirement = true; + break; + } + } + + if ($hasMatchingPhasedRequirement) { + continue; + } + } + + $rows[$row['requirement_key']] = [ + 'requirement_key' => $row['requirement_key'], + 'label' => $row['label'], + 'required_units' => 0, + 'sort_order' => $row['sort_order'], + ]; + } + + $insert = $this->db->prepare( + 'INSERT INTO student_requirement_snapshots + (student_id, requirement_key, label, required_units, prior_completed_units, sort_order, phase_label) + SELECT :student_id, :requirement_key, :label, :required_units, 0, :sort_order, :phase_label + WHERE NOT EXISTS ( + SELECT 1 FROM student_requirement_snapshots + WHERE student_id = :student_id AND requirement_key = :requirement_key + )' + ); + $update = $this->db->prepare( + 'UPDATE student_requirement_snapshots + SET label = :label, + required_units = :required_units, + sort_order = :sort_order, + phase_label = :phase_label + WHERE student_id = :student_id AND requirement_key = :requirement_key' + ); + + foreach ($rows as $row) { + $insert->execute([ + 'student_id' => $studentId, + 'requirement_key' => $row['requirement_key'], + 'label' => $row['label'], + 'required_units' => $row['required_units'], + 'sort_order' => $row['sort_order'], + 'phase_label' => $row['phase_label'] ?? '', + ]); + $update->execute([ + 'student_id' => $studentId, + 'requirement_key' => $row['requirement_key'], + 'label' => $row['label'], + 'required_units' => $row['required_units'], + 'sort_order' => $row['sort_order'], + 'phase_label' => $row['phase_label'] ?? '', + ]); + } + + if ($rows !== []) { + $placeholders = implode(', ', array_fill(0, count($rows), '?')); + $delete = $this->db->prepare( + "DELETE FROM student_requirement_snapshots + WHERE student_id = ? + AND requirement_key NOT IN ($placeholders) + AND prior_completed_units = 0 + AND NOT EXISTS ( + SELECT 1 FROM appointment_units au + JOIN appointments a ON a.id = au.appointment_id + WHERE a.student_id = student_requirement_snapshots.student_id + AND au.requirement_key = student_requirement_snapshots.requirement_key + )" + ); + $delete->execute(array_merge([$studentId], array_keys($rows))); + } + } +} diff --git a/www/fahrschultermin.de/app/Repositories/TenantRepository.php b/www/fahrschultermin.de/app/Repositories/TenantRepository.php new file mode 100644 index 0000000..3c71a02 --- /dev/null +++ b/www/fahrschultermin.de/app/Repositories/TenantRepository.php @@ -0,0 +1,86 @@ +db->query('SELECT * FROM tenants ORDER BY name')->fetchAll(); + } + + public function find(int $id): ?array + { + $statement = $this->db->prepare('SELECT * FROM tenants WHERE id = :id'); + $statement->execute(['id' => $id]); + + return $statement->fetch() ?: null; + } + + public function create(array $data): array + { + $timestamp = $this->now(); + $statement = $this->db->prepare( + 'INSERT INTO tenants (name, timezone, federal_state, location_label, latitude, longitude, is_active, created_at, updated_at) + VALUES (:name, :timezone, :federal_state, :location_label, :latitude, :longitude, :is_active, :created_at, :updated_at)' + ); + $statement->execute([ + 'name' => $data['name'], + 'timezone' => $data['timezone'] ?? 'Europe/Berlin', + 'federal_state' => $data['federal_state'] ?? 'BE', + 'location_label' => $data['location_label'] ?? '', + 'latitude' => $data['latitude'] !== '' ? $data['latitude'] : null, + 'longitude' => $data['longitude'] !== '' ? $data['longitude'] : null, + 'is_active' => !empty($data['is_active']) ? 1 : 0, + 'created_at' => $timestamp, + 'updated_at' => $timestamp, + ]); + + return $this->find((int) $this->db->lastInsertId()); + } + + public function update(int $id, array $data): ?array + { + $statement = $this->db->prepare( + 'UPDATE tenants + SET name = :name, timezone = :timezone, federal_state = :federal_state, location_label = :location_label, + latitude = :latitude, longitude = :longitude, is_active = :is_active, updated_at = :updated_at + WHERE id = :id' + ); + $statement->execute([ + 'id' => $id, + 'name' => $data['name'], + 'timezone' => $data['timezone'], + 'federal_state' => $data['federal_state'] ?? 'BE', + 'location_label' => $data['location_label'] ?? '', + 'latitude' => $data['latitude'] !== '' ? $data['latitude'] : null, + 'longitude' => $data['longitude'] !== '' ? $data['longitude'] : null, + 'is_active' => !empty($data['is_active']) ? 1 : 0, + 'updated_at' => $this->now(), + ]); + + return $this->find($id); + } + + public function updateForTenantAdmin(int $id, array $data): ?array + { + $current = $this->find($id); + if ($current === null) { + return null; + } + + return $this->update($id, [ + 'name' => $data['name'] ?? $current['name'], + 'timezone' => $data['timezone'] ?? $current['timezone'], + 'federal_state' => $data['federal_state'] ?? ($current['federal_state'] ?? 'BE'), + 'location_label' => $data['location_label'] ?? ($current['location_label'] ?? ''), + 'latitude' => array_key_exists('latitude', $data) ? $data['latitude'] : ($current['latitude'] ?? null), + 'longitude' => array_key_exists('longitude', $data) ? $data['longitude'] : ($current['longitude'] ?? null), + 'is_active' => $current['is_active'], + ]); + } +} diff --git a/www/fahrschultermin.de/app/Repositories/UserRepository.php b/www/fahrschultermin.de/app/Repositories/UserRepository.php new file mode 100644 index 0000000..9e0664d --- /dev/null +++ b/www/fahrschultermin.de/app/Repositories/UserRepository.php @@ -0,0 +1,112 @@ +db->prepare( + 'SELECT id, tenant_id, role, email, first_name, last_name, is_active, created_at, updated_at + FROM users WHERE tenant_id = :tenant_id ORDER BY role, last_name, first_name' + ); + $statement->execute(['tenant_id' => $tenantId]); + + return $statement->fetchAll(); + } + + public function allUsers(): array + { + return $this->db->query( + 'SELECT u.id, u.tenant_id, u.role, u.email, u.first_name, u.last_name, u.is_active, + u.created_at, u.updated_at, t.name AS tenant_name + FROM users u + LEFT JOIN tenants t ON t.id = u.tenant_id + ORDER BY COALESCE(t.name, "System"), u.role, u.last_name, u.first_name' + )->fetchAll(); + } + + public function findByEmail(string $email): ?array + { + $statement = $this->db->prepare( + 'SELECT u.*, t.name AS tenant_name, t.timezone AS tenant_timezone, t.is_active AS tenant_is_active + FROM users u + LEFT JOIN tenants t ON t.id = u.tenant_id + WHERE u.email = :email' + ); + $statement->execute(['email' => $email]); + + return $statement->fetch() ?: null; + } + + public function findById(int $id): ?array + { + $statement = $this->db->prepare( + 'SELECT u.*, t.name AS tenant_name, t.timezone AS tenant_timezone, t.is_active AS tenant_is_active + FROM users u + LEFT JOIN tenants t ON t.id = u.tenant_id + WHERE u.id = :id' + ); + $statement->execute(['id' => $id]); + + return $statement->fetch() ?: null; + } + + public function create(array $data): array + { + $timestamp = $this->now(); + $statement = $this->db->prepare( + 'INSERT INTO users + (tenant_id, role, email, password_hash, first_name, last_name, is_active, created_at, updated_at) + VALUES (:tenant_id, :role, :email, :password_hash, :first_name, :last_name, :is_active, :created_at, :updated_at)' + ); + $statement->execute([ + 'tenant_id' => $data['tenant_id'], + 'role' => $data['role'], + 'email' => $data['email'], + 'password_hash' => password_hash($data['password'], PASSWORD_DEFAULT), + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'], + 'is_active' => !empty($data['is_active']) ? 1 : 0, + 'created_at' => $timestamp, + 'updated_at' => $timestamp, + ]); + + return $this->findById((int) $this->db->lastInsertId()); + } + + public function update(int $id, array $data): ?array + { + $current = $this->findById($id); + if ($current === null) { + return null; + } + + $passwordHash = $current['password_hash']; + if (!empty($data['password'])) { + $passwordHash = password_hash($data['password'], PASSWORD_DEFAULT); + } + + $statement = $this->db->prepare( + 'UPDATE users + SET tenant_id = :tenant_id, role = :role, email = :email, password_hash = :password_hash, + first_name = :first_name, last_name = :last_name, is_active = :is_active, updated_at = :updated_at + WHERE id = :id' + ); + $statement->execute([ + 'id' => $id, + 'tenant_id' => $data['tenant_id'], + 'role' => $data['role'], + 'email' => $data['email'], + 'password_hash' => $passwordHash, + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'], + 'is_active' => !empty($data['is_active']) ? 1 : 0, + 'updated_at' => $this->now(), + ]); + + return $this->findById($id); + } +} diff --git a/www/fahrschultermin.de/app/Services/CalendarService.php b/www/fahrschultermin.de/app/Services/CalendarService.php new file mode 100644 index 0000000..76fcbd0 --- /dev/null +++ b/www/fahrschultermin.de/app/Services/CalendarService.php @@ -0,0 +1,93 @@ +findLessonType($tenantId, (int) $payload['lesson_type_id']); + $warnings = [ + 'conflicts' => [], + 'rest_period_warnings' => [], + ]; + + $conflicts = $appointmentRepository->overlappingForInstructor( + $tenantId, + (int) $payload['instructor_id'], + $payload['start_at'], + $payload['end_at'], + $appointmentId + ); + + foreach ($conflicts as $conflict) { + $warnings['conflicts'][] = [ + 'type' => 'instructor_overlap', + 'message' => 'Fahrlehrer ist in diesem Zeitraum bereits verplant.', + 'appointment_id' => (int) $conflict['id'], + ]; + } + + if (!empty($payload['student_id'])) { + $studentConflicts = $appointmentRepository->overlappingForStudent( + $tenantId, + (int) $payload['student_id'], + $payload['start_at'], + $payload['end_at'], + $appointmentId + ); + foreach ($studentConflicts as $conflict) { + $warnings['conflicts'][] = [ + 'type' => 'student_overlap', + 'message' => 'Fahrschueler ist in diesem Zeitraum bereits verplant.', + 'appointment_id' => (int) $conflict['id'], + ]; + } + } + + if ($lessonType !== null && (int) $lessonType['is_rest_relevant'] === 1) { + $newStart = new DateTimeImmutable($payload['start_at']); + $dayStart = $newStart->setTime(0, 0, 0); + $dayEnd = $dayStart->modify('+1 day'); + $hasLaterRelevantOnDay = $appointmentRepository->hasRestRelevantAfterStartOnDay( + $tenantId, + (int) $payload['instructor_id'], + $dayStart->format(DATE_ATOM), + $dayEnd->format(DATE_ATOM), + $payload['start_at'] + ); + + if ($hasLaterRelevantOnDay) { + return $warnings; + } + + $lastRelevant = $appointmentRepository->lastRestRelevantForInstructor( + $tenantId, + (int) $payload['instructor_id'], + $payload['start_at'], + $appointmentId + ); + if ($lastRelevant !== null) { + $restEnds = (new DateTimeImmutable($lastRelevant['end_at']))->add(new DateInterval('PT11H')); + if ($newStart < $restEnds) { + $warnings['rest_period_warnings'][] = [ + 'type' => 'rest_period', + 'message' => 'Achtung: Dieser Termin liegt innerhalb der 11-Stunden-Ruhezeit.', + 'rest_until' => $restEnds->format(DATE_ATOM), + 'blocking_appointment_id' => (int) $lastRelevant['id'], + ]; + } + } + } + + return $warnings; + } +} diff --git a/www/fahrschultermin.de/app/Services/HolidayService.php b/www/fahrschultermin.de/app/Services/HolidayService.php new file mode 100644 index 0000000..1b6d66b --- /dev/null +++ b/www/fahrschultermin.de/app/Services/HolidayService.php @@ -0,0 +1,109 @@ +setTimezone($timezone)->setTime(0, 0); + $end = (new DateTimeImmutable($to, new DateTimeZone('UTC')))->setTimezone($timezone)->setTime(0, 0); + + $years = []; + $day = $start; + while ($day <= $end) { + $years[(int) $day->format('Y')] = true; + $day = $day->add(new DateInterval('P1D')); + } + + $holidaysByDate = []; + foreach (array_keys($years) as $year) { + foreach ($this->holidaysForYear((int) $year, $state, $timezone) as $date => $name) { + $holidaysByDate[$date] = $name; + } + } + + $result = []; + $day = $start; + while ($day < $end) { + $key = $day->format('Y-m-d'); + $result[$key] = [ + 'isSunday' => (int) $day->format('N') === 7, + 'holidayName' => $holidaysByDate[$key] ?? null, + ]; + $day = $day->add(new DateInterval('P1D')); + } + + return $result; + } + + private function holidaysForYear(int $year, string $state, DateTimeZone $timezone): array + { + $easter = (new DateTimeImmutable('@' . (string) easter_date($year)))->setTimezone($timezone)->setTime(0, 0); + $holidays = [ + $this->dateKey($year, 1, 1, $timezone) => 'Neujahr', + $easter->sub(new DateInterval('P2D'))->format('Y-m-d') => 'Karfreitag', + $easter->add(new DateInterval('P1D'))->format('Y-m-d') => 'Ostermontag', + $this->dateKey($year, 5, 1, $timezone) => 'Tag der Arbeit', + $easter->add(new DateInterval('P39D'))->format('Y-m-d') => 'Christi Himmelfahrt', + $easter->add(new DateInterval('P50D'))->format('Y-m-d') => 'Pfingstmontag', + $this->dateKey($year, 10, 3, $timezone) => 'Tag der Deutschen Einheit', + $this->dateKey($year, 12, 25, $timezone) => '1. Weihnachtstag', + $this->dateKey($year, 12, 26, $timezone) => '2. Weihnachtstag', + ]; + + if (in_array($state, self::STATES_WITH_EPIPHANY, true)) { + $holidays[$this->dateKey($year, 1, 6, $timezone)] = 'Heilige Drei Koenige'; + } + if (in_array($state, self::STATES_WITH_WOMENS_DAY, true)) { + $holidays[$this->dateKey($year, 3, 8, $timezone)] = 'Internationaler Frauentag'; + } + if (in_array($state, self::STATES_WITH_CORPUS_CHRISTI, true)) { + $holidays[$easter->add(new DateInterval('P60D'))->format('Y-m-d')] = 'Fronleichnam'; + } + if (in_array($state, self::STATES_WITH_ASSUMPTION, true)) { + $holidays[$this->dateKey($year, 8, 15, $timezone)] = 'Mariae Himmelfahrt'; + } + if (in_array($state, self::STATES_WITH_REFORMATION, true)) { + $holidays[$this->dateKey($year, 10, 31, $timezone)] = 'Reformationstag'; + } + if (in_array($state, self::STATES_WITH_ALL_SAINTS, true)) { + $holidays[$this->dateKey($year, 11, 1, $timezone)] = 'Allerheiligen'; + } + if (in_array($state, self::STATES_WITH_REPENTANCE, true)) { + $holidays[$this->repentanceDay($year, $timezone)->format('Y-m-d')] = 'Buss- und Bettag'; + } + + return $holidays; + } + + private function dateKey(int $year, int $month, int $day, DateTimeZone $timezone): string + { + return (new DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $day), $timezone))->format('Y-m-d'); + } + + private function repentanceDay(int $year, DateTimeZone $timezone): DateTimeImmutable + { + $day = new DateTimeImmutable(sprintf('%04d-11-23', $year), $timezone); + while ((int) $day->format('N') !== 3) { + $day = $day->sub(new DateInterval('P1D')); + } + return $day; + } +} diff --git a/www/fahrschultermin.de/app/Services/RequirementService.php b/www/fahrschultermin.de/app/Services/RequirementService.php new file mode 100644 index 0000000..fa646fc --- /dev/null +++ b/www/fahrschultermin.de/app/Services/RequirementService.php @@ -0,0 +1,56 @@ +requirements($studentId); + + $statement = $db->prepare( + 'SELECT au.requirement_key, + SUM(CASE WHEN a.status = "planned" THEN au.units_counted ELSE 0 END) AS planned_units, + SUM(CASE WHEN a.status = "completed" THEN au.units_counted ELSE 0 END) AS completed_units + FROM appointment_units au + JOIN appointments a ON a.id = au.appointment_id + WHERE a.student_id = :student_id + GROUP BY au.requirement_key' + ); + $statement->execute(['student_id' => $studentId]); + $totals = []; + foreach ($statement->fetchAll() as $row) { + $totals[$row['requirement_key']] = [ + 'planned' => (int) $row['planned_units'], + 'completed' => (int) $row['completed_units'], + ]; + } + + return array_map(static function (array $snapshot) use ($totals): array { + $row = $totals[$snapshot['requirement_key']] ?? ['planned' => 0, 'completed' => 0]; + $priorCompleted = (int) ($snapshot['prior_completed_units'] ?? 0); + $appointmentCompleted = (int) $row['completed']; + $completed = $priorCompleted + $appointmentCompleted; + $open = (int) $snapshot['required_units'] - $row['planned'] - $completed; + + return [ + 'requirement_key' => $snapshot['requirement_key'], + 'phase_label' => $snapshot['phase_label'] ?? '', + 'label' => $snapshot['label'], + 'required_units' => (int) $snapshot['required_units'], + 'planned_units' => $row['planned'], + 'completed_units' => $completed, + 'prior_completed_units' => $priorCompleted, + 'appointment_completed_units' => $appointmentCompleted, + 'open_units' => $open, + ]; + }, $snapshots); + } +} diff --git a/www/fahrschultermin.de/app/Services/SunTimesService.php b/www/fahrschultermin.de/app/Services/SunTimesService.php new file mode 100644 index 0000000..041fef0 --- /dev/null +++ b/www/fahrschultermin.de/app/Services/SunTimesService.php @@ -0,0 +1,55 @@ +setTimezone($timezone)->setTime(0, 0); + $lastDay = $end->setTimezone($timezone)->setTime(0, 0); + + $result = []; + while ($day < $lastDay) { + $solar = date_sun_info($day->setTime(12, 0)->getTimestamp(), $latitude, $longitude); + $key = $day->format('Y-m-d'); + + $result[$key] = [ + 'sunrise' => $this->formatSolarTimestamp($solar['sunrise'] ?? false, $timezone), + 'sunset' => $this->formatSolarTimestamp($solar['sunset'] ?? false, $timezone), + ]; + + $day = $day->add(new DateInterval('P1D')); + } + + return $result; + } + + private function formatSolarTimestamp(int|float|bool $timestamp, DateTimeZone $timezone): ?string + { + if (!is_int($timestamp) && !is_float($timestamp)) { + return null; + } + + return (new DateTimeImmutable('@' . (string) (int) $timestamp)) + ->setTimezone($timezone) + ->format('H:i'); + } +} diff --git a/www/fahrschultermin.de/app/Support/Auth.php b/www/fahrschultermin.de/app/Support/Auth.php new file mode 100644 index 0000000..488c54f --- /dev/null +++ b/www/fahrschultermin.de/app/Support/Auth.php @@ -0,0 +1,42 @@ +findById((int) $userId); + } + + public static function requireUser(): array + { + $user = self::user(); + if ($user === null) { + Response::json(['message' => 'Unauthenticated'], 401); + } + + return $user; + } + + public static function requireRole(array|string $roles): array + { + $user = self::requireUser(); + $roles = (array) $roles; + + if (!in_array($user['role'], $roles, true)) { + Response::json(['message' => 'Forbidden'], 403); + } + + return $user; + } +} diff --git a/www/fahrschultermin.de/app/Support/Database.php b/www/fahrschultermin.de/app/Support/Database.php new file mode 100644 index 0000000..c895c52 --- /dev/null +++ b/www/fahrschultermin.de/app/Support/Database.php @@ -0,0 +1,37 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + self::$connection->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + self::$connection->exec('PRAGMA foreign_keys = ON'); + + return self::$connection; + } +} diff --git a/www/fahrschultermin.de/app/Support/Request.php b/www/fahrschultermin.de/app/Support/Request.php new file mode 100644 index 0000000..ca66565 --- /dev/null +++ b/www/fahrschultermin.de/app/Support/Request.php @@ -0,0 +1,40 @@ +map('GET', $path, $handler); + } + + public function post(string $path, callable $handler): void + { + $this->map('POST', $path, $handler); + } + + public function patch(string $path, callable $handler): void + { + $this->map('PATCH', $path, $handler); + } + + public function delete(string $path, callable $handler): void + { + $this->map('DELETE', $path, $handler); + } + + public function dispatch(): void + { + $method = $this->request->method(); + $path = $this->request->path(); + + foreach ($this->routes[$method] ?? [] as $route) { + $pattern = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>[^/]+)', $route['path']); + $pattern = '#^' . $pattern . '$#'; + + if (!preg_match($pattern, $path, $matches)) { + continue; + } + + $params = array_filter($matches, static fn (string|int $key): bool => is_string($key), ARRAY_FILTER_USE_KEY); + $route['handler']($this->request, $params); + return; + } + + Response::json(['message' => 'Route not found', 'path' => $path], 404); + } + + private function map(string $method, string $path, callable $handler): void + { + $this->routes[$method][] = [ + 'path' => rtrim($path, '/') ?: '/', + 'handler' => $handler, + ]; + } +} diff --git a/www/fahrschultermin.de/app/bootstrap.php b/www/fahrschultermin.de/app/bootstrap.php new file mode 100644 index 0000000..044e7f1 --- /dev/null +++ b/www/fahrschultermin.de/app/bootstrap.php @@ -0,0 +1,44 @@ + $config, + 'router' => new Router(new Request()), +]; diff --git a/www/fahrschultermin.de/bin/migrate.php b/www/fahrschultermin.de/bin/migrate.php new file mode 100644 index 0000000..33bc9d5 --- /dev/null +++ b/www/fahrschultermin.de/bin/migrate.php @@ -0,0 +1,27 @@ +query('PRAGMA table_info(' . $matches[1] . ')')->fetchAll(); + $columnNames = array_map(static fn (array $column): string => strtolower((string) $column['name']), $columns); + if (in_array(strtolower($matches[2]), $columnNames, true)) { + continue; + } + } + + $db->exec($statement); + } + fwrite(STDOUT, "Applied: " . basename($migration) . PHP_EOL); +} diff --git a/www/fahrschultermin.de/bin/seed.php b/www/fahrschultermin.de/bin/seed.php new file mode 100644 index 0000000..38babcb --- /dev/null +++ b/www/fahrschultermin.de/bin/seed.php @@ -0,0 +1,208 @@ +query('SELECT COUNT(*) AS count FROM users')->fetch(); +if ((int) $existing['count'] > 0) { + fwrite(STDOUT, "Seed skipped: data already exists." . PHP_EOL); + exit(0); +} + +$tenant = $tenantRepository->create([ + 'name' => 'Fahrschule Nordring', + 'timezone' => 'Europe/Berlin', + 'is_active' => true, +]); + +$db->prepare('INSERT INTO settings (tenant_id, key_name, value) VALUES (:tenant_id, :key_name, :value)') + ->execute([ + 'tenant_id' => $tenant['id'], + 'key_name' => 'app.theme', + 'value' => 'signal', + ]); + +$superadmin = $userRepository->create([ + 'tenant_id' => null, + 'role' => 'superadmin', + 'email' => 'superadmin@example.com', + 'password' => 'secret123', + 'first_name' => 'System', + 'last_name' => 'Admin', + 'is_active' => true, +]); + +$tenantAdmin = $userRepository->create([ + 'tenant_id' => $tenant['id'], + 'role' => 'tenant_admin', + 'email' => 'admin@nordring.test', + 'password' => 'secret123', + 'first_name' => 'Jana', + 'last_name' => 'Koester', + 'is_active' => true, +]); + +$dispatcher = $userRepository->create([ + 'tenant_id' => $tenant['id'], + 'role' => 'dispatcher', + 'email' => 'planung@nordring.test', + 'password' => 'secret123', + 'first_name' => 'Mara', + 'last_name' => 'Dispo', + 'is_active' => true, +]); + +$instructorUser = $userRepository->create([ + 'tenant_id' => $tenant['id'], + 'role' => 'instructor', + 'email' => 'fahrlehrer@nordring.test', + 'password' => 'secret123', + 'first_name' => 'Tobias', + 'last_name' => 'Fahr', + 'is_active' => true, +]); + +$instructors = [ + ['user_id' => $instructorUser['id'], 'first_name' => 'Tobias', 'last_name' => 'Fahr', 'color' => '#0E5D80'], + ['user_id' => null, 'first_name' => 'Leonie', 'last_name' => 'Kraft', 'color' => '#A74826'], +]; + +foreach ($instructors as $instructor) { + $instructorRepository->create($tenant['id'], $instructor + ['notes' => '', 'is_active' => true]); +} + +$lessonTypes = [ + ['name' => 'Einweisung', 'color' => '#0E5D80', 'default_duration' => 45, 'category' => 'student', 'requirement_key' => 'intro', 'is_billable' => 1, 'is_counted' => 1, 'is_rest_relevant' => 1, 'fixed_duration' => 0, 'sort_order' => 1], + ['name' => 'Uebungsstunde', 'color' => '#2C7A4B', 'default_duration' => 45, 'category' => 'student', 'requirement_key' => 'practice', 'is_billable' => 1, 'is_counted' => 1, 'is_rest_relevant' => 1, 'fixed_duration' => 0, 'sort_order' => 2], + ['name' => 'Bundes- / Landstrasse', 'color' => '#D99A1A', 'default_duration' => 45, 'category' => 'student', 'requirement_key' => 'rural', 'is_billable' => 1, 'is_counted' => 1, 'is_rest_relevant' => 1, 'fixed_duration' => 0, 'sort_order' => 3], + ['name' => 'Autobahn', 'color' => '#C86C1E', 'default_duration' => 45, 'category' => 'student', 'requirement_key' => 'highway', 'is_billable' => 1, 'is_counted' => 1, 'is_rest_relevant' => 1, 'fixed_duration' => 0, 'sort_order' => 4], + ['name' => 'Daemmerung / Dunkelheit', 'color' => '#4B356B', 'default_duration' => 45, 'category' => 'student', 'requirement_key' => 'night', 'is_billable' => 1, 'is_counted' => 1, 'is_rest_relevant' => 1, 'fixed_duration' => 0, 'sort_order' => 5], + ['name' => 'Pruefungsfahrt', 'color' => '#A83333', 'default_duration' => 70, 'category' => 'student', 'requirement_key' => null, 'is_billable' => 1, 'is_counted' => 0, 'is_rest_relevant' => 1, 'fixed_duration' => 1, 'sort_order' => 6], + ['name' => 'Theorieunterricht', 'color' => '#2B8793', 'default_duration' => 90, 'category' => 'other', 'requirement_key' => null, 'is_billable' => 0, 'is_counted' => 0, 'is_rest_relevant' => 0, 'fixed_duration' => 1, 'sort_order' => 7], + ['name' => 'Sonstige Arbeiten', 'color' => '#70747C', 'default_duration' => 60, 'category' => 'other', 'requirement_key' => null, 'is_billable' => 0, 'is_counted' => 0, 'is_rest_relevant' => 0, 'fixed_duration' => 1, 'sort_order' => 8], + ['name' => 'Privater Termin', 'color' => '#44444A', 'default_duration' => 60, 'category' => 'private', 'requirement_key' => null, 'is_billable' => 0, 'is_counted' => 0, 'is_rest_relevant' => 0, 'fixed_duration' => 1, 'sort_order' => 9], + ['name' => 'Sperrzeit / Blockierung', 'color' => '#111111', 'default_duration' => 60, 'category' => 'blocked', 'requirement_key' => null, 'is_billable' => 0, 'is_counted' => 0, 'is_rest_relevant' => 0, 'fixed_duration' => 1, 'sort_order' => 10], +]; + +foreach ($lessonTypes as $lessonType) { + $referenceRepository->createLessonType($tenant['id'], $lessonType); +} + +$templates = [ + [ + 'code' => 'C', + 'name' => 'Klasse C', + 'is_combination' => false, + 'requirements' => [ + ['requirement_key' => 'practice', 'label' => 'Uebungsstunden', 'required_units' => 8], + ['requirement_key' => 'rural', 'label' => 'Ueberland', 'required_units' => 5], + ['requirement_key' => 'highway', 'label' => 'Autobahn', 'required_units' => 4], + ['requirement_key' => 'night', 'label' => 'Nachtfahrt', 'required_units' => 3], + ], + ], + [ + 'code' => 'CE', + 'name' => 'Klasse CE', + 'is_combination' => false, + 'requirements' => [ + ['requirement_key' => 'practice', 'label' => 'Uebungsstunden', 'required_units' => 10], + ['requirement_key' => 'rural', 'label' => 'Ueberland', 'required_units' => 5], + ['requirement_key' => 'highway', 'label' => 'Autobahn', 'required_units' => 5], + ['requirement_key' => 'night', 'label' => 'Nachtfahrt', 'required_units' => 3], + ], + ], + [ + 'code' => 'C+CE', + 'name' => 'Klasse C + CE', + 'is_combination' => true, + 'requirements' => [ + ['requirement_key' => 'C::practice', 'phase_label' => 'C', 'label' => 'Uebungsstunden', 'required_units' => 8], + ['requirement_key' => 'C::rural', 'phase_label' => 'C', 'label' => 'Ueberland', 'required_units' => 5], + ['requirement_key' => 'C::highway', 'phase_label' => 'C', 'label' => 'Autobahn', 'required_units' => 4], + ['requirement_key' => 'C::night', 'phase_label' => 'C', 'label' => 'Nachtfahrt', 'required_units' => 3], + ['requirement_key' => 'CE::practice', 'phase_label' => 'CE', 'label' => 'Uebungsstunden', 'required_units' => 6], + ['requirement_key' => 'CE::rural', 'phase_label' => 'CE', 'label' => 'Ueberland', 'required_units' => 3], + ['requirement_key' => 'CE::highway', 'phase_label' => 'CE', 'label' => 'Autobahn', 'required_units' => 3], + ['requirement_key' => 'CE::night', 'phase_label' => 'CE', 'label' => 'Nachtfahrt', 'required_units' => 2], + ], + ], + [ + 'code' => 'C1', + 'name' => 'Klasse C1', + 'is_combination' => false, + 'requirements' => [ + ['requirement_key' => 'practice', 'label' => 'Uebungsstunden', 'required_units' => 6], + ['requirement_key' => 'rural', 'label' => 'Ueberland', 'required_units' => 4], + ['requirement_key' => 'highway', 'label' => 'Autobahn', 'required_units' => 3], + ['requirement_key' => 'night', 'label' => 'Nachtfahrt', 'required_units' => 2], + ], + ], + [ + 'code' => 'C1E', + 'name' => 'Klasse C1E', + 'is_combination' => false, + 'requirements' => [ + ['requirement_key' => 'practice', 'label' => 'Uebungsstunden', 'required_units' => 7], + ['requirement_key' => 'rural', 'label' => 'Ueberland', 'required_units' => 4], + ['requirement_key' => 'highway', 'label' => 'Autobahn', 'required_units' => 3], + ['requirement_key' => 'night', 'label' => 'Nachtfahrt', 'required_units' => 2], + ], + ], + [ + 'code' => 'C1+C1E', + 'name' => 'Klasse C1 + C1E', + 'is_combination' => true, + 'requirements' => [ + ['requirement_key' => 'C1::practice', 'phase_label' => 'C1', 'label' => 'Uebungsstunden', 'required_units' => 6], + ['requirement_key' => 'C1::rural', 'phase_label' => 'C1', 'label' => 'Ueberland', 'required_units' => 4], + ['requirement_key' => 'C1::highway', 'phase_label' => 'C1', 'label' => 'Autobahn', 'required_units' => 3], + ['requirement_key' => 'C1::night', 'phase_label' => 'C1', 'label' => 'Nachtfahrt', 'required_units' => 2], + ['requirement_key' => 'C1E::practice', 'phase_label' => 'C1E', 'label' => 'Uebungsstunden', 'required_units' => 4], + ['requirement_key' => 'C1E::rural', 'phase_label' => 'C1E', 'label' => 'Ueberland', 'required_units' => 2], + ['requirement_key' => 'C1E::highway', 'phase_label' => 'C1E', 'label' => 'Autobahn', 'required_units' => 2], + ['requirement_key' => 'C1E::night', 'phase_label' => 'C1E', 'label' => 'Nachtfahrt', 'required_units' => 1], + ], + ], +]; + +$templateIds = []; +foreach ($templates as $template) { + $record = $referenceRepository->createTemplate($tenant['id'], $template); + $templateIds[$record['code']] = $record['id']; +} + +$studentRepository->create($tenant['id'], [ + 'template_id' => $templateIds['C+CE'], + 'first_name' => 'Mila', + 'last_name' => 'Hansen', + 'phone' => '0170 1234567', + 'notes' => 'Startet naechste Woche mit Nachtfahrten.', + 'status' => 'active', +]); + +$studentRepository->create($tenant['id'], [ + 'template_id' => $templateIds['C1'], + 'first_name' => 'Jonas', + 'last_name' => 'Winter', + 'phone' => '0172 999000', + 'notes' => 'Braucht fruehe Termine.', + 'status' => 'active', +]); + +fwrite(STDOUT, "Seed complete." . PHP_EOL); diff --git a/www/fahrschultermin.de/config/app.php b/www/fahrschultermin.de/config/app.php new file mode 100644 index 0000000..d252d45 --- /dev/null +++ b/www/fahrschultermin.de/config/app.php @@ -0,0 +1,13 @@ + 'DriveTime Planner', + 'env' => 'local', + 'debug' => true, + 'db_path' => __DIR__ . '/../storage/database/app.sqlite', + 'session_path' => __DIR__ . '/../storage/sessions', + 'session_name' => 'drive_time_session', + 'frontend_entry' => __DIR__ . '/../public/assets/index.js', +]; diff --git a/www/fahrschultermin.de/database/migrations/001_initial.sql b/www/fahrschultermin.de/database/migrations/001_initial.sql new file mode 100644 index 0000000..c9ec8b6 --- /dev/null +++ b/www/fahrschultermin.de/database/migrations/001_initial.sql @@ -0,0 +1,145 @@ +CREATE TABLE IF NOT EXISTS tenants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + timezone TEXT NOT NULL DEFAULT 'Europe/Berlin', + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NULL, + role TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS instructors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + color TEXT NOT NULL, + notes TEXT DEFAULT '', + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS lesson_types ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + name TEXT NOT NULL, + color TEXT NOT NULL, + default_duration INTEGER NOT NULL, + category TEXT NOT NULL, + requirement_key TEXT DEFAULT NULL, + is_billable INTEGER NOT NULL DEFAULT 0, + is_counted INTEGER NOT NULL DEFAULT 0, + is_rest_relevant INTEGER NOT NULL DEFAULT 0, + fixed_duration INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS license_class_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + code TEXT NOT NULL, + name TEXT NOT NULL, + is_combination INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (tenant_id, code), + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS license_template_requirements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + template_id INTEGER NOT NULL, + requirement_key TEXT NOT NULL, + label TEXT NOT NULL, + required_units INTEGER NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (template_id) REFERENCES license_class_templates (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS students ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + template_id INTEGER NOT NULL, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + phone TEXT DEFAULT '', + notes TEXT DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE, + FOREIGN KEY (template_id) REFERENCES license_class_templates (id) +); + +CREATE TABLE IF NOT EXISTS student_requirement_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + student_id INTEGER NOT NULL, + requirement_key TEXT NOT NULL, + label TEXT NOT NULL, + required_units INTEGER NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (student_id) REFERENCES students (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS appointments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + student_id INTEGER DEFAULT NULL, + instructor_id INTEGER NOT NULL, + lesson_type_id INTEGER NOT NULL, + title TEXT NOT NULL, + category TEXT NOT NULL, + start_at TEXT NOT NULL, + end_at TEXT NOT NULL, + units INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'planned', + notes TEXT DEFAULT '', + warning_acknowledged INTEGER NOT NULL DEFAULT 0, + created_by INTEGER NOT NULL, + updated_by INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE, + FOREIGN KEY (student_id) REFERENCES students (id) ON DELETE SET NULL, + FOREIGN KEY (instructor_id) REFERENCES instructors (id), + FOREIGN KEY (lesson_type_id) REFERENCES lesson_types (id), + FOREIGN KEY (created_by) REFERENCES users (id), + FOREIGN KEY (updated_by) REFERENCES users (id) +); + +CREATE TABLE IF NOT EXISTS appointment_units ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + appointment_id INTEGER NOT NULL, + requirement_key TEXT DEFAULT NULL, + label TEXT DEFAULT NULL, + units_counted INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + FOREIGN KEY (appointment_id) REFERENCES appointments (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + key_name TEXT NOT NULL, + value TEXT NOT NULL, + UNIQUE (tenant_id, key_name), + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE +); diff --git a/www/fahrschultermin.de/database/migrations/002_instructors_user_link.sql b/www/fahrschultermin.de/database/migrations/002_instructors_user_link.sql new file mode 100644 index 0000000..d1a755e --- /dev/null +++ b/www/fahrschultermin.de/database/migrations/002_instructors_user_link.sql @@ -0,0 +1 @@ +ALTER TABLE instructors ADD COLUMN user_id INTEGER DEFAULT NULL; diff --git a/www/fahrschultermin.de/database/migrations/003_backfill_instructor_user_links.sql b/www/fahrschultermin.de/database/migrations/003_backfill_instructor_user_links.sql new file mode 100644 index 0000000..c41d2fd --- /dev/null +++ b/www/fahrschultermin.de/database/migrations/003_backfill_instructor_user_links.sql @@ -0,0 +1,11 @@ +UPDATE instructors +SET user_id = ( + SELECT u.id + FROM users u + WHERE u.tenant_id = instructors.tenant_id + AND u.role = 'instructor' + AND u.first_name = instructors.first_name + AND u.last_name = instructors.last_name + LIMIT 1 +) +WHERE user_id IS NULL; diff --git a/www/fahrschultermin.de/database/migrations/004_lesson_type_template_visibility.sql b/www/fahrschultermin.de/database/migrations/004_lesson_type_template_visibility.sql new file mode 100644 index 0000000..084a4f3 --- /dev/null +++ b/www/fahrschultermin.de/database/migrations/004_lesson_type_template_visibility.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS lesson_type_template_visibility ( + lesson_type_id INTEGER NOT NULL, + template_id INTEGER NOT NULL, + PRIMARY KEY (lesson_type_id, template_id), + FOREIGN KEY (lesson_type_id) REFERENCES lesson_types (id) ON DELETE CASCADE, + FOREIGN KEY (template_id) REFERENCES license_class_templates (id) ON DELETE CASCADE +); + +INSERT OR IGNORE INTO lesson_type_template_visibility (lesson_type_id, template_id) +SELECT lt.id, tpl.id +FROM lesson_types lt +JOIN license_class_templates tpl ON tpl.tenant_id = lt.tenant_id +WHERE lt.category = 'student'; diff --git a/www/fahrschultermin.de/database/migrations/005_tenant_location_sun.sql b/www/fahrschultermin.de/database/migrations/005_tenant_location_sun.sql new file mode 100644 index 0000000..154f13a --- /dev/null +++ b/www/fahrschultermin.de/database/migrations/005_tenant_location_sun.sql @@ -0,0 +1,3 @@ +ALTER TABLE tenants ADD COLUMN location_label TEXT DEFAULT ''; +ALTER TABLE tenants ADD COLUMN latitude REAL DEFAULT NULL; +ALTER TABLE tenants ADD COLUMN longitude REAL DEFAULT NULL; diff --git a/www/fahrschultermin.de/database/migrations/006_tenant_federal_state.sql b/www/fahrschultermin.de/database/migrations/006_tenant_federal_state.sql new file mode 100644 index 0000000..7af0b1b --- /dev/null +++ b/www/fahrschultermin.de/database/migrations/006_tenant_federal_state.sql @@ -0,0 +1 @@ +ALTER TABLE tenants ADD COLUMN federal_state TEXT DEFAULT 'BE'; diff --git a/www/fahrschultermin.de/database/migrations/007_student_prior_completed_units.sql b/www/fahrschultermin.de/database/migrations/007_student_prior_completed_units.sql new file mode 100644 index 0000000..0d181c8 --- /dev/null +++ b/www/fahrschultermin.de/database/migrations/007_student_prior_completed_units.sql @@ -0,0 +1 @@ +ALTER TABLE student_requirement_snapshots ADD COLUMN prior_completed_units INTEGER NOT NULL DEFAULT 0; diff --git a/www/fahrschultermin.de/database/migrations/008_lesson_type_system_key.sql b/www/fahrschultermin.de/database/migrations/008_lesson_type_system_key.sql new file mode 100644 index 0000000..623f0ff --- /dev/null +++ b/www/fahrschultermin.de/database/migrations/008_lesson_type_system_key.sql @@ -0,0 +1,2 @@ +ALTER TABLE lesson_types ADD COLUMN system_key TEXT DEFAULT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_lesson_types_tenant_system_key ON lesson_types (tenant_id, system_key) WHERE system_key IS NOT NULL; diff --git a/www/fahrschultermin.de/database/migrations/009_requirement_phase_labels.sql b/www/fahrschultermin.de/database/migrations/009_requirement_phase_labels.sql new file mode 100644 index 0000000..8d403ba --- /dev/null +++ b/www/fahrschultermin.de/database/migrations/009_requirement_phase_labels.sql @@ -0,0 +1,2 @@ +ALTER TABLE license_template_requirements ADD COLUMN phase_label TEXT NOT NULL DEFAULT ''; +ALTER TABLE student_requirement_snapshots ADD COLUMN phase_label TEXT NOT NULL DEFAULT ''; diff --git a/www/fahrschultermin.de/database/migrations/010_student_email.sql b/www/fahrschultermin.de/database/migrations/010_student_email.sql new file mode 100644 index 0000000..64fbf40 --- /dev/null +++ b/www/fahrschultermin.de/database/migrations/010_student_email.sql @@ -0,0 +1 @@ +ALTER TABLE students ADD COLUMN email TEXT NOT NULL DEFAULT ''; diff --git a/www/fahrschultermin.de/database/migrations/010_students_contact_flags.sql b/www/fahrschultermin.de/database/migrations/010_students_contact_flags.sql new file mode 100644 index 0000000..1c1d76f --- /dev/null +++ b/www/fahrschultermin.de/database/migrations/010_students_contact_flags.sql @@ -0,0 +1,2 @@ +ALTER TABLE students ADD COLUMN email TEXT DEFAULT ''; +ALTER TABLE students ADD COLUMN needs_glasses INTEGER NOT NULL DEFAULT 0; diff --git a/www/fahrschultermin.de/database/migrations/011_student_needs_glasses.sql b/www/fahrschultermin.de/database/migrations/011_student_needs_glasses.sql new file mode 100644 index 0000000..9e31849 --- /dev/null +++ b/www/fahrschultermin.de/database/migrations/011_student_needs_glasses.sql @@ -0,0 +1 @@ +ALTER TABLE students ADD COLUMN needs_glasses INTEGER NOT NULL DEFAULT 0; diff --git a/www/fahrschultermin.de/database/migrations/012_split_combination_templates.sql b/www/fahrschultermin.de/database/migrations/012_split_combination_templates.sql new file mode 100644 index 0000000..9e37494 --- /dev/null +++ b/www/fahrschultermin.de/database/migrations/012_split_combination_templates.sql @@ -0,0 +1,183 @@ +DROP TABLE IF EXISTS student_requirement_snapshots_old; +CREATE TEMP TABLE student_requirement_snapshots_old AS +SELECT * +FROM student_requirement_snapshots +WHERE student_id IN ( + SELECT s.id + FROM students s + JOIN license_class_templates t ON t.id = s.template_id + WHERE t.code IN ('C+CE', 'C1+C1E') +); + +DELETE FROM license_template_requirements +WHERE template_id IN ( + SELECT id FROM license_class_templates WHERE code IN ('C+CE', 'C1+C1E') +); + +INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label) +SELECT id, 'C::practice', 'Uebungsstunden', 8, 0, 'C' +FROM license_class_templates +WHERE code = 'C+CE'; + +INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label) +SELECT id, 'C::rural', 'Ueberland', 5, 1, 'C' +FROM license_class_templates +WHERE code = 'C+CE'; + +INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label) +SELECT id, 'C::highway', 'Autobahn', 4, 2, 'C' +FROM license_class_templates +WHERE code = 'C+CE'; + +INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label) +SELECT id, 'C::night', 'Nachtfahrt', 3, 3, 'C' +FROM license_class_templates +WHERE code = 'C+CE'; + +INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label) +SELECT id, 'CE::practice', 'Uebungsstunden', 6, 100, 'CE' +FROM license_class_templates +WHERE code = 'C+CE'; + +INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label) +SELECT id, 'CE::rural', 'Ueberland', 3, 101, 'CE' +FROM license_class_templates +WHERE code = 'C+CE'; + +INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label) +SELECT id, 'CE::highway', 'Autobahn', 3, 102, 'CE' +FROM license_class_templates +WHERE code = 'C+CE'; + +INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label) +SELECT id, 'CE::night', 'Nachtfahrt', 2, 103, 'CE' +FROM license_class_templates +WHERE code = 'C+CE'; + +INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label) +SELECT id, 'C1::practice', 'Uebungsstunden', 6, 0, 'C1' +FROM license_class_templates +WHERE code = 'C1+C1E'; + +INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label) +SELECT id, 'C1::rural', 'Ueberland', 4, 1, 'C1' +FROM license_class_templates +WHERE code = 'C1+C1E'; + +INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label) +SELECT id, 'C1::highway', 'Autobahn', 3, 2, 'C1' +FROM license_class_templates +WHERE code = 'C1+C1E'; + +INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label) +SELECT id, 'C1::night', 'Nachtfahrt', 2, 3, 'C1' +FROM license_class_templates +WHERE code = 'C1+C1E'; + +INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label) +SELECT id, 'C1E::practice', 'Uebungsstunden', 4, 100, 'C1E' +FROM license_class_templates +WHERE code = 'C1+C1E'; + +INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label) +SELECT id, 'C1E::rural', 'Ueberland', 2, 101, 'C1E' +FROM license_class_templates +WHERE code = 'C1+C1E'; + +INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label) +SELECT id, 'C1E::highway', 'Autobahn', 2, 102, 'C1E' +FROM license_class_templates +WHERE code = 'C1+C1E'; + +INSERT INTO license_template_requirements (template_id, requirement_key, label, required_units, sort_order, phase_label) +SELECT id, 'C1E::night', 'Nachtfahrt', 1, 103, 'C1E' +FROM license_class_templates +WHERE code = 'C1+C1E'; + +UPDATE appointment_units +SET requirement_key = 'C::' || requirement_key +WHERE requirement_key IN ('practice', 'rural', 'highway', 'night') + AND appointment_id IN ( + SELECT a.id + FROM appointments a + JOIN students s ON s.id = a.student_id + JOIN license_class_templates t ON t.id = s.template_id + WHERE t.code = 'C+CE' + ); + +UPDATE appointment_units +SET requirement_key = 'C1::' || requirement_key +WHERE requirement_key IN ('practice', 'rural', 'highway', 'night') + AND appointment_id IN ( + SELECT a.id + FROM appointments a + JOIN students s ON s.id = a.student_id + JOIN license_class_templates t ON t.id = s.template_id + WHERE t.code = 'C1+C1E' + ); + +DELETE FROM student_requirement_snapshots +WHERE student_id IN ( + SELECT s.id + FROM students s + JOIN license_class_templates t ON t.id = s.template_id + WHERE t.code IN ('C+CE', 'C1+C1E') +); + +INSERT INTO student_requirement_snapshots (student_id, requirement_key, label, required_units, sort_order, prior_completed_units, phase_label) +SELECT + s.id, + r.requirement_key, + r.label, + r.required_units, + r.sort_order, + CASE + WHEN r.requirement_key = 'C::practice' THEN COALESCE(( + SELECT prior_completed_units + FROM student_requirement_snapshots_old old + WHERE old.student_id = s.id AND old.requirement_key = 'practice' + ), 0) + WHEN r.requirement_key = 'C::rural' THEN COALESCE(( + SELECT prior_completed_units + FROM student_requirement_snapshots_old old + WHERE old.student_id = s.id AND old.requirement_key = 'rural' + ), 0) + WHEN r.requirement_key = 'C::highway' THEN COALESCE(( + SELECT prior_completed_units + FROM student_requirement_snapshots_old old + WHERE old.student_id = s.id AND old.requirement_key = 'highway' + ), 0) + WHEN r.requirement_key = 'C::night' THEN COALESCE(( + SELECT prior_completed_units + FROM student_requirement_snapshots_old old + WHERE old.student_id = s.id AND old.requirement_key = 'night' + ), 0) + WHEN r.requirement_key = 'C1::practice' THEN COALESCE(( + SELECT prior_completed_units + FROM student_requirement_snapshots_old old + WHERE old.student_id = s.id AND old.requirement_key = 'practice' + ), 0) + WHEN r.requirement_key = 'C1::rural' THEN COALESCE(( + SELECT prior_completed_units + FROM student_requirement_snapshots_old old + WHERE old.student_id = s.id AND old.requirement_key = 'rural' + ), 0) + WHEN r.requirement_key = 'C1::highway' THEN COALESCE(( + SELECT prior_completed_units + FROM student_requirement_snapshots_old old + WHERE old.student_id = s.id AND old.requirement_key = 'highway' + ), 0) + WHEN r.requirement_key = 'C1::night' THEN COALESCE(( + SELECT prior_completed_units + FROM student_requirement_snapshots_old old + WHERE old.student_id = s.id AND old.requirement_key = 'night' + ), 0) + ELSE 0 + END, + r.phase_label +FROM students s +JOIN license_class_templates t ON t.id = s.template_id +JOIN license_template_requirements r ON r.template_id = t.id +WHERE t.code IN ('C+CE', 'C1+C1E'); + +DROP TABLE IF EXISTS student_requirement_snapshots_old; diff --git a/www/fahrschultermin.de/database/migrations/013_combo_generic_key_cleanup.sql b/www/fahrschultermin.de/database/migrations/013_combo_generic_key_cleanup.sql new file mode 100644 index 0000000..2cc654a --- /dev/null +++ b/www/fahrschultermin.de/database/migrations/013_combo_generic_key_cleanup.sql @@ -0,0 +1,42 @@ +UPDATE appointment_units +SET requirement_key = COALESCE( + ( + SELECT r.requirement_key + FROM appointments a + JOIN students s ON s.id = a.student_id + JOIN license_class_templates t ON t.id = s.template_id + JOIN license_template_requirements r ON r.template_id = t.id + WHERE a.id = appointment_units.appointment_id + AND t.is_combination = 1 + AND COALESCE(r.phase_label, '') <> '' + AND substr(r.requirement_key, instr(r.requirement_key, '::') + 2) = appointment_units.requirement_key + ORDER BY r.sort_order + LIMIT 1 + ), + requirement_key +) +WHERE requirement_key NOT LIKE '%::%' + AND appointment_id IN ( + SELECT a.id + FROM appointments a + JOIN students s ON s.id = a.student_id + JOIN license_class_templates t ON t.id = s.template_id + WHERE t.is_combination = 1 + ); + +DELETE FROM student_requirement_snapshots +WHERE requirement_key NOT LIKE '%::%' + AND student_id IN ( + SELECT s.id + FROM students s + JOIN license_class_templates t ON t.id = s.template_id + WHERE t.is_combination = 1 + ) + AND EXISTS ( + SELECT 1 + FROM students s + JOIN license_template_requirements r ON r.template_id = s.template_id + WHERE s.id = student_requirement_snapshots.student_id + AND COALESCE(r.phase_label, '') <> '' + AND substr(r.requirement_key, instr(r.requirement_key, '::') + 2) = student_requirement_snapshots.requirement_key + ); diff --git a/www/fahrschultermin.de/database/migrations/014_legacy_student_key_aliases_to_lesson_type_keys.sql b/www/fahrschultermin.de/database/migrations/014_legacy_student_key_aliases_to_lesson_type_keys.sql new file mode 100644 index 0000000..94a52ef --- /dev/null +++ b/www/fahrschultermin.de/database/migrations/014_legacy_student_key_aliases_to_lesson_type_keys.sql @@ -0,0 +1,170 @@ +UPDATE appointment_units +SET requirement_key = COALESCE(( + SELECT 'lesson_type_' || lt.id + FROM appointments a + JOIN students s ON s.id = a.student_id + JOIN lesson_type_template_visibility vis ON vis.template_id = s.template_id + JOIN lesson_types lt ON lt.id = vis.lesson_type_id + WHERE a.id = appointment_units.appointment_id + AND lt.category = 'student' + AND lt.is_counted = 1 + AND ( + (appointment_units.requirement_key = 'intro' AND lt.name = 'Einweisung') + OR (appointment_units.requirement_key = 'practice' AND lt.name IN ('Uebungsstunde', 'Übungsstunde', 'Uebungsstunden', 'Übungsstunden')) + OR (appointment_units.requirement_key = 'rural' AND lt.name IN ('Ueberlandfahrt', 'Überlandfahrt', 'Bundes- / Landstrasse')) + OR (appointment_units.requirement_key = 'highway' AND lt.name IN ('Autobahn', 'Autobahnfahrt')) + OR (appointment_units.requirement_key = 'night' AND lt.name IN ('Nachtfahrt', 'Nachtfahrten', 'Daemmerung / Dunkelheit')) + ) + ORDER BY lt.sort_order, lt.id + LIMIT 1 +), requirement_key) +WHERE requirement_key IN ('intro', 'practice', 'rural', 'highway', 'night'); + +UPDATE student_requirement_snapshots AS target +SET prior_completed_units = prior_completed_units + COALESCE(( + SELECT SUM(src.prior_completed_units) + FROM student_requirement_snapshots src + WHERE src.student_id = target.student_id + AND src.requirement_key = 'intro' +), 0) +WHERE target.requirement_key = COALESCE(( + SELECT 'lesson_type_' || lt.id + FROM students s + JOIN lesson_type_template_visibility vis ON vis.template_id = s.template_id + JOIN lesson_types lt ON lt.id = vis.lesson_type_id + WHERE s.id = target.student_id + AND lt.category = 'student' + AND lt.is_counted = 1 + AND lt.name = 'Einweisung' + ORDER BY lt.sort_order, lt.id + LIMIT 1 +), target.requirement_key) + AND EXISTS ( + SELECT 1 + FROM student_requirement_snapshots src + WHERE src.student_id = target.student_id + AND src.requirement_key = 'intro' + ); + +UPDATE student_requirement_snapshots AS target +SET prior_completed_units = prior_completed_units + COALESCE(( + SELECT SUM(src.prior_completed_units) + FROM student_requirement_snapshots src + WHERE src.student_id = target.student_id + AND src.requirement_key = 'practice' +), 0) +WHERE target.requirement_key = COALESCE(( + SELECT 'lesson_type_' || lt.id + FROM students s + JOIN lesson_type_template_visibility vis ON vis.template_id = s.template_id + JOIN lesson_types lt ON lt.id = vis.lesson_type_id + WHERE s.id = target.student_id + AND lt.category = 'student' + AND lt.is_counted = 1 + AND lt.name IN ('Uebungsstunde', 'Übungsstunde', 'Uebungsstunden', 'Übungsstunden') + ORDER BY lt.sort_order, lt.id + LIMIT 1 +), target.requirement_key) + AND EXISTS ( + SELECT 1 + FROM student_requirement_snapshots src + WHERE src.student_id = target.student_id + AND src.requirement_key = 'practice' + ); + +UPDATE student_requirement_snapshots AS target +SET prior_completed_units = prior_completed_units + COALESCE(( + SELECT SUM(src.prior_completed_units) + FROM student_requirement_snapshots src + WHERE src.student_id = target.student_id + AND src.requirement_key = 'rural' +), 0) +WHERE target.requirement_key = COALESCE(( + SELECT 'lesson_type_' || lt.id + FROM students s + JOIN lesson_type_template_visibility vis ON vis.template_id = s.template_id + JOIN lesson_types lt ON lt.id = vis.lesson_type_id + WHERE s.id = target.student_id + AND lt.category = 'student' + AND lt.is_counted = 1 + AND lt.name IN ('Ueberlandfahrt', 'Überlandfahrt', 'Bundes- / Landstrasse') + ORDER BY lt.sort_order, lt.id + LIMIT 1 +), target.requirement_key) + AND EXISTS ( + SELECT 1 + FROM student_requirement_snapshots src + WHERE src.student_id = target.student_id + AND src.requirement_key = 'rural' + ); + +UPDATE student_requirement_snapshots AS target +SET prior_completed_units = prior_completed_units + COALESCE(( + SELECT SUM(src.prior_completed_units) + FROM student_requirement_snapshots src + WHERE src.student_id = target.student_id + AND src.requirement_key = 'highway' +), 0) +WHERE target.requirement_key = COALESCE(( + SELECT 'lesson_type_' || lt.id + FROM students s + JOIN lesson_type_template_visibility vis ON vis.template_id = s.template_id + JOIN lesson_types lt ON lt.id = vis.lesson_type_id + WHERE s.id = target.student_id + AND lt.category = 'student' + AND lt.is_counted = 1 + AND lt.name IN ('Autobahn', 'Autobahnfahrt') + ORDER BY lt.sort_order, lt.id + LIMIT 1 +), target.requirement_key) + AND EXISTS ( + SELECT 1 + FROM student_requirement_snapshots src + WHERE src.student_id = target.student_id + AND src.requirement_key = 'highway' + ); + +UPDATE student_requirement_snapshots AS target +SET prior_completed_units = prior_completed_units + COALESCE(( + SELECT SUM(src.prior_completed_units) + FROM student_requirement_snapshots src + WHERE src.student_id = target.student_id + AND src.requirement_key = 'night' +), 0) +WHERE target.requirement_key = COALESCE(( + SELECT 'lesson_type_' || lt.id + FROM students s + JOIN lesson_type_template_visibility vis ON vis.template_id = s.template_id + JOIN lesson_types lt ON lt.id = vis.lesson_type_id + WHERE s.id = target.student_id + AND lt.category = 'student' + AND lt.is_counted = 1 + AND lt.name IN ('Nachtfahrt', 'Nachtfahrten', 'Daemmerung / Dunkelheit') + ORDER BY lt.sort_order, lt.id + LIMIT 1 +), target.requirement_key) + AND EXISTS ( + SELECT 1 + FROM student_requirement_snapshots src + WHERE src.student_id = target.student_id + AND src.requirement_key = 'night' + ); + +DELETE FROM student_requirement_snapshots +WHERE requirement_key IN ('intro', 'practice', 'rural', 'highway', 'night') + AND EXISTS ( + SELECT 1 + FROM students s + JOIN lesson_type_template_visibility vis ON vis.template_id = s.template_id + JOIN lesson_types lt ON lt.id = vis.lesson_type_id + WHERE s.id = student_requirement_snapshots.student_id + AND lt.category = 'student' + AND lt.is_counted = 1 + AND ( + (student_requirement_snapshots.requirement_key = 'intro' AND lt.name = 'Einweisung') + OR (student_requirement_snapshots.requirement_key = 'practice' AND lt.name IN ('Uebungsstunde', 'Übungsstunde', 'Uebungsstunden', 'Übungsstunden')) + OR (student_requirement_snapshots.requirement_key = 'rural' AND lt.name IN ('Ueberlandfahrt', 'Überlandfahrt', 'Bundes- / Landstrasse')) + OR (student_requirement_snapshots.requirement_key = 'highway' AND lt.name IN ('Autobahn', 'Autobahnfahrt')) + OR (student_requirement_snapshots.requirement_key = 'night' AND lt.name IN ('Nachtfahrt', 'Nachtfahrten', 'Daemmerung / Dunkelheit')) + ) + ); diff --git a/www/fahrschultermin.de/database/migrations/015_instructor_pre_start_buffer.sql b/www/fahrschultermin.de/database/migrations/015_instructor_pre_start_buffer.sql new file mode 100644 index 0000000..60c1107 --- /dev/null +++ b/www/fahrschultermin.de/database/migrations/015_instructor_pre_start_buffer.sql @@ -0,0 +1 @@ +ALTER TABLE instructors ADD COLUMN pre_start_buffer_minutes INTEGER NOT NULL DEFAULT 0; diff --git a/www/fahrschultermin.de/public/.htaccess b/www/fahrschultermin.de/public/.htaccess new file mode 100644 index 0000000..66ef8f6 --- /dev/null +++ b/www/fahrschultermin.de/public/.htaccess @@ -0,0 +1,4 @@ +RewriteEngine On +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^ index.php [QSA,L] diff --git a/www/fahrschultermin.de/public/assets/.vite/manifest.json b/www/fahrschultermin.de/public/assets/.vite/manifest.json new file mode 100644 index 0000000..9d7b925 --- /dev/null +++ b/www/fahrschultermin.de/public/assets/.vite/manifest.json @@ -0,0 +1,11 @@ +{ + "src/main.jsx": { + "file": "assets/main-F76uNDS1.js", + "name": "main", + "src": "src/main.jsx", + "isEntry": true, + "css": [ + "assets/main-C54NZrM5.css" + ] + } +} \ No newline at end of file diff --git a/www/fahrschultermin.de/public/assets/assets/main-C54NZrM5.css b/www/fahrschultermin.de/public/assets/assets/main-C54NZrM5.css new file mode 100644 index 0000000..d475cba --- /dev/null +++ b/www/fahrschultermin.de/public/assets/assets/main-C54NZrM5.css @@ -0,0 +1 @@ +:root{--bg: #f3efe6;--panel: rgba(255, 252, 246, .94);--ink: #111217;--muted: #67625a;--line: rgba(17, 18, 23, .08);--accent: #a74826;--accent-deep: #6d2410;--teal: #0e5d80;--success: #2c7a4b;--danger: #a83333;--shadow: 0 24px 60px rgba(44, 36, 25, .14);--surface-soft: #efe6d7;--surface-time: #f7f2ea;--input-bg: rgba(255, 255, 255, .88);--ghost-bg: rgba(255, 255, 255, .7);--badge-bg: rgba(17, 18, 23, .07);font-family:Iowan Old Style,Palatino Linotype,Book Antiqua,serif;color:var(--ink);background:radial-gradient(circle at top left,rgba(167,72,38,.17),transparent 30%),radial-gradient(circle at bottom right,rgba(14,93,128,.14),transparent 26%),linear-gradient(180deg,#efe8da,#f6f3ed)}:root[data-theme=dark]{--bg: #14181d;--panel: rgba(24, 29, 36, .94);--ink: #edf0f4;--muted: #a3acb8;--line: rgba(237, 240, 244, .12);--accent: #c66945;--accent-deep: #f1b08f;--teal: #63a8c2;--success: #7bc596;--danger: #f08a8a;--shadow: 0 24px 60px rgba(0, 0, 0, .32);--surface-soft: #222933;--surface-time: #1b2129;--input-bg: rgba(35, 42, 52, .92);--ghost-bg: rgba(35, 42, 52, .82);--badge-bg: rgba(237, 240, 244, .08);background:radial-gradient(circle at top left,rgba(198,105,69,.18),transparent 28%),radial-gradient(circle at bottom right,rgba(99,168,194,.14),transparent 25%),linear-gradient(180deg,#11161c,#161c23)}*{box-sizing:border-box}body{margin:0;color:var(--ink);background:var(--bg)}button,input,select,textarea{font:inherit}.app-shell{min-height:100vh;padding:24px}.topbar,.calendar-stage,.planner-panel,.list-panel,.loading-panel{background:var(--panel);border:1px solid var(--line);box-shadow:var(--shadow);border-radius:24px}.topbar{display:flex;justify-content:space-between;gap:24px;align-items:center;padding:24px 28px}.topbar h1,.login-card h1,.section-head h2{font-family:Avenir Next Condensed,Franklin Gothic Medium,Arial Narrow,sans-serif;text-transform:uppercase;letter-spacing:.06em;margin:0}.eyebrow,.hint-line,.meta-line{color:var(--muted);margin:0}.badge,.open-pill{border-radius:999px;padding:8px 12px;font-size:.88rem;background:var(--badge-bg)}.open-pill.is-missing,.phase-state.is-missing{background:#a8333329;color:var(--danger)}.open-pill.is-complete,.phase-state.is-complete{background:#2c7a4b29;color:var(--success)}.open-pill.is-over,.phase-state.is-over{background:#d99a1a33;color:#a96b00}.tab-strip{display:flex;gap:10px;margin:16px 0;flex-wrap:wrap}.tab-button,.ghost-button,.primary-button,.danger-button{border-radius:999px;border:1px solid var(--line);padding:10px 16px;cursor:pointer}.tab-button,.ghost-button{background:var(--ghost-bg);color:var(--ink)}.tab-button.active,.primary-button{background:var(--accent);color:#fffaf4;border-color:transparent}.danger-button{background:#8f1d28;color:#fff;border-color:transparent}.content-grid{display:grid;grid-template-columns:1.5fr .9fr;gap:18px;align-items:start}.calendar-stage,.planner-panel,.list-panel{padding:20px}.section-head{display:flex;justify-content:space-between;gap:16px;align-items:start;margin-bottom:18px}.toolbar,.topbar-actions,.mode-switch,.modal-actions{display:flex;gap:10px;flex-wrap:wrap}.calendar-filter{display:grid;gap:6px;color:var(--muted);font-size:.92rem}.calendar-grid{display:grid;grid-template-columns:88px repeat(7,minmax(0,1fr));gap:0;border-radius:18px;overflow:hidden;border:1px solid rgba(17,18,23,.08)}.calendar-head{padding:10px 12px;background:var(--surface-soft);border-right:1px solid rgba(17,18,23,.08);border-bottom:1px solid rgba(17,18,23,.08);display:grid;gap:4px}.calendar-head.sunday-head{background:#f7d8d8}.calendar-head.holiday-head{background:#f1c8c8}.calendar-head.past-head{background:linear-gradient(180deg,#1112171f,#1112171f),var(--surface-soft);color:color-mix(in srgb,var(--ink) 72%,transparent)}.sun-meta{color:var(--muted);font-size:.78rem}.day-summary{display:grid;gap:3px;margin-top:2px}.day-summary-line{display:flex;align-items:baseline;gap:6px;font-size:.75rem;color:var(--muted);flex-wrap:wrap}.day-summary-line strong{color:var(--ink);font-size:.82rem}.break-line.ok,.break-line.ok strong{color:var(--success)}.break-line.warning,.break-line.warning strong{color:var(--danger)}.holiday-meta{color:#8f1d28;font-size:.78rem;font-weight:700}.time-grid,.day-grid{display:grid;grid-template-columns:minmax(0,1fr);grid-template-rows:repeat(96,28px);position:relative}.time-grid{background:var(--surface-time)}.day-grid{background:#ffffffd1;border-right:1px solid rgba(17,18,23,.08)}.day-grid.sunday-column{background:linear-gradient(180deg,#c1272d14,#c1272d14),#ffffffd1}.day-grid.holiday-column{background:linear-gradient(180deg,#8f1d281f,#8f1d281f),#ffffffd1}.day-grid.past-column{background:linear-gradient(180deg,#1112170f,#1112170f),#ffffffd1}.day-past-overlay{grid-column:1;align-self:stretch;justify-self:stretch;margin:0;background:#11121729;pointer-events:none;z-index:1}.current-time-past{grid-column:1;align-self:stretch;justify-self:stretch;margin:0;background:#1112172e;pointer-events:none;z-index:1}.current-time-line{position:absolute;left:0;right:0;height:0;border-top:2px solid color-mix(in srgb,var(--accent) 72%,white);pointer-events:none;z-index:4}.current-time-line span{position:absolute;top:-12px;right:6px;padding:2px 8px;border-radius:999px;background:color-mix(in srgb,var(--accent) 90%,white);color:#fffaf4;font-size:.66rem;font-weight:700;letter-spacing:.03em;box-shadow:0 4px 12px #11121729}.slot-cell{grid-column:1;min-height:28px;padding:0;border:0;border-top:1px solid rgba(17,18,23,.05);border-radius:0;background:transparent;position:relative;overflow:hidden;z-index:2}.slot-label{align-self:stretch;display:flex;align-items:center;padding:4px 8px;color:var(--muted);font-size:.82rem;border-top:1px solid rgba(17,18,23,.05);border-right:1px solid rgba(17,18,23,.08)}.slot-label.full-hour,.slot-cell.full-hour{border-top:1px solid rgba(17,18,23,.16)}.resource-lane,.mobile-day-stack{border-radius:14px;background:#ffffffa3;padding:8px;min-height:100px}.lane-caption{font-size:.75rem;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:6px}.appointment-card,.entity-item{width:100%;text-align:left;border:0;border-radius:14px;padding:10px 12px;cursor:pointer}.appointment-card{background:linear-gradient(135deg,var(--appointment-color),color-mix(in srgb,var(--appointment-color) 45%,white));color:#fff;display:grid;gap:4px;margin-bottom:8px;font-size:.92rem}.compact-card{padding:4px 6px;border-radius:8px;gap:6px;margin-bottom:0;font-size:.7rem;line-height:1.15;min-height:calc(var(--span-blocks, 1) * 28px - 4px);height:calc(var(--span-blocks, 1) * 28px - 4px);align-content:stretch;grid-template-rows:1fr auto;position:relative;z-index:2;overflow:hidden}.appointment-top{display:grid;gap:2px;align-content:start}.appointment-title-row{display:flex;align-items:center;justify-content:space-between;gap:8px}.appointment-student{font-weight:700}.appointment-meta{opacity:.9;font-size:.66rem}.appointment-bottom{align-self:end;padding-top:4px;border-top:1px solid rgba(255,250,244,.35);font-weight:700}.vision-badge{display:inline-flex;align-items:center;justify-content:center;padding:2px 6px;border-radius:999px;background:#fffaf438;border:1px solid rgba(255,250,244,.32);font-size:.62rem;line-height:1;white-space:nowrap}.vision-inline{font-weight:600;opacity:.92}.day-appointment{grid-column:1;align-self:stretch;justify-self:stretch;margin:2px 3px;z-index:3}.rest-stripe{grid-column:1;align-self:stretch;justify-self:stretch;margin:0;z-index:0;pointer-events:auto;position:relative;cursor:pointer}.rest-stripe.warning{background:repeating-linear-gradient(-45deg,#8f1d2829,#8f1d2829 8px,#8f1d280d 8px,#8f1d280d 16px);border-left:3px solid rgba(143,29,40,.6)}.rest-stripe.ok{background:repeating-linear-gradient(-45deg,#0e5d801f,#0e5d801f 8px,#0e5d800a 8px,#0e5d800a 16px);border-left:3px solid rgba(14,93,128,.48)}.rest-stripe span{position:sticky;top:4px;display:inline-block;margin:4px 6px;padding:2px 6px;border-radius:999px;background:#fffaf4db;color:#0e5d80;font-size:.68rem;pointer-events:none}.pre-start-stripe{grid-column:1;align-self:stretch;justify-self:stretch;margin:0;z-index:1;pointer-events:none;position:relative;background:repeating-linear-gradient(-45deg,#a7482629,#a7482629 8px,#a748260d 8px,#a748260d 16px);border-left:3px solid rgba(167,72,38,.56)}.pre-start-stripe span{position:sticky;top:4px;display:inline-block;margin:4px 6px;padding:2px 6px;border-radius:999px;background:#fffaf4e6;color:var(--accent-deep);font-size:.68rem}.sun-band{grid-column:1;align-self:stretch;justify-self:stretch;margin:0;z-index:0;pointer-events:none;position:relative;background:repeating-linear-gradient(180deg,#11121714,#11121714 8px,#11121724 8px,#11121724 16px)}.sun-band.top-band{border-bottom:2px solid rgba(14,93,128,.35)}.sun-band.bottom-band{border-top:2px solid rgba(14,93,128,.35)}.sun-band span{position:sticky;display:inline-block;margin:4px 6px;padding:2px 6px;border-radius:999px;background:#fffaf4e6;color:var(--ink);font-size:.68rem;box-shadow:0 1px #11121714}.sun-band.top-band span{top:4px}.sun-band.bottom-band span{bottom:4px}.planner-panel{position:sticky;top:24px}.block-bank,.entity-list,.requirements-grid{display:grid;gap:10px}.autocomplete-shell{position:relative}.autocomplete-list{position:absolute;top:calc(100% + 8px);left:0;right:0;display:grid;gap:6px;padding:8px;border-radius:16px;border:1px solid var(--line);background:#fffaf4;box-shadow:var(--shadow);z-index:8}.autocomplete-option{width:100%;border:0;background:#1112170d;border-radius:12px;padding:10px 12px;text-align:left;display:grid;gap:2px;cursor:pointer}.autocomplete-option span{color:var(--muted);font-size:.9rem}.location-search{display:flex;gap:10px}.location-search input{flex:1 1 auto}.form-error{margin:-6px 0 0;color:#8f1d28;font-size:.92rem}.time-editor-grid{display:grid;gap:14px;grid-template-columns:repeat(3,minmax(0,1fr));align-items:start}.time-dial-field{position:relative}.time-dial-button{width:100%;border:1px solid rgba(17,18,23,.12);border-radius:16px;padding:12px 14px;background:var(--input-bg);color:var(--ink);display:flex;justify-content:space-between;gap:12px;align-items:center;cursor:pointer}.time-dial-popover{position:absolute;top:calc(100% + 8px);left:50%;transform:translate(-50%);width:min(340px,92vw);padding:14px;border-radius:20px;border:1px solid var(--line);background:#fffaf4;box-shadow:var(--shadow);display:grid;gap:12px;z-index:12}.time-dial-head{display:flex;justify-content:space-between;gap:12px;align-items:start}.time-dial-face{position:relative;width:280px;height:280px;margin:0 auto;border-radius:999px;background:radial-gradient(circle at center,rgba(17,18,23,.08) 0,rgba(17,18,23,.08) 10px,transparent 11px),radial-gradient(circle at center,transparent 0 54px,rgba(17,18,23,.06) 55px 56px,transparent 57px),linear-gradient(180deg,#ffffffb8,#efe6d7db);border:1px solid var(--line)}.time-dial-center{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-family:Avenir Next Condensed,Franklin Gothic Medium,Arial Narrow,sans-serif;font-size:1.7rem;letter-spacing:.05em}.time-dial-point{position:absolute;transform:translate(-50%,-50%);width:42px;height:42px;border-radius:999px;border:1px solid rgba(17,18,23,.08);background:#ffffffc7;color:var(--ink);cursor:pointer}.time-dial-point.inner-ring{width:38px;height:38px;font-size:.84rem;background:#efe6d7eb}.time-dial-point.outer-ring{box-shadow:0 4px 12px #11121714}.time-dial-point.active{background:var(--accent);color:#fffaf4;border-color:transparent}.duration-preset-panel,.share-panel,.quick-plan-panel{display:grid;gap:12px;padding:14px;border-radius:18px;border:1px solid var(--line);background:#ffffff6b}.compact-head{margin-bottom:0}.time-editor-grid.two-col{grid-template-columns:repeat(2,minmax(0,1fr))}.duration-preset-row,.share-actions{display:flex;gap:10px;flex-wrap:wrap}.share-button{display:inline-flex;align-items:center;gap:10px;padding:10px 14px;border-radius:999px;border:1px solid var(--line);background:var(--ghost-bg);color:var(--ink);text-decoration:none}.share-button.disabled{opacity:.45;cursor:default}.share-badge{min-width:36px;height:36px;border-radius:999px;display:inline-flex;align-items:center;justify-content:center;color:#fffaf4;font-weight:700}.share-badge.whatsapp{background:#1d8f5a}.share-badge.sms{background:#0e5d80}.share-badge.email{background:#a74826}.location-result-list{display:grid;gap:8px}.location-result{width:100%;border:1px solid var(--line);background:#ffffffc7;border-radius:14px;padding:10px 12px;text-align:left;display:grid;gap:4px;cursor:pointer}.location-result span{color:var(--muted);font-size:.88rem}.coordinate-grid{display:grid;gap:14px;grid-template-columns:repeat(2,minmax(0,1fr))}.map-preview{display:grid;gap:8px}.map-preview iframe{width:100%;min-height:260px;border:1px solid var(--line);border-radius:18px;background:#e7efe9}.location-hint{display:grid;gap:4px;padding:12px 14px;border-radius:16px;background:#0e5d8014;border:1px solid rgba(14,93,128,.14)}.requirement-title{display:inline-flex;align-items:center;gap:8px}.color-dot{width:12px;height:12px;border-radius:999px;background:var(--dot-color);box-shadow:inset 0 0 0 1px #1112171f;flex:0 0 auto}.visibility-matrix-wrap{overflow-x:auto;border:1px solid var(--line);border-radius:18px;background:#ffffff94}.visibility-matrix{width:100%;border-collapse:collapse;min-width:720px}.visibility-matrix th,.visibility-matrix td{padding:12px 14px;border-bottom:1px solid var(--line);text-align:center}.visibility-matrix thead th{position:sticky;top:0;background:#f5eee2;z-index:1;font-size:.82rem;text-transform:uppercase;letter-spacing:.06em}.visibility-matrix tbody th{text-align:left;background:#11121708;min-width:180px}.visibility-matrix tbody tr:last-child th,.visibility-matrix tbody tr:last-child td{border-bottom:0}.lesson-chip{border-radius:18px;padding:14px;background:linear-gradient(135deg,color-mix(in srgb,var(--chip-color) 78%,white),#ffffffbf),repeating-linear-gradient(-45deg,#ffffff2e,#ffffff2e 8px,#ffffff05 8px,#ffffff05 16px);color:#fff;cursor:grab;display:grid;gap:4px}.lesson-chip.active{box-shadow:inset 0 0 0 2px #fffaf4e6,0 0 0 2px color-mix(in srgb,var(--chip-color) 72%,white)}.progress-panel{margin-top:16px;padding-top:16px;border-top:1px solid var(--line)}.progress-row{display:flex;justify-content:space-between;gap:12px;padding:10px 0;border-bottom:1px solid var(--line)}.progress-phase,.training-phase,.requirement-phase{display:grid;gap:10px}.progress-phase+.progress-phase,.training-phase+.training-phase,.requirement-phase+.requirement-phase{margin-top:14px}.phase-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 10px;border-radius:12px;background:var(--badge-bg)}.phase-state{font-size:.86rem;font-weight:700;border-radius:999px;padding:6px 10px}.student-page{grid-column:1 / -1}.student-list-row{display:grid;gap:4px}.student-list-row span{color:var(--muted)}.student-detail-page{max-width:980px}.detail-tabs{margin-bottom:18px}.training-panel{display:grid;gap:12px}.training-row{display:grid;gap:10px;padding:14px;border:1px solid var(--line);border-radius:18px;background:#ffffff75}:root[data-theme=dark] .training-row{background:#ffffff0a}.training-row p{margin:4px 0 0;color:var(--muted)}.training-row-head{display:grid;grid-template-columns:minmax(0,1fr) 150px;gap:14px;align-items:end}.training-import-field{display:grid;gap:6px;color:var(--muted);font-size:.9rem}.training-meter{height:12px;overflow:hidden;border-radius:999px;background:var(--badge-bg)}.training-meter span{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,var(--teal),var(--accent))}.requirement-phase h3{margin:0;font-size:.95rem}.entity-item{background:#1112170d}.lesson-type-list{gap:12px}.lesson-type-row{display:flex;align-items:center;gap:12px;justify-content:space-between}.lesson-type-main{display:grid;gap:2px;flex:1 1 auto}.lesson-type-actions{display:flex;gap:8px;flex:0 0 auto}.reorder-button[disabled]{opacity:.45;cursor:default}.modal-shell{position:fixed;top:0;right:0;bottom:0;left:0;background:#11121747;display:grid;align-items:start;justify-items:center;padding:24px;overflow-y:auto;z-index:50}.modal-card,.login-card{width:min(720px,100%);max-height:calc(100dvh - 48px);border-radius:24px;padding:24px;background:#fffaf4;box-shadow:var(--shadow);border:1px solid rgba(17,18,23,.08);overflow-y:auto}.modal-card .modal-actions{position:sticky;bottom:0;padding-top:12px;background:linear-gradient(180deg,#fffaf400,#fffaf4 24px)}.stack-form{display:grid;gap:14px}.stack-form label{display:grid;gap:8px}.checkbox-row{display:flex!important;align-items:center;gap:10px}.checkbox-row span{font-weight:600}input,select,textarea{width:100%;border-radius:14px;border:1px solid rgba(17,18,23,.12);padding:12px 14px;background:var(--input-bg);color:var(--ink)}input[type=checkbox]{width:auto}.login-shell{min-height:100vh;display:grid;place-items:center;padding:24px}.login-card{background:linear-gradient(180deg,color-mix(in srgb,var(--panel) 96%,white),color-mix(in srgb,var(--surface-soft) 92%,transparent)),repeating-linear-gradient(90deg,rgba(17,18,23,.04),rgba(17,18,23,.04) 1px,transparent 1px,transparent 24px)}.login-copy{max-width:38ch}.inline-error,.flash-error{background:#8f1d281f;color:#6b1820;border-radius:14px;padding:12px 14px}.flash-error{margin-bottom:14px}.loading-panel{min-height:50vh;display:grid;place-items:center}.compact{padding:8px 12px}@media (max-width: 960px){.app-shell{padding:14px}.content-grid{grid-template-columns:1fr}.modal-shell{padding:14px}.modal-card{max-height:calc(100dvh - 28px);padding:18px}.calendar-grid{grid-template-columns:72px 1fr}.time-editor-grid{grid-template-columns:1fr}.time-dial-popover{position:static;width:100%}.time-dial-face{width:min(280px,100%);height:min(280px,calc(100vw - 88px))}.planner-panel{position:static}.topbar{flex-direction:column;align-items:flex-start}.location-search,.coordinate-grid,.training-row-head{display:grid;grid-template-columns:1fr}} diff --git a/www/fahrschultermin.de/public/assets/assets/main-F76uNDS1.js b/www/fahrschultermin.de/public/assets/assets/main-F76uNDS1.js new file mode 100644 index 0000000..0310b69 --- /dev/null +++ b/www/fahrschultermin.de/public/assets/assets/main-F76uNDS1.js @@ -0,0 +1,44 @@ +function Zc(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Do={exports:{}},jl={},Lo={exports:{}},U={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var hr=Symbol.for("react.element"),Jc=Symbol.for("react.portal"),ed=Symbol.for("react.fragment"),td=Symbol.for("react.strict_mode"),nd=Symbol.for("react.profiler"),rd=Symbol.for("react.provider"),ld=Symbol.for("react.context"),id=Symbol.for("react.forward_ref"),sd=Symbol.for("react.suspense"),ad=Symbol.for("react.memo"),od=Symbol.for("react.lazy"),ya=Symbol.iterator;function ud(e){return e===null||typeof e!="object"?null:(e=ya&&e[ya]||e["@@iterator"],typeof e=="function"?e:null)}var Fo={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Ro=Object.assign,$o={};function En(e,t,n){this.props=e,this.context=t,this.refs=$o,this.updater=n||Fo}En.prototype.isReactComponent={};En.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};En.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function Oo(){}Oo.prototype=En.prototype;function xs(e,t,n){this.props=e,this.context=t,this.refs=$o,this.updater=n||Fo}var ws=xs.prototype=new Oo;ws.constructor=xs;Ro(ws,En.prototype);ws.isPureReactComponent=!0;var xa=Array.isArray,Io=Object.prototype.hasOwnProperty,Ss={current:null},Bo={key:!0,ref:!0,__self:!0,__source:!0};function Ao(e,t,n){var r,l={},i=null,s=null;if(t!=null)for(r in t.ref!==void 0&&(s=t.ref),t.key!==void 0&&(i=""+t.key),t)Io.call(t,r)&&!Bo.hasOwnProperty(r)&&(l[r]=t[r]);var o=arguments.length-2;if(o===1)l.children=n;else if(1>>1,Q=P[q];if(0>>1;ql(Fe,I))_el(vt,Fe)?(P[q]=vt,P[_e]=I,q=_e):(P[q]=Fe,P[Y]=I,q=Y);else if(_el(vt,I))P[q]=vt,P[_e]=I,q=_e;else break e}}return O}function l(P,O){var I=P.sortIndex-O.sortIndex;return I!==0?I:P.id-O.id}if(typeof performance=="object"&&typeof performance.now=="function"){var i=performance;e.unstable_now=function(){return i.now()}}else{var s=Date,o=s.now();e.unstable_now=function(){return s.now()-o}}var u=[],d=[],g=1,h=null,m=3,x=!1,w=!1,v=!1,D=typeof setTimeout=="function"?setTimeout:null,f=typeof clearTimeout=="function"?clearTimeout:null,c=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function p(P){for(var O=n(d);O!==null;){if(O.callback===null)r(d);else if(O.startTime<=P)r(d),O.sortIndex=O.expirationTime,t(u,O);else break;O=n(d)}}function y(P){if(v=!1,p(P),!w)if(n(u)!==null)w=!0,V(j);else{var O=n(d);O!==null&&J(y,O.startTime-P)}}function j(P,O){w=!1,v&&(v=!1,f(T),T=-1),x=!0;var I=m;try{for(p(O),h=n(u);h!==null&&(!(h.expirationTime>O)||P&&!W());){var q=h.callback;if(typeof q=="function"){h.callback=null,m=h.priorityLevel;var Q=q(h.expirationTime<=O);O=e.unstable_now(),typeof Q=="function"?h.callback=Q:h===n(u)&&r(u),p(O)}else r(u);h=n(u)}if(h!==null)var it=!0;else{var Y=n(d);Y!==null&&J(y,Y.startTime-O),it=!1}return it}finally{h=null,m=I,x=!1}}var M=!1,S=null,T=-1,$=5,L=-1;function W(){return!(e.unstable_now()-L<$)}function C(){if(S!==null){var P=e.unstable_now();L=P;var O=!0;try{O=S(!0,P)}finally{O?R():(M=!1,S=null)}}else M=!1}var R;if(typeof c=="function")R=function(){c(C)};else if(typeof MessageChannel<"u"){var A=new MessageChannel,N=A.port2;A.port1.onmessage=C,R=function(){N.postMessage(null)}}else R=function(){D(C,0)};function V(P){S=P,M||(M=!0,R())}function J(P,O){T=D(function(){P(e.unstable_now())},O)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(P){P.callback=null},e.unstable_continueExecution=function(){w||x||(w=!0,V(j))},e.unstable_forceFrameRate=function(P){0>P||125q?(P.sortIndex=I,t(d,P),n(u)===null&&P===n(d)&&(v?(f(T),T=-1):v=!0,J(y,I-q))):(P.sortIndex=Q,t(u,P),w||x||(w=!0,V(j))),P},e.unstable_shouldYield=W,e.unstable_wrapCallback=function(P){var O=m;return function(){var I=m;m=O;try{return P.apply(this,arguments)}finally{m=I}}}})(Qo);Wo.exports=Qo;var Sd=Wo.exports;/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var _d=F,Ie=Sd;function k(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Ni=Object.prototype.hasOwnProperty,kd=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,Sa={},_a={};function Nd(e){return Ni.call(_a,e)?!0:Ni.call(Sa,e)?!1:kd.test(e)?_a[e]=!0:(Sa[e]=!0,!1)}function jd(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Ed(e,t,n,r){if(t===null||typeof t>"u"||jd(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function Ce(e,t,n,r,l,i,s){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i,this.removeEmptyString=s}var pe={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){pe[e]=new Ce(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];pe[t]=new Ce(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){pe[e]=new Ce(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){pe[e]=new Ce(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){pe[e]=new Ce(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){pe[e]=new Ce(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){pe[e]=new Ce(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){pe[e]=new Ce(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){pe[e]=new Ce(e,5,!1,e.toLowerCase(),null,!1,!1)});var ks=/[\-:]([a-z])/g;function Ns(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(ks,Ns);pe[t]=new Ce(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(ks,Ns);pe[t]=new Ce(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(ks,Ns);pe[t]=new Ce(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){pe[e]=new Ce(e,1,!1,e.toLowerCase(),null,!1,!1)});pe.xlinkHref=new Ce("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){pe[e]=new Ce(e,1,!1,e.toLowerCase(),null,!0,!0)});function js(e,t,n,r){var l=pe.hasOwnProperty(t)?pe[t]:null;(l!==null?l.type!==0:r||!(2o||l[s]!==i[o]){var u=` +`+l[s].replace(" at new "," at ");return e.displayName&&u.includes("")&&(u=u.replace("",e.displayName)),u}while(1<=s&&0<=o);break}}}finally{Gl=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?On(e):""}function Cd(e){switch(e.tag){case 5:return On(e.type);case 16:return On("Lazy");case 13:return On("Suspense");case 19:return On("SuspenseList");case 0:case 2:case 15:return e=Zl(e.type,!1),e;case 11:return e=Zl(e.type.render,!1),e;case 1:return e=Zl(e.type,!0),e;default:return""}}function Mi(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case tn:return"Fragment";case en:return"Portal";case ji:return"Profiler";case Es:return"StrictMode";case Ei:return"Suspense";case Ci:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case qo:return(e.displayName||"Context")+".Consumer";case bo:return(e._context.displayName||"Context")+".Provider";case Cs:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Ms:return t=e.displayName||null,t!==null?t:Mi(e.type)||"Memo";case yt:t=e._payload,e=e._init;try{return Mi(e(t))}catch{}}return null}function Md(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return Mi(t);case 8:return t===Es?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function Ft(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function Xo(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Pd(e){var t=Xo(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,i=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(s){r=""+s,i.call(this,s)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(s){r=""+s},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function kr(e){e._valueTracker||(e._valueTracker=Pd(e))}function Go(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=Xo(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function el(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Pi(e,t){var n=t.checked;return re({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Na(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=Ft(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Zo(e,t){t=t.checked,t!=null&&js(e,"checked",t,!1)}function Ti(e,t){Zo(e,t);var n=Ft(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?zi(e,t.type,n):t.hasOwnProperty("defaultValue")&&zi(e,t.type,Ft(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function ja(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function zi(e,t,n){(t!=="number"||el(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var In=Array.isArray;function pn(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=Nr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Jn(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Vn={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Td=["Webkit","ms","Moz","O"];Object.keys(Vn).forEach(function(e){Td.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Vn[t]=Vn[e]})});function nu(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||Vn.hasOwnProperty(e)&&Vn[e]?(""+t).trim():t+"px"}function ru(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=nu(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var zd=re({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Fi(e,t){if(t){if(zd[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(k(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(k(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(k(61))}if(t.style!=null&&typeof t.style!="object")throw Error(k(62))}}function Ri(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var $i=null;function Ps(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Oi=null,mn=null,hn=null;function Ma(e){if(e=yr(e)){if(typeof Oi!="function")throw Error(k(280));var t=e.stateNode;t&&(t=Tl(t),Oi(e.stateNode,e.type,t))}}function lu(e){mn?hn?hn.push(e):hn=[e]:mn=e}function iu(){if(mn){var e=mn,t=hn;if(hn=mn=null,Ma(e),t)for(e=0;e>>=0,e===0?32:31-(Vd(e)/Hd|0)|0}var jr=64,Er=4194304;function Bn(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function ll(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,i=e.pingedLanes,s=n&268435455;if(s!==0){var o=s&~l;o!==0?r=Bn(o):(i&=s,i!==0&&(r=Bn(i)))}else s=n&~l,s!==0?r=Bn(s):i!==0&&(r=Bn(i));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,i=t&-t,l>=i||l===16&&(i&4194240)!==0))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function vr(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Ge(t),e[t]=n}function bd(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Wn),Oa=" ",Ia=!1;function ju(e,t){switch(e){case"keyup":return _f.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Eu(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var nn=!1;function Nf(e,t){switch(e){case"compositionend":return Eu(t);case"keypress":return t.which!==32?null:(Ia=!0,Oa);case"textInput":return e=t.data,e===Oa&&Ia?null:e;default:return null}}function jf(e,t){if(nn)return e==="compositionend"||!Os&&ju(e,t)?(e=ku(),Hr=Fs=kt=null,nn=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=Va(n)}}function Tu(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Tu(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function zu(){for(var e=window,t=el();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=el(e.document)}return t}function Is(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Ff(e){var t=zu(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Tu(n.ownerDocument.documentElement,n)){if(r!==null&&Is(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,i=Math.min(r.start,l);r=r.end===void 0?i:Math.min(r.end,l),!e.extend&&i>r&&(l=r,r=i,i=l),l=Ha(n,i);var s=Ha(n,r);l&&s&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==s.node||e.focusOffset!==s.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),i>r?(e.addRange(t),e.extend(s.node,s.offset)):(t.setEnd(s.node,s.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,rn=null,Hi=null,Kn=null,Wi=!1;function Wa(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Wi||rn==null||rn!==el(r)||(r=rn,"selectionStart"in r&&Is(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Kn&&ir(Kn,r)||(Kn=r,r=al(Hi,"onSelect"),0an||(e.current=Xi[an],Xi[an]=null,an--)}function X(e,t){an++,Xi[an]=e.current,e.current=t}var Rt={},Se=Ot(Rt),ze=Ot(!1),Kt=Rt;function wn(e,t){var n=e.type.contextTypes;if(!n)return Rt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},i;for(i in n)l[i]=t[i];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function De(e){return e=e.childContextTypes,e!=null}function ul(){Z(ze),Z(Se)}function Ga(e,t,n){if(Se.current!==Rt)throw Error(k(168));X(Se,t),X(ze,n)}function Au(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(k(108,Md(e)||"Unknown",l));return re({},n,r)}function cl(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Rt,Kt=Se.current,X(Se,e),X(ze,ze.current),!0}function Za(e,t,n){var r=e.stateNode;if(!r)throw Error(k(169));n?(e=Au(e,t,Kt),r.__reactInternalMemoizedMergedChildContext=e,Z(ze),Z(Se),X(Se,e)):Z(ze),X(ze,n)}var at=null,zl=!1,fi=!1;function Uu(e){at===null?at=[e]:at.push(e)}function Kf(e){zl=!0,Uu(e)}function It(){if(!fi&&at!==null){fi=!0;var e=0,t=K;try{var n=at;for(K=1;e>=s,l-=s,ot=1<<32-Ge(t)+l|n<T?($=S,S=null):$=S.sibling;var L=m(f,S,p[T],y);if(L===null){S===null&&(S=$);break}e&&S&&L.alternate===null&&t(f,S),c=i(L,c,T),M===null?j=L:M.sibling=L,M=L,S=$}if(T===p.length)return n(f,S),ee&&Bt(f,T),j;if(S===null){for(;TT?($=S,S=null):$=S.sibling;var W=m(f,S,L.value,y);if(W===null){S===null&&(S=$);break}e&&S&&W.alternate===null&&t(f,S),c=i(W,c,T),M===null?j=W:M.sibling=W,M=W,S=$}if(L.done)return n(f,S),ee&&Bt(f,T),j;if(S===null){for(;!L.done;T++,L=p.next())L=h(f,L.value,y),L!==null&&(c=i(L,c,T),M===null?j=L:M.sibling=L,M=L);return ee&&Bt(f,T),j}for(S=r(f,S);!L.done;T++,L=p.next())L=x(S,f,T,L.value,y),L!==null&&(e&&L.alternate!==null&&S.delete(L.key===null?T:L.key),c=i(L,c,T),M===null?j=L:M.sibling=L,M=L);return e&&S.forEach(function(C){return t(f,C)}),ee&&Bt(f,T),j}function D(f,c,p,y){if(typeof p=="object"&&p!==null&&p.type===tn&&p.key===null&&(p=p.props.children),typeof p=="object"&&p!==null){switch(p.$$typeof){case _r:e:{for(var j=p.key,M=c;M!==null;){if(M.key===j){if(j=p.type,j===tn){if(M.tag===7){n(f,M.sibling),c=l(M,p.props.children),c.return=f,f=c;break e}}else if(M.elementType===j||typeof j=="object"&&j!==null&&j.$$typeof===yt&&to(j)===M.type){n(f,M.sibling),c=l(M,p.props),c.ref=Fn(f,M,p),c.return=f,f=c;break e}n(f,M);break}else t(f,M);M=M.sibling}p.type===tn?(c=Qt(p.props.children,f.mode,y,p.key),c.return=f,f=c):(y=Gr(p.type,p.key,p.props,null,f.mode,y),y.ref=Fn(f,c,p),y.return=f,f=y)}return s(f);case en:e:{for(M=p.key;c!==null;){if(c.key===M)if(c.tag===4&&c.stateNode.containerInfo===p.containerInfo&&c.stateNode.implementation===p.implementation){n(f,c.sibling),c=l(c,p.children||[]),c.return=f,f=c;break e}else{n(f,c);break}else t(f,c);c=c.sibling}c=wi(p,f.mode,y),c.return=f,f=c}return s(f);case yt:return M=p._init,D(f,c,M(p._payload),y)}if(In(p))return w(f,c,p,y);if(Pn(p))return v(f,c,p,y);Lr(f,p)}return typeof p=="string"&&p!==""||typeof p=="number"?(p=""+p,c!==null&&c.tag===6?(n(f,c.sibling),c=l(c,p),c.return=f,f=c):(n(f,c),c=xi(p,f.mode,y),c.return=f,f=c),s(f)):n(f,c)}return D}var _n=Qu(!0),Ku=Qu(!1),pl=Ot(null),ml=null,cn=null,Vs=null;function Hs(){Vs=cn=ml=null}function Ws(e){var t=pl.current;Z(pl),e._currentValue=t}function Ji(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function gn(e,t){ml=e,Vs=cn=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(Te=!0),e.firstContext=null)}function Qe(e){var t=e._currentValue;if(Vs!==e)if(e={context:e,memoizedValue:t,next:null},cn===null){if(ml===null)throw Error(k(308));cn=e,ml.dependencies={lanes:0,firstContext:e}}else cn=cn.next=e;return t}var Vt=null;function Qs(e){Vt===null?Vt=[e]:Vt.push(e)}function bu(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,Qs(t)):(n.next=l.next,l.next=n),t.interleaved=n,pt(e,r)}function pt(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var xt=!1;function Ks(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function qu(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function ct(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function Tt(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,H&2){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,pt(e,n)}return l=r.interleaved,l===null?(t.next=t,Qs(r)):(t.next=l.next,l.next=t),r.interleaved=t,pt(e,n)}function Qr(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,zs(e,n)}}function no(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,i=null;if(n=n.firstBaseUpdate,n!==null){do{var s={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};i===null?l=i=s:i=i.next=s,n=n.next}while(n!==null);i===null?l=i=t:i=i.next=t}else l=i=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:i,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function hl(e,t,n,r){var l=e.updateQueue;xt=!1;var i=l.firstBaseUpdate,s=l.lastBaseUpdate,o=l.shared.pending;if(o!==null){l.shared.pending=null;var u=o,d=u.next;u.next=null,s===null?i=d:s.next=d,s=u;var g=e.alternate;g!==null&&(g=g.updateQueue,o=g.lastBaseUpdate,o!==s&&(o===null?g.firstBaseUpdate=d:o.next=d,g.lastBaseUpdate=u))}if(i!==null){var h=l.baseState;s=0,g=d=u=null,o=i;do{var m=o.lane,x=o.eventTime;if((r&m)===m){g!==null&&(g=g.next={eventTime:x,lane:0,tag:o.tag,payload:o.payload,callback:o.callback,next:null});e:{var w=e,v=o;switch(m=t,x=n,v.tag){case 1:if(w=v.payload,typeof w=="function"){h=w.call(x,h,m);break e}h=w;break e;case 3:w.flags=w.flags&-65537|128;case 0:if(w=v.payload,m=typeof w=="function"?w.call(x,h,m):w,m==null)break e;h=re({},h,m);break e;case 2:xt=!0}}o.callback!==null&&o.lane!==0&&(e.flags|=64,m=l.effects,m===null?l.effects=[o]:m.push(o))}else x={eventTime:x,lane:m,tag:o.tag,payload:o.payload,callback:o.callback,next:null},g===null?(d=g=x,u=h):g=g.next=x,s|=m;if(o=o.next,o===null){if(o=l.shared.pending,o===null)break;m=o,o=m.next,m.next=null,l.lastBaseUpdate=m,l.shared.pending=null}}while(!0);if(g===null&&(u=h),l.baseState=u,l.firstBaseUpdate=d,l.lastBaseUpdate=g,t=l.shared.interleaved,t!==null){l=t;do s|=l.lane,l=l.next;while(l!==t)}else i===null&&(l.shared.lanes=0);Yt|=s,e.lanes=s,e.memoizedState=h}}function ro(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=mi.transition;mi.transition={};try{e(!1),t()}finally{K=n,mi.transition=r}}function dc(){return Ke().memoizedState}function Xf(e,t,n){var r=Dt(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},fc(e))pc(t,n);else if(n=bu(e,t,n,r),n!==null){var l=je();Ze(n,e,r,l),mc(n,t,r)}}function Gf(e,t,n){var r=Dt(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(fc(e))pc(t,l);else{var i=e.alternate;if(e.lanes===0&&(i===null||i.lanes===0)&&(i=t.lastRenderedReducer,i!==null))try{var s=t.lastRenderedState,o=i(s,n);if(l.hasEagerState=!0,l.eagerState=o,Je(o,s)){var u=t.interleaved;u===null?(l.next=l,Qs(t)):(l.next=u.next,u.next=l),t.interleaved=l;return}}catch{}finally{}n=bu(e,t,l,r),n!==null&&(l=je(),Ze(n,e,r,l),mc(n,t,r))}}function fc(e){var t=e.alternate;return e===ne||t!==null&&t===ne}function pc(e,t){bn=gl=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function mc(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,zs(e,n)}}var yl={readContext:Qe,useCallback:ve,useContext:ve,useEffect:ve,useImperativeHandle:ve,useInsertionEffect:ve,useLayoutEffect:ve,useMemo:ve,useReducer:ve,useRef:ve,useState:ve,useDebugValue:ve,useDeferredValue:ve,useTransition:ve,useMutableSource:ve,useSyncExternalStore:ve,useId:ve,unstable_isNewReconciler:!1},Zf={readContext:Qe,useCallback:function(e,t){return tt().memoizedState=[e,t===void 0?null:t],e},useContext:Qe,useEffect:io,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,br(4194308,4,sc.bind(null,t,e),n)},useLayoutEffect:function(e,t){return br(4194308,4,e,t)},useInsertionEffect:function(e,t){return br(4,2,e,t)},useMemo:function(e,t){var n=tt();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=tt();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=Xf.bind(null,ne,e),[r.memoizedState,e]},useRef:function(e){var t=tt();return e={current:e},t.memoizedState=e},useState:lo,useDebugValue:ea,useDeferredValue:function(e){return tt().memoizedState=e},useTransition:function(){var e=lo(!1),t=e[0];return e=Yf.bind(null,e[1]),tt().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=ne,l=tt();if(ee){if(n===void 0)throw Error(k(407));n=n()}else{if(n=t(),ce===null)throw Error(k(349));qt&30||Zu(r,t,n)}l.memoizedState=n;var i={value:n,getSnapshot:t};return l.queue=i,io(ec.bind(null,r,i,e),[e]),r.flags|=2048,pr(9,Ju.bind(null,r,i,n,t),void 0,null),n},useId:function(){var e=tt(),t=ce.identifierPrefix;if(ee){var n=ut,r=ot;n=(r&~(1<<32-Ge(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=dr++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=s.createElement(n,{is:r.is}):(e=s.createElement(n),n==="select"&&(s=e,r.multiple?s.multiple=!0:r.size&&(s.size=r.size))):e=s.createElementNS(e,n),e[nt]=t,e[or]=r,Nc(e,t,!1,!1),t.stateNode=e;e:{switch(s=Ri(n,r),n){case"dialog":G("cancel",e),G("close",e),l=r;break;case"iframe":case"object":case"embed":G("load",e),l=r;break;case"video":case"audio":for(l=0;ljn&&(t.flags|=128,r=!0,Rn(i,!1),t.lanes=4194304)}else{if(!r)if(e=vl(s),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),Rn(i,!0),i.tail===null&&i.tailMode==="hidden"&&!s.alternate&&!ee)return ge(t),null}else 2*ie()-i.renderingStartTime>jn&&n!==1073741824&&(t.flags|=128,r=!0,Rn(i,!1),t.lanes=4194304);i.isBackwards?(s.sibling=t.child,t.child=s):(n=i.last,n!==null?n.sibling=s:t.child=s,i.last=s)}return i.tail!==null?(t=i.tail,i.rendering=t,i.tail=t.sibling,i.renderingStartTime=ie(),t.sibling=null,n=te.current,X(te,r?n&1|2:n&1),t):(ge(t),null);case 22:case 23:return sa(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?Re&1073741824&&(ge(t),t.subtreeFlags&6&&(t.flags|=8192)):ge(t),null;case 24:return null;case 25:return null}throw Error(k(156,t.tag))}function sp(e,t){switch(As(t),t.tag){case 1:return De(t.type)&&ul(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return kn(),Z(ze),Z(Se),Ys(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return qs(t),null;case 13:if(Z(te),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(k(340));Sn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return Z(te),null;case 4:return kn(),null;case 10:return Ws(t.type._context),null;case 22:case 23:return sa(),null;case 24:return null;default:return null}}var Rr=!1,we=!1,ap=typeof WeakSet=="function"?WeakSet:Set,z=null;function dn(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){le(e,t,r)}else n.current=null}function os(e,t,n){try{n()}catch(r){le(e,t,r)}}var go=!1;function op(e,t){if(Qi=il,e=zu(),Is(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,i=r.focusNode;r=r.focusOffset;try{n.nodeType,i.nodeType}catch{n=null;break e}var s=0,o=-1,u=-1,d=0,g=0,h=e,m=null;t:for(;;){for(var x;h!==n||l!==0&&h.nodeType!==3||(o=s+l),h!==i||r!==0&&h.nodeType!==3||(u=s+r),h.nodeType===3&&(s+=h.nodeValue.length),(x=h.firstChild)!==null;)m=h,h=x;for(;;){if(h===e)break t;if(m===n&&++d===l&&(o=s),m===i&&++g===r&&(u=s),(x=h.nextSibling)!==null)break;h=m,m=h.parentNode}h=x}n=o===-1||u===-1?null:{start:o,end:u}}else n=null}n=n||{start:0,end:0}}else n=null;for(Ki={focusedElem:e,selectionRange:n},il=!1,z=t;z!==null;)if(t=z,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,z=e;else for(;z!==null;){t=z;try{var w=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(w!==null){var v=w.memoizedProps,D=w.memoizedState,f=t.stateNode,c=f.getSnapshotBeforeUpdate(t.elementType===t.type?v:qe(t.type,v),D);f.__reactInternalSnapshotBeforeUpdate=c}break;case 3:var p=t.stateNode.containerInfo;p.nodeType===1?p.textContent="":p.nodeType===9&&p.documentElement&&p.removeChild(p.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(k(163))}}catch(y){le(t,t.return,y)}if(e=t.sibling,e!==null){e.return=t.return,z=e;break}z=t.return}return w=go,go=!1,w}function qn(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var i=l.destroy;l.destroy=void 0,i!==void 0&&os(t,n,i)}l=l.next}while(l!==r)}}function Fl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function us(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function Cc(e){var t=e.alternate;t!==null&&(e.alternate=null,Cc(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[nt],delete t[or],delete t[Yi],delete t[Wf],delete t[Qf])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Mc(e){return e.tag===5||e.tag===3||e.tag===4}function yo(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Mc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function cs(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=ol));else if(r!==4&&(e=e.child,e!==null))for(cs(e,t,n),e=e.sibling;e!==null;)cs(e,t,n),e=e.sibling}function ds(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(ds(e,t,n),e=e.sibling;e!==null;)ds(e,t,n),e=e.sibling}var de=null,Ye=!1;function gt(e,t,n){for(n=n.child;n!==null;)Pc(e,t,n),n=n.sibling}function Pc(e,t,n){if(rt&&typeof rt.onCommitFiberUnmount=="function")try{rt.onCommitFiberUnmount(El,n)}catch{}switch(n.tag){case 5:we||dn(n,t);case 6:var r=de,l=Ye;de=null,gt(e,t,n),de=r,Ye=l,de!==null&&(Ye?(e=de,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):de.removeChild(n.stateNode));break;case 18:de!==null&&(Ye?(e=de,n=n.stateNode,e.nodeType===8?di(e.parentNode,n):e.nodeType===1&&di(e,n),rr(e)):di(de,n.stateNode));break;case 4:r=de,l=Ye,de=n.stateNode.containerInfo,Ye=!0,gt(e,t,n),de=r,Ye=l;break;case 0:case 11:case 14:case 15:if(!we&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var i=l,s=i.destroy;i=i.tag,s!==void 0&&(i&2||i&4)&&os(n,t,s),l=l.next}while(l!==r)}gt(e,t,n);break;case 1:if(!we&&(dn(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(o){le(n,t,o)}gt(e,t,n);break;case 21:gt(e,t,n);break;case 22:n.mode&1?(we=(r=we)||n.memoizedState!==null,gt(e,t,n),we=r):gt(e,t,n);break;default:gt(e,t,n)}}function xo(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new ap),t.forEach(function(r){var l=gp.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function be(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=s),r&=~i}if(r=l,r=ie()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*cp(r/1960))-r,10e?16:e,Nt===null)var r=!1;else{if(e=Nt,Nt=null,Sl=0,H&6)throw Error(k(331));var l=H;for(H|=4,z=e.current;z!==null;){var i=z,s=i.child;if(z.flags&16){var o=i.deletions;if(o!==null){for(var u=0;uie()-la?Wt(e,0):ra|=n),Le(e,t)}function Oc(e,t){t===0&&(e.mode&1?(t=Er,Er<<=1,!(Er&130023424)&&(Er=4194304)):t=1);var n=je();e=pt(e,t),e!==null&&(vr(e,t,n),Le(e,n))}function vp(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Oc(e,n)}function gp(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(k(314))}r!==null&&r.delete(t),Oc(e,n)}var Ic;Ic=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||ze.current)Te=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return Te=!1,lp(e,t,n);Te=!!(e.flags&131072)}else Te=!1,ee&&t.flags&1048576&&Vu(t,fl,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;qr(e,t),e=t.pendingProps;var l=wn(t,Se.current);gn(t,n),l=Gs(null,t,r,e,l,n);var i=Zs();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,De(r)?(i=!0,cl(t)):i=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,Ks(t),l.updater=Ll,t.stateNode=l,l._reactInternals=t,ts(t,r,e,n),t=ls(null,t,r,!0,i,n)):(t.tag=0,ee&&i&&Bs(t),Ne(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(qr(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=xp(r),e=qe(r,e),l){case 0:t=rs(null,t,r,e,n);break e;case 1:t=mo(null,t,r,e,n);break e;case 11:t=fo(null,t,r,e,n);break e;case 14:t=po(null,t,r,qe(r.type,e),n);break e}throw Error(k(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:qe(r,l),rs(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:qe(r,l),mo(e,t,r,l,n);case 3:e:{if(Sc(t),e===null)throw Error(k(387));r=t.pendingProps,i=t.memoizedState,l=i.element,qu(e,t),hl(t,r,null,n);var s=t.memoizedState;if(r=s.element,i.isDehydrated)if(i={element:r,isDehydrated:!1,cache:s.cache,pendingSuspenseBoundaries:s.pendingSuspenseBoundaries,transitions:s.transitions},t.updateQueue.baseState=i,t.memoizedState=i,t.flags&256){l=Nn(Error(k(423)),t),t=ho(e,t,r,n,l);break e}else if(r!==l){l=Nn(Error(k(424)),t),t=ho(e,t,r,n,l);break e}else for($e=Pt(t.stateNode.containerInfo.firstChild),Oe=t,ee=!0,Xe=null,n=Ku(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(Sn(),r===l){t=mt(e,t,n);break e}Ne(e,t,r,n)}t=t.child}return t;case 5:return Yu(t),e===null&&Zi(t),r=t.type,l=t.pendingProps,i=e!==null?e.memoizedProps:null,s=l.children,bi(r,l)?s=null:i!==null&&bi(r,i)&&(t.flags|=32),wc(e,t),Ne(e,t,s,n),t.child;case 6:return e===null&&Zi(t),null;case 13:return _c(e,t,n);case 4:return bs(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=_n(t,null,r,n):Ne(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:qe(r,l),fo(e,t,r,l,n);case 7:return Ne(e,t,t.pendingProps,n),t.child;case 8:return Ne(e,t,t.pendingProps.children,n),t.child;case 12:return Ne(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,i=t.memoizedProps,s=l.value,X(pl,r._currentValue),r._currentValue=s,i!==null)if(Je(i.value,s)){if(i.children===l.children&&!ze.current){t=mt(e,t,n);break e}}else for(i=t.child,i!==null&&(i.return=t);i!==null;){var o=i.dependencies;if(o!==null){s=i.child;for(var u=o.firstContext;u!==null;){if(u.context===r){if(i.tag===1){u=ct(-1,n&-n),u.tag=2;var d=i.updateQueue;if(d!==null){d=d.shared;var g=d.pending;g===null?u.next=u:(u.next=g.next,g.next=u),d.pending=u}}i.lanes|=n,u=i.alternate,u!==null&&(u.lanes|=n),Ji(i.return,n,t),o.lanes|=n;break}u=u.next}}else if(i.tag===10)s=i.type===t.type?null:i.child;else if(i.tag===18){if(s=i.return,s===null)throw Error(k(341));s.lanes|=n,o=s.alternate,o!==null&&(o.lanes|=n),Ji(s,n,t),s=i.sibling}else s=i.child;if(s!==null)s.return=i;else for(s=i;s!==null;){if(s===t){s=null;break}if(i=s.sibling,i!==null){i.return=s.return,s=i;break}s=s.return}i=s}Ne(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,gn(t,n),l=Qe(l),r=r(l),t.flags|=1,Ne(e,t,r,n),t.child;case 14:return r=t.type,l=qe(r,t.pendingProps),l=qe(r.type,l),po(e,t,r,l,n);case 15:return yc(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:qe(r,l),qr(e,t),t.tag=1,De(r)?(e=!0,cl(t)):e=!1,gn(t,n),hc(t,r,l),ts(t,r,l,n),ls(null,t,r,!0,e,n);case 19:return kc(e,t,n);case 22:return xc(e,t,n)}throw Error(k(156,t.tag))};function Bc(e,t){return fu(e,t)}function yp(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function He(e,t,n,r){return new yp(e,t,n,r)}function oa(e){return e=e.prototype,!(!e||!e.isReactComponent)}function xp(e){if(typeof e=="function")return oa(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Cs)return 11;if(e===Ms)return 14}return 2}function Lt(e,t){var n=e.alternate;return n===null?(n=He(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Gr(e,t,n,r,l,i){var s=2;if(r=e,typeof e=="function")oa(e)&&(s=1);else if(typeof e=="string")s=5;else e:switch(e){case tn:return Qt(n.children,l,i,t);case Es:s=8,l|=8;break;case ji:return e=He(12,n,t,l|2),e.elementType=ji,e.lanes=i,e;case Ei:return e=He(13,n,t,l),e.elementType=Ei,e.lanes=i,e;case Ci:return e=He(19,n,t,l),e.elementType=Ci,e.lanes=i,e;case Yo:return $l(n,l,i,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case bo:s=10;break e;case qo:s=9;break e;case Cs:s=11;break e;case Ms:s=14;break e;case yt:s=16,r=null;break e}throw Error(k(130,e==null?e:typeof e,""))}return t=He(s,n,t,l),t.elementType=e,t.type=r,t.lanes=i,t}function Qt(e,t,n,r){return e=He(7,e,r,t),e.lanes=n,e}function $l(e,t,n,r){return e=He(22,e,r,t),e.elementType=Yo,e.lanes=n,e.stateNode={isHidden:!1},e}function xi(e,t,n){return e=He(6,e,null,t),e.lanes=n,e}function wi(e,t,n){return t=He(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function wp(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=ei(0),this.expirationTimes=ei(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=ei(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function ua(e,t,n,r,l,i,s,o,u){return e=new wp(e,t,n,o,u),t===1?(t=1,i===!0&&(t|=8)):t=0,i=He(3,null,null,t),e.current=i,i.stateNode=e,i.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Ks(i),e}function Sp(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(Hc)}catch(e){console.error(e)}}Hc(),Ho.exports=Be;var Ep=Ho.exports,Co=Ep;ki.createRoot=Co.createRoot,ki.hydrateRoot=Co.hydrateRoot;const Cp={"Content-Type":"application/json"};async function Ir(e,t={}){const n=await fetch(e,{credentials:"same-origin",headers:{...t.body?Cp:{},...t.headers||{}},...t});if(n.status===204)return null;const r=n.headers.get("content-type")||"",l=await n.text();let i=null;if(l!=="")if(r.includes("application/json"))try{i=JSON.parse(l)}catch{const s=new Error("Server lieferte ungueltiges JSON.");throw s.status=n.status,s.payload={raw:l},s}else i={raw:l};if(!n.ok){const s=(i==null?void 0:i.message)||(l===""?`Request failed with status ${n.status}`:"Server lieferte keine JSON-Antwort."),o=new Error(s);throw o.status=n.status,o.payload=i,o}return i}const ye={get:e=>Ir(e),post:(e,t)=>Ir(e,{method:"POST",body:JSON.stringify(t)}),patch:(e,t)=>Ir(e,{method:"PATCH",body:JSON.stringify(t)}),delete:e=>Ir(e,{method:"DELETE"})};function Mp(e){const t=()=>window.matchMedia(e).matches,[n,r]=F.useState(t);return F.useEffect(()=>{const l=window.matchMedia(e),i=s=>r(s.matches);return l.addEventListener("change",i),r(l.matches),()=>l.removeEventListener("change",i)},[e]),n}const Pp=[{id:"calendar",label:"Kalender",roles:["tenant_admin","dispatcher","instructor"]},{id:"students",label:"Fahrschueler",roles:["tenant_admin","dispatcher"]},{id:"templates",label:"Klassen",roles:["tenant_admin"]},{id:"lesson-types",label:"Stundenarten",roles:["tenant_admin"]},{id:"instructors",label:"Fahrlehrer",roles:["tenant_admin"]},{id:"tenant-profile",label:"Fahrschule",roles:["tenant_admin"]},{id:"users",label:"Benutzer",roles:["tenant_admin","superadmin"]},{id:"tenants",label:"Mandanten",roles:["superadmin"]}],Wc=[{value:"BW",label:"Baden-Wuerttemberg"},{value:"BY",label:"Bayern"},{value:"BE",label:"Berlin"},{value:"BB",label:"Brandenburg"},{value:"HB",label:"Bremen"},{value:"HH",label:"Hamburg"},{value:"HE",label:"Hessen"},{value:"MV",label:"Mecklenburg-Vorpommern"},{value:"NI",label:"Niedersachsen"},{value:"NW",label:"Nordrhein-Westfalen"},{value:"RP",label:"Rheinland-Pfalz"},{value:"SL",label:"Saarland"},{value:"SN",label:"Sachsen"},{value:"ST",label:"Sachsen-Anhalt"},{value:"SH",label:"Schleswig-Holstein"},{value:"TH",label:"Thueringen"}];function vs(e=new Date){const t=new Date(e),r=(t.getDay()+6)%7;return t.setDate(t.getDate()-r),t.setHours(0,0,0,0),t}function Gn(e,t){const n=new Date(e);return n.setDate(n.getDate()+t),n}function xe(e){const t=e.getFullYear(),n=String(e.getMonth()+1).padStart(2,"0"),r=String(e.getDate()).padStart(2,"0");return`${t}-${n}-${r}`}function Tp(e,t){return xe(e)===xe(t)}function Qc(e,t){const n=new Date(e),r=new Date(t);return n.setHours(0,0,0,0),r.setHours(0,0,0,0),n0?l:r,s=Number((e==null?void 0:e.fixed_duration)||0)===1,o=s?1:Math.max(1,Math.round(i/r));return{baseDuration:r,duration:i,units:o,isFixed:s}}function _t(e,t=!1){return new Intl.DateTimeFormat("de-DE",{weekday:t?void 0:"short",day:"2-digit",month:"2-digit",hour:t?"2-digit":void 0,minute:t?"2-digit":void 0}).format(new Date(e))}function zp(e){return new Intl.DateTimeFormat("de-DE",{weekday:"long",day:"2-digit",month:"2-digit",year:"numeric"}).format(new Date(e))}function Po(e){return new Intl.DateTimeFormat("de-DE",{hour:"2-digit",minute:"2-digit"}).format(new Date(e))}function To(e){const t=Math.max(0,Number(e||0)),n=Math.floor(t/60),r=t%60;return`${String(n).padStart(2,"0")}:${String(r).padStart(2,"0")}`}function Dp(e){return e>9*60?45:e>6*60?30:0}function Lp(e,t){const n=new Date(t);n.setHours(0,0,0,0);const r=new Date(t);r.setHours(24,0,0,0);const l=(e||[]).filter(x=>{if(x.status==="cancelled")return!1;const w=new Date(x.start_at),v=new Date(x.end_at);return wn}).map(x=>{const w=new Date(x.start_at),v=new Date(x.end_at);return{...x,visibleStart:w>n?w:n,visibleEnd:vx.visibleEnd>x.visibleStart).sort((x,w)=>x.visibleStart-w.visibleStart),i=l.reduce((x,w)=>w.category!=="student"?x:x+Math.max(0,Number(w.units||0)),0);if(!l.length)return{taughtUnits:i,workSpanMinutes:0,breakMinutes:0,requiredBreakMinutes:0,breakStatus:"ok"};const s=[];for(const x of l){const w={start:x.visibleStart,end:x.visibleEnd},v=s[s.length-1];if(!v||w.start>v.end){s.push(w);continue}w.end>v.end&&(v.end=w.end)}let o=0,u=0;s.forEach((x,w)=>{if(o+=Math.max(0,Math.round((x.end.getTime()-x.start.getTime())/6e4)),w===0)return;const v=s[w-1];u+=Math.max(0,Math.round((x.start.getTime()-v.end.getTime())/6e4))});const d=s[0].start,g=s[s.length-1].end,h=Math.max(0,Math.round((g.getTime()-d.getTime())/6e4)),m=Dp(o);return{taughtUnits:i,workSpanMinutes:h,breakMinutes:u,requiredBreakMinutes:m,breakStatus:u>=m?"ok":"warning"}}function Fp(e){return String(e||"").replace(/[^\d+]/g,"").replace(/^\+/,"")}function Rp(e,t,n,r){if(!n||!r)return"";const l=e!=null&&e.first_name?` ${e.first_name}`:"",i=t?`${t.first_name} ${t.last_name}`.trim():"dein Fahrlehrer";return`Hi${l}, die Fahrstunde ist am ${zp(n)} um ${Po(n)} Uhr und geht voraussichtlich bis ${Po(r)} Uhr. Gruss ${i}`}function $p(e,t){const n=[0,1,2,3,4,5,6],r=new Date(e),l=Gn(e,7);return{start:r,end:l,days:n.map(i=>Gn(e,i))}}function Op(e){const t=[];for(const n of e.conflicts||[])t.push(`- ${n.message}`);for(const n of e.rest_period_warnings||[])t.push(`- ${n.message}`);return t.join(` +`)}function Ip(e){return Object.fromEntries(e.map(t=>[t.id,t.progress||[]]))}function Bp(e){return{...e,requirements:(e.requirements||[]).map((t,n)=>({...t,sort_order:n,required_units:Number(t.required_units||0),phase_label:t.phase_label||""}))}}function Ap(e,t){return e?`${e}::${t}`:t}function Kc(e){if(!Number(e==null?void 0:e.is_combination))return[""];const t=[...new Set((e.requirements||[]).map(n=>n.phase_label||"").filter(Boolean))];return t.length>0?t:["C","CE"]}function Zr(e,t){const n=new Set(e.lesson_type_ids&&e.lesson_type_ids.length>0?e.lesson_type_ids.map(s=>Number(s)):t.filter(s=>s.category==="student").map(s=>Number(s.id))),r=new Map((e.requirements||[]).map(s=>[s.requirement_key,s])),l=Kc(e),i=[];return t.filter(s=>s.category==="student"&&Number(s.is_counted)===1&&n.has(Number(s.id))).forEach((s,o)=>{const u=s.effective_requirement_key||s.requirement_key||`lesson_type_${s.id}`;l.forEach((d,g)=>{const h=Ap(d,u),m=r.get(h)||(g===0?r.get(u):null);i.push({requirement_key:h,phase_label:d,base_requirement_key:u,label:s.name,required_units:Number((m==null?void 0:m.required_units)||0),sort_order:o+g*1e3,lesson_type_id:Number(s.id),color:s.color})})}),i}function pa(e){const t=[];for(const n of e||[]){const r=n.phase_label||"";let l=t.find(i=>i.phase_label===r);l||(l={phase_label:r,rows:[]},t.push(l)),l.rows.push(n)}return t}function bc(e){const t=e.reduce((r,l)=>r+Number(l.required_units||0),0),n=e.reduce((r,l)=>r+Number(l.open_units||0),0);return{required:t,open:n,status:n>0?"missing":n<0?"over":"complete"}}function Jr(e){return Number(e)>0?{className:"is-missing",text:`${e} offen`}:Number(e)<0?{className:"is-over",text:`${Math.abs(e)} ueberplant`}:{className:"is-complete",text:"0 offen"}}function qc(e,t){return e.category!=="student"||!t?!0:(e.visible_template_ids||[]).includes(t)}function Up(e,t,n){if(!t)return e;const r=n.find(l=>Number(l.id)===Number(t));return r?e.filter(l=>l.category==="student"&&qc(l,Number(r.template_id))):e}function Yc(e){return{student:"Fahrschueler",private:"Privat",work:"Sonstige Arbeiten",theory:"Theorie",other:"Sonstiges",blocked:"Sperre"}[e]||e}function Vp(){var _,b;const e=Mp("(max-width: 960px)"),[t,n]=F.useState({user:null}),[r,l]=F.useState(null),[i,s]=F.useState(!0),[o,u]=F.useState("calendar"),[d,g]=F.useState(vs()),[h,m]=F.useState(null),[x,w]=F.useState("student"),[v,D]=F.useState(null),[f,c]=F.useState(null),[p,y]=F.useState(""),[j,M]=F.useState(()=>window.localStorage.getItem("calendar-theme")||"light"),S=F.useMemo(()=>$p(d),[d]);async function T(){try{const E=await ye.get("/api/v1/auth/me");return n(E),E.user&&await $(E.user,S.start,S.end),E}catch(E){return y(E.message),{user:null}}finally{s(!1)}}async function $(E=t.user,B=S.start,me=S.end){var he;if(!E)return;const ke=new URLSearchParams({from:B.toISOString(),to:me.toISOString()}),Me=await ye.get(`/api/v1/bootstrap?${ke.toString()}`);l(Me),!h&&((he=Me.students)!=null&&he.length)&&m(Me.students[0].id)}F.useEffect(()=>{T()},[]),F.useEffect(()=>{t.user&&t.user.role!=="superadmin"&&$(t.user,S.start,S.end).catch(E=>y(E.message))},[d]),F.useEffect(()=>{document.documentElement.setAttribute("data-theme",j),window.localStorage.setItem("calendar-theme",j)},[j]);const L=(r==null?void 0:r.students)||[],W=(r==null?void 0:r.lessonTypes)||[],C=(r==null?void 0:r.instructors)||[],R=(r==null?void 0:r.templates)||[],A=(r==null?void 0:r.users)||[],N=(r==null?void 0:r.tenants)||[],V=(r==null?void 0:r.appointments)||[],J=(r==null?void 0:r.nextAppointment)||null,P=(r==null?void 0:r.sunTimes)||{},O=(r==null?void 0:r.dayMeta)||{},I=L.find(E=>E.id===h)||null,q=Ip(L),Q=Pp.filter(E=>{var B;return E.roles.includes((B=t.user)==null?void 0:B.role)});F.useEffect(()=>{Q.length&&!Q.some(E=>E.id===o)&&u(Q[0].id)},[o,Q]);async function it(E){y("");try{await ye.post("/api/v1/auth/login",E);const B=await T();B!=null&&B.user||y("Login war erfolgreich, aber die Session konnte nicht geladen werden. Bitte Server-Session und Cookies pruefen.")}catch(B){y(B.message)}}async function Y(){await ye.post("/api/v1/auth/logout",{}),window.location.reload()}async function Fe(E,B,me=null){me?await ye.patch(`${E}/${me}`,B):await ye.post(E,B),await $(t.user,S.start,S.end)}async function _e(E,B){await ye.delete(`${E}/${B}`),await $(t.user,S.start,S.end)}async function vt(E,B){await ye.patch(`/api/v1/students/${E}/training`,{rows:B}),await $(t.user,S.start,S.end)}async function Wl(E){E.preventDefault();const B=W.find(Me=>Number(Me.id)===Number(v==null?void 0:v.lesson_type_id)),me=ys(B,v==null?void 0:v.start_at,v==null?void 0:v.end_at);if(!(v!=null&&v.start_at)||!(v!=null&&v.end_at)||Ul(v.start_at,v.end_at)<=0){y("Bitte eine gueltige Start- und Endzeit waehlen.");return}const ke={student_id:v.student_id?Number(v.student_id):null,lesson_type_id:Number(v.lesson_type_id),instructor_id:Number(v.instructor_id),start_at:new Date(v.start_at).toISOString(),end_at:new Date(v.end_at).toISOString(),units:me.units,notes:v.notes,status:v.status,requirement_phase:v.requirement_phase||"",warning_acknowledged:!1};try{f?await ye.patch(`/api/v1/appointments/${f}`,ke):await ye.post("/api/v1/appointments",ke),D(null),c(null),await $(t.user,S.start,S.end)}catch(Me){Me.status===409?window.confirm(`Warnungen beim Einplanen: +${Op(Me.payload.warnings)} + +Trotzdem speichern?`)&&(ke.warning_acknowledged=!0,f?await ye.patch(`/api/v1/appointments/${f}`,ke):await ye.post("/api/v1/appointments",ke),D(null),c(null),await $(t.user,S.start,S.end)):y(Me.message)}}async function Ql(){f&&window.confirm("Termin wirklich loeschen?")&&(await ye.delete(`/api/v1/appointments/${f}`),D(null),c(null),await $(t.user,S.start,S.end))}function wr({lessonTypeId:E,day:B,hour:me,minute:ke=0,instructorId:Me,appointment:he=null,startAt:Kl=null,endAt:ma=null}){var va,ga;if(he){c(he.id),D({student_id:he.student_id||"",lesson_type_id:he.lesson_type_id,instructor_id:he.instructor_id,start_at:Un(new Date(he.start_at)),end_at:Un(new Date(he.end_at)),units:he.units,notes:he.notes||"",status:he.status,requirement_phase:(he.counted_requirement_key||"").includes("::")?he.counted_requirement_key.split("::")[0]:""});return}const bl=Kl?new Date(Kl):new Date(B);Kl||bl.setHours(me,ke,0,0);const ha=E||((va=W[0])==null?void 0:va.id)||"",ql=W.find(Gc=>Number(Gc.id)===Number(ha)),Xc=ma?new Date(ma):new Date(bl.getTime()+Math.max(1,Number((ql==null?void 0:ql.default_duration)||45))*60*1e3);c(null),D({student_id:x==="student"&&h||"",lesson_type_id:ha,instructor_id:Me||((ga=C[0])==null?void 0:ga.id)||"",start_at:Un(bl),end_at:Un(Xc),units:1,notes:"",status:"planned",requirement_phase:""})}return i?a.jsx("div",{className:"app-shell",children:a.jsx("div",{className:"loading-panel",children:"Lade Anwendung ..."})}):t.user?a.jsxs("div",{className:"app-shell",children:[a.jsxs("header",{className:"topbar",children:[a.jsxs("div",{children:[a.jsx("p",{className:"eyebrow",children:"Fahrschul-Kalender"}),a.jsx("h1",{children:"DriveTime Planner"}),a.jsx("p",{className:"meta-line",children:t.user.role==="superadmin"?"Systemweit":`${(_=r==null?void 0:r.tenant)==null?void 0:_.name} · ${(b=r==null?void 0:r.tenant)==null?void 0:b.timezone}`})]}),a.jsxs("div",{className:"topbar-actions",children:[a.jsxs("span",{className:"badge",children:[t.user.first_name," ",t.user.last_name]}),a.jsx("button",{className:"ghost-button",onClick:()=>M(E=>E==="dark"?"light":"dark"),children:j==="dark"?"Hell":"Dunkel"}),a.jsx("button",{className:"ghost-button",onClick:Y,children:"Logout"})]})]}),p&&a.jsx("div",{className:"flash-error",children:p}),a.jsx("nav",{className:"tab-strip",children:Q.map(E=>a.jsx("button",{className:E.id===o?"tab-button active":"tab-button",onClick:()=>u(E.id),children:E.label},E.id))}),a.jsxs("main",{className:"content-grid",children:[o==="calendar"&&t.user.role!=="superadmin"&&a.jsx(Wp,{isMobile:e,weekStart:d,setWeekStart:g,range:S,instructors:C,students:L,lessonTypes:W,appointments:V,nextAppointment:J,sunTimes:P,dayMeta:O,selectedStudent:I,selectedStudentId:h,setSelectedStudentId:m,plannerMode:x,setPlannerMode:w,progress:I?q[I.id]||[]:[],onCreate:wr}),o==="students"&&t.user.role!=="superadmin"&&a.jsx(bp,{students:L,templates:R,onSave:(E,B)=>Fe("/api/v1/students",E,B),onSaveTraining:vt,onDelete:E=>_e("/api/v1/students",E)}),o==="lesson-types"&&t.user.role!=="superadmin"&&a.jsx(qp,{lessonTypes:W,templates:R,onSave:(E,B)=>Fe("/api/v1/lesson-types",E,B),onDelete:E=>_e("/api/v1/lesson-types",E),onReorder:async E=>{await ye.post("/api/v1/lesson-types/reorder",{lesson_type_ids:E}),await $(t.user,S.start,S.end)},onSaveMatrix:async E=>{await ye.post("/api/v1/lesson-types/visibility",{rows:E}),await $(t.user,S.start,S.end)}}),o==="templates"&&t.user.role!=="superadmin"&&a.jsx(Xp,{templates:R,lessonTypes:W,onSave:(E,B)=>Fe("/api/v1/license-class-templates",E,B)}),o==="instructors"&&t.user.role!=="superadmin"&&a.jsx(Yp,{instructors:C,onSave:(E,B)=>Fe("/api/v1/instructors",E,B)}),o==="users"&&a.jsx(Gp,{users:A,tenants:N,currentUser:t.user,onSave:(E,B)=>Fe("/api/v1/users",E,B)}),o==="tenant-profile"&&t.user.role==="tenant_admin"&&(r==null?void 0:r.tenant)&&a.jsx(Jp,{tenant:r.tenant,onSave:async E=>{await ye.patch("/api/v1/tenant/profile",E),await $(t.user,S.start,S.end)}}),o==="tenants"&&t.user.role==="superadmin"&&a.jsx(Zp,{tenants:N,onSave:(E,B)=>Fe("/api/v1/tenants",E,B)})]}),v&&a.jsx(tm,{draft:v,setDraft:D,lessonTypes:W,instructors:C,students:L,templates:R,onPickStudent:E=>{E&&(m(Number(E)),w("student"),D(null),c(null))},onSubmit:Wl,onDelete:Ql,onClose:()=>{D(null),c(null)},isEditing:!!f})]}):a.jsx(Hp,{onSubmit:it,error:p})}function Hp({onSubmit:e,error:t}){async function n(r){r.preventDefault();const l=new FormData(r.currentTarget);await e({email:l.get("email"),password:l.get("password")})}return a.jsx("div",{className:"login-shell",children:a.jsxs("div",{className:"login-card",children:[a.jsx("p",{className:"eyebrow",children:"Mandantenfaehig · Kalenderfokus"}),a.jsx("h1",{children:"Dispositionsfenster"}),a.jsx("p",{className:"login-copy",children:"Planung fuer Fahrstunden, Vorlagen und Ruhezeitwarnungen in einer kompakten Leitstelle."}),a.jsxs("form",{onSubmit:n,className:"stack-form",children:[a.jsxs("label",{children:["E-Mail",a.jsx("input",{name:"email",type:"email",defaultValue:"admin@nordring.test",required:!0})]}),a.jsxs("label",{children:["Passwort",a.jsx("input",{name:"password",type:"password",defaultValue:"secret123",required:!0})]}),t&&a.jsx("div",{className:"inline-error",children:t}),a.jsx("button",{className:"primary-button",type:"submit",children:"Einloggen"})]}),a.jsx("p",{className:"hint-line",children:"Demo: `admin@nordring.test` / `secret123` oder `superadmin@example.com` / `secret123`"})]})})}function Wp(e){var wr;const{isMobile:t,weekStart:n,setWeekStart:r,range:l,instructors:i,students:s,lessonTypes:o,appointments:u,nextAppointment:d,sunTimes:g,dayMeta:h,selectedStudent:m,selectedStudentId:x,setSelectedStudentId:w,plannerMode:v,setPlannerMode:D,progress:f,onCreate:c}=e,p=t?[new Date]:l.days,[y,j]=F.useState(()=>new Date),[M,S]=F.useState(""),[T,$]=F.useState(!1),[L,W]=F.useState(null),[C,R]=F.useState(((wr=i[0])==null?void 0:wr.id)||null),[A,N]=F.useState(()=>xe(new Date)),[V,J]=F.useState("08:00"),[P,O]=F.useState("10:00"),I=[{id:"student",label:"Fahrschueler"},{id:"private",label:"Privat"},{id:"work",label:"Sonstige Arbeiten"},{id:"theory",label:"Theorie"}],q=Array.from({length:96},(_,b)=>{const E=Math.floor(b/4),B=b%4*15;return{hour:E,minute:B,label:`${String(E).padStart(2,"0")}:${String(B).padStart(2,"0")}`,rowClass:B===0?"full-hour":""}}),Q=o.filter(_=>v==="student"?_.category==="student"&&qc(_,m==null?void 0:m.template_id):v==="private"?_.category==="private":v==="work"?_.category==="work"||_.category==="other"||_.category==="blocked":v==="theory"?_.category==="theory":!1),it=s.filter(_=>`${_.first_name} ${_.last_name} ${_.template_code}`.toLowerCase().includes(M.toLowerCase())).slice(0,8);F.useEffect(()=>{const _=window.setInterval(()=>{j(new Date)},6e4);return()=>window.clearInterval(_)},[]),F.useEffect(()=>{m&&S(`${m.first_name} ${m.last_name}`)},[x,m]),F.useEffect(()=>{if(!i.length){R(null);return}i.some(_=>_.id===C)||R(i[0].id)},[i,C]),F.useEffect(()=>{if(Q.length===0){W(null);return}Q.some(_=>_.id===L)||W(Q[0].id)},[L,Q]);const Y=i.find(_=>_.id===C)||i[0]||null;Q.find(_=>_.id===L);const Fe=A&&Ul(jt(A,V),jt(A,P))>0,_e=Y?u.filter(_=>_.instructor_id===Y.id):[],vt=Math.max(0,Number((Y==null?void 0:Y.pre_start_buffer_minutes)||0)),Wl=Array.from(_e.filter(_=>Number(_.is_rest_relevant)===1).reduce((_,b)=>{const E=new Date(b.end_at),B=`${E.getFullYear()}-${String(E.getMonth()+1).padStart(2,"0")}-${String(E.getDate()).padStart(2,"0")}`,me=_.get(B);return(!me||E>me.endAt)&&_.set(B,{endAt:E}),_},new Map).values()).map(({endAt:_})=>{const b=_,E=new Date(_.getTime()+11*60*60*1e3),B=_e.some(me=>{const ke=new Date(me.start_at);return ke>b&&ke_.start-b.start);function Ql(){!L||!(Y!=null&&Y.id)||!Fe||c({lessonTypeId:L,instructorId:Y.id,startAt:jt(A,V),endAt:jt(A,P)})}return a.jsxs(a.Fragment,{children:[a.jsxs("section",{className:"calendar-stage",children:[a.jsxs("div",{className:"section-head",children:[a.jsxs("div",{children:[a.jsx("p",{className:"eyebrow",children:"Einsatzfenster"}),a.jsxs("h2",{children:[_t(l.start)," - ",_t(Gn(l.end,-1))]})]}),a.jsxs("div",{className:"toolbar",children:[a.jsxs("label",{className:"calendar-filter",children:["Fahrlehrer",a.jsx("select",{value:C||"",onChange:_=>R(Number(_.target.value)),children:i.map(_=>a.jsxs("option",{value:_.id,children:[_.first_name," ",_.last_name]},_.id))})]}),a.jsx("button",{className:"ghost-button",onClick:()=>r(Gn(n,-7)),children:"Zurueck"}),a.jsx("button",{className:"ghost-button",onClick:()=>r(vs()),children:"Heute"}),d&&a.jsx("button",{className:"ghost-button",onClick:()=>r(vs(new Date(d.start_at))),children:"Naechster Termin"}),a.jsx("button",{className:"ghost-button",onClick:()=>r(Gn(n,7)),children:"Weiter"})]})]}),a.jsxs("div",{className:"calendar-grid",children:[a.jsx("div",{className:"calendar-head time-col",children:"Zeit"}),p.map(_=>(()=>{var E,B,me,ke,Me;const b=Lp(_e,_);return a.jsxs("div",{className:["calendar-head day-col",Qc(_,y)?"past-head":"",(E=h[xe(_)])!=null&&E.holidayName?"holiday-head":"",(B=h[xe(_)])!=null&&B.isSunday?"sunday-head":""].filter(Boolean).join(" "),children:[a.jsx("strong",{children:_t(_)}),a.jsxs("div",{className:"day-summary",children:[a.jsxs("span",{className:"day-summary-line",children:[a.jsxs("strong",{children:[b.taughtUnits," UE"]}),a.jsx("span",{children:"geschult"})]}),a.jsxs("span",{className:"day-summary-line",children:[a.jsx("strong",{children:To(b.workSpanMinutes)}),a.jsx("span",{children:"Arbeitszeit"})]}),a.jsxs("span",{className:`day-summary-line break-line ${b.breakStatus}`,children:[a.jsx("strong",{children:To(b.breakMinutes)}),a.jsxs("span",{children:["(Pause ",b.requiredBreakMinutes," min)"]})]})]}),((me=h[xe(_)])==null?void 0:me.holidayName)&&a.jsx("span",{className:"holiday-meta",children:h[xe(_)].holidayName}),((ke=g[xe(_)])==null?void 0:ke.sunrise)&&((Me=g[xe(_)])==null?void 0:Me.sunset)&&a.jsxs("span",{className:"sun-meta",children:["Auf ",g[xe(_)].sunrise," · Unter ",g[xe(_)].sunset]})]},_.toISOString())})()),a.jsx("div",{className:"time-grid",children:q.map(_=>a.jsx("div",{className:`slot-label ${_.rowClass}`,children:_.label},_.label))}),p.map(_=>a.jsx(Qp,{day:_,slots:q,instructor:Y,appointments:_e,preStartBufferMinutes:vt,restIntervals:Wl,sunInfo:g[xe(_)]||null,dayMeta:h[xe(_)]||null,now:y,armedLessonTypeId:L,onCreate:c},_.toISOString()))]})]}),a.jsxs("aside",{className:"planner-panel",children:[a.jsxs("div",{className:"section-head",children:[a.jsxs("div",{children:[a.jsx("p",{className:"eyebrow",children:"Planung"}),a.jsx("h2",{children:"Bausteine"})]}),t&&a.jsx("button",{className:"primary-button compact",onClick:()=>c({day:new Date,hour:8,minute:0,instructorId:Y==null?void 0:Y.id}),children:"+"})]}),a.jsx("div",{className:"mode-switch",children:I.map(_=>a.jsx("button",{className:v===_.id?"tab-button active":"tab-button",onClick:()=>D(_.id),children:_.label},_.id))}),v==="student"&&a.jsxs("label",{className:"stack-form",children:["Fahrschueler",a.jsxs("div",{className:"autocomplete-shell",children:[a.jsx("input",{type:"text",value:M,placeholder:"Name eingeben ...",onFocus:()=>$(!0),onChange:_=>{S(_.target.value),$(!0)},onBlur:()=>{window.setTimeout(()=>$(!1),120)}}),T&&it.length>0&&a.jsx("div",{className:"autocomplete-list",children:it.map(_=>a.jsxs("button",{type:"button",className:"autocomplete-option",onMouseDown:b=>{b.preventDefault(),w(_.id),S(`${_.first_name} ${_.last_name}`),$(!1)},children:[a.jsxs("strong",{children:[_.first_name," ",_.last_name]}),a.jsx("span",{children:_.template_code})]},_.id))})]})]}),a.jsx("div",{className:"block-bank",children:Q.map(_=>a.jsxs("div",{className:L===_.id?"lesson-chip active":"lesson-chip",draggable:!t,onDragStart:b=>{const E=JSON.stringify({lessonTypeId:_.id});b.dataTransfer.effectAllowed="move",b.dataTransfer.setData("application/json",E),b.dataTransfer.setData("text/plain",E)},onClick:()=>{W(_.id),t&&c({day:new Date,hour:8,minute:0,lessonTypeId:_.id,instructorId:Y==null?void 0:Y.id})},style:{"--chip-color":_.color},children:[a.jsx("strong",{children:_.name}),a.jsx("span",{children:_.fixed_duration?`${_.default_duration} min fix`:`${_.default_duration} min pro Einheit`})]},_.id))}),v==="private"&&L&&a.jsxs("div",{className:"quick-plan-panel",children:[a.jsx("div",{className:"section-head compact-head",children:a.jsxs("div",{children:[a.jsx("p",{className:"eyebrow",children:"Privat"}),a.jsx("h2",{children:"Schnell eintragen"})]})}),a.jsxs("label",{className:"stack-form",children:["Datum",a.jsx("input",{type:"date",value:A,onChange:_=>N(_.target.value)})]}),a.jsxs("div",{className:"time-editor-grid two-col",children:[a.jsx(Nl,{label:"Start",value:V,onChange:J}),a.jsx(Nl,{label:"Ende",value:P,onChange:O})]}),a.jsx("button",{type:"button",className:"primary-button",onClick:Ql,disabled:!Fe,children:"Privatblock anlegen"})]}),m&&a.jsxs("div",{className:"progress-panel",children:[a.jsxs("div",{className:"section-head",children:[a.jsxs("div",{children:[a.jsx("p",{className:"eyebrow",children:"Reststunden"}),a.jsxs("h2",{children:[m.first_name," ",m.last_name]})]}),a.jsx("span",{className:"badge",children:m.template_code})]}),pa(f).map(_=>{const b=bc(_.rows);return a.jsxs("div",{className:"progress-phase",children:[_.phase_label&&a.jsxs("div",{className:"phase-head",children:[a.jsx("strong",{children:_.phase_label}),a.jsx("span",{className:`phase-state ${b.status}`,children:Jr(b.open).text})]}),_.rows.map(E=>a.jsxs("div",{className:"progress-row",children:[a.jsxs("div",{children:[a.jsx("strong",{children:E.label}),a.jsxs("p",{children:["Soll ",E.required_units," · Geplant ",E.planned_units," · Erledigt ",E.completed_units]})]}),a.jsx("div",{className:`open-pill ${Jr(E.open_units).className}`,children:Jr(E.open_units).text})]},E.requirement_key))]},_.phase_label||"default")})]})]})]})}function Qp({day:e,slots:t,instructor:n,appointments:r,preStartBufferMinutes:l,restIntervals:i,sunInfo:s,dayMeta:o,now:u,armedLessonTypeId:d,onCreate:g}){const h=new Date(e);h.setHours(0,0,0,0);const m=new Date(e);m.setHours(24,0,0,0);const x=Tp(e,u),w=Qc(e,u),v=u.getHours()*60+u.getMinutes(),D=Math.min(96,Math.max(0,v/15)),f=Math.min(96,Math.max(0,Math.ceil(D))),c=r.filter(C=>{const R=new Date(C.start_at),A=new Date(C.end_at);return Rh}),p=r.filter(C=>{const R=new Date(C.start_at);return R>=h&&Rnew Date(C.start_at)-new Date(R.start_at))[0]||null,y=(()=>{if(!p||l<=0)return null;const C=new Date(p.start_at),R=new Date(Math.max(h.getTime(),C.getTime()-l*60*1e3)),A=C;if(A<=R)return null;const N=R.getHours()*60+R.getMinutes(),V=A.getHours()*60+A.getMinutes(),J=Math.max(0,Math.floor(N/15)),P=Math.max(1,Math.ceil((V-N)/15));return{startIndex:J,spanBlocks:Math.min(P,96-J),label:`Anfahrt ${l} min`}})(),j=i.map(C=>{const R=C.start>h?C.start:h,A=C.end=m?24*60:A.getHours()*60+A.getMinutes(),J=Math.max(0,Math.floor(N/15)),P=Math.max(1,Math.ceil((V-N)/15));return{key:`${C.start.toISOString()}-${C.end.toISOString()}-${e.toISOString()}`,startIndex:J,spanBlocks:Math.min(P,96-J),label:`Ruhezeit bis ${String(C.end.getHours()).padStart(2,"0")}:${String(C.end.getMinutes()).padStart(2,"0")}`,hasConflict:C.hasConflict}}).filter(Boolean),M=Mo(s==null?void 0:s.sunrise),S=Mo(s==null?void 0:s.sunset),T=[];if(M!==null&&M>0&&T.push({key:`night-before-${xe(e)}`,startIndex:0,spanBlocks:Math.max(1,Math.ceil(M/15)),label:`Nacht bis ${s.sunrise}`,align:"end"}),S!==null&&S<24*60){const C=Math.max(0,Math.floor(S/15));T.push({key:`night-after-${xe(e)}`,startIndex:C,spanBlocks:Math.max(1,Math.ceil((24*60-S)/15)),label:`Dunkel ab ${s.sunset}`,align:"start"})}function $(C){g({day:e,hour:C.hour,minute:C.minute,instructorId:n==null?void 0:n.id,lessonTypeId:d||void 0})}function L(C,R){C.preventDefault();const A=C.dataTransfer.getData("application/json")||C.dataTransfer.getData("text/plain");if(!A)return;const N=JSON.parse(A);g({lessonTypeId:N.lessonTypeId,day:e,hour:R.hour,minute:R.minute,instructorId:n==null?void 0:n.id})}function W(C,R){const A=C.currentTarget.getBoundingClientRect(),N=Math.max(0,Math.min(A.height-1,C.clientY-A.top)),V=Math.floor(N/28),J=Math.min(95,R.startIndex+V);return t[J]}return a.jsxs("div",{className:["day-grid",w?"past-column":"",o!=null&&o.holidayName?"holiday-column":"",o!=null&&o.isSunday?"sunday-column":""].filter(Boolean).join(" "),children:[w&&a.jsx("div",{className:"day-past-overlay",style:{gridRow:"1 / span 96"}}),x&&f>0&&a.jsx("div",{className:"current-time-past",style:{gridRow:`1 / span ${f}`},"aria-hidden":"true"}),x&&D>0&&D<96&&a.jsx("div",{className:"current-time-line",style:{top:`${D*28}px`},"aria-hidden":"true",children:a.jsx("span",{children:"Jetzt"})}),t.map((C,R)=>a.jsx("button",{type:"button",className:`slot-cell quarter-hour ${C.rowClass}`,style:{gridRow:R+1},onDragOver:A=>{A.preventDefault(),A.dataTransfer.dropEffect="move"},onDrop:A=>L(A,C),onClick:()=>$(C)},C.label)),T.map(C=>a.jsx("div",{className:`sun-band ${C.align==="end"?"top-band":"bottom-band"}`,style:{gridRow:`${C.startIndex+1} / span ${C.spanBlocks}`},children:a.jsx("span",{children:C.label})},C.key)),y&&a.jsx("div",{className:"pre-start-stripe",style:{gridRow:`${y.startIndex+1} / span ${y.spanBlocks}`},"aria-hidden":"true",children:a.jsx("span",{children:y.label})}),c.map(C=>{const R=new Date(C.start_at),A=new Date(C.end_at),N=R>h?R:h,V=A=m?24*60:V.getHours()*60+V.getMinutes(),O=Math.max(0,Math.floor(J/15)),I=Math.max(1,Math.min(96-O,Math.ceil((P-J)/15)));return a.jsx(Kp,{appointment:C,onOpen:()=>g({appointment:C}),compact:!0,spanBlocks:I,gridRowStart:O+1},`${C.id}-${xe(e)}`)}),j.map(C=>a.jsx("div",{className:C.hasConflict?"rest-stripe warning":"rest-stripe ok",style:{gridRow:`${C.startIndex+1} / span ${C.spanBlocks}`},onDragOver:R=>{R.preventDefault(),R.dataTransfer.dropEffect="move"},onDrop:R=>{const A=W(R,C);L(R,A)},onClick:R=>{const A=W(R,C);$(A)},children:a.jsx("span",{children:C.label})},C.key))]})}function Kp({appointment:e,onOpen:t,compact:n=!1,spanBlocks:r=1,gridRowStart:l=null}){const s=`${e.student_first_name||""} ${e.student_last_name||""}`.trim()||Yc(e.category),o=r>=3,u=Number(e.student_needs_glasses||0)===1;return a.jsx("button",{className:n?"appointment-card compact-card day-appointment":"appointment-card",onClick:d=>{d.stopPropagation(),t()},style:{"--appointment-color":e.lesson_type_color,"--span-blocks":r,...l?{gridRow:`${l} / span ${r}`}:{}},children:n?a.jsxs(a.Fragment,{children:[a.jsxs("div",{className:"appointment-top",children:[a.jsxs("strong",{className:"appointment-title-row",children:[a.jsx("span",{children:e.lesson_type_name}),u&&a.jsx("span",{className:"vision-badge",title:"Brille erforderlich",children:"[Br]"})]}),a.jsxs("span",{className:"appointment-student",children:[s,u&&a.jsx("span",{className:"vision-inline","aria-hidden":"true",children:" [Brille]"})]}),o&&a.jsxs("span",{className:"appointment-meta",children:[_t(e.start_at)," · ",_t(e.start_at,!0)]})]}),a.jsx("div",{className:"appointment-bottom",children:a.jsxs("span",{children:["bis ",_t(e.end_at,!0)]})})]}):a.jsxs(a.Fragment,{children:[a.jsxs("strong",{className:"appointment-title-row",children:[a.jsx("span",{children:e.lesson_type_name}),u&&a.jsx("span",{className:"vision-badge",title:"Brille erforderlich",children:"[Br]"})]}),a.jsxs("span",{children:[_t(e.start_at,!0)," - ",_t(e.end_at,!0)]}),a.jsxs("span",{children:[s,u&&a.jsx("span",{className:"vision-inline",children:" [Brille]"})]})]})})}function bp({students:e,templates:t,onSave:n,onSaveTraining:r,onDelete:l}){var g;const[i,s]=F.useState(null),[o,u]=F.useState("master"),d=e.find(h=>Number(h.id)===Number(i))||null;return i==="new"?a.jsx(zo,{student:{first_name:"",last_name:"",phone:"",email:"",needs_glasses:0,notes:"",template_id:(g=t[0])==null?void 0:g.id,status:"active",progress:[]},templates:t,activeTab:"master",setActiveTab:u,onBack:()=>s(null),onSave:async h=>{await n(h,null),s(null)},onSaveTraining:null,onDelete:null,isNew:!0}):d?a.jsx(zo,{student:d,templates:t,activeTab:o,setActiveTab:u,onBack:()=>{s(null),u("master")},onSave:async h=>{await n(h,d.id)},onSaveTraining:async h=>{await r(d.id,h)},onDelete:async()=>{window.confirm(`Fahrschueler ${d.first_name} ${d.last_name} wirklich loeschen? Termine bleiben erhalten, verlieren aber den Fahrschueler-Bezug.`)&&(await l(d.id),s(null),u("master"))}}):a.jsxs("section",{className:"list-panel student-page",children:[a.jsxs("div",{className:"section-head",children:[a.jsxs("div",{children:[a.jsx("p",{className:"eyebrow",children:"Verwaltung"}),a.jsx("h2",{children:"Fahrschueler"}),a.jsx("p",{className:"meta-line",children:"Klick oeffnet die eigene Fahrschueler-Seite mit Stammdaten und Ausbildung."})]}),a.jsx("button",{className:"primary-button",onClick:()=>{u("master"),s("new")},children:"Neu"})]}),a.jsx("div",{className:"entity-list",children:e.map(h=>a.jsxs("button",{className:"entity-item student-list-row",onClick:()=>s(h.id),children:[a.jsxs("strong",{children:[h.first_name," ",h.last_name]}),a.jsxs("span",{children:[h.template_code," · ",h.status]})]},h.id))})]})}function zo({student:e,templates:t,activeTab:n,setActiveTab:r,onBack:l,onSave:i,onSaveTraining:s,onDelete:o,isNew:u=!1}){const[d,g]=F.useState(e),[h,m]=F.useState(e.progress||[]),[x,w]=F.useState(!1);F.useEffect(()=>{g(e),m(e.progress||[])},[e]);async function v(f){f.preventDefault(),await i(d)}async function D(f){if(f.preventDefault(),!!s){w(!0);try{await s(h.map(c=>({requirement_key:c.requirement_key,phase_label:c.phase_label||"",prior_completed_units:Number(c.prior_completed_units||0)})))}finally{w(!1)}}}return a.jsxs("section",{className:"list-panel student-page student-detail-page",children:[a.jsxs("div",{className:"section-head",children:[a.jsxs("div",{children:[a.jsx("p",{className:"eyebrow",children:"Fahrschueler"}),a.jsx("h2",{children:u?"Neuer Fahrschueler":`${e.first_name} ${e.last_name}`}),a.jsx("p",{className:"meta-line",children:u?"Stammdaten anlegen.":`${e.template_name||e.template_code} · ${e.status}`})]}),a.jsx("button",{type:"button",className:"ghost-button",onClick:l,children:"Zurueck"})]}),!u&&a.jsxs("div",{className:"mode-switch detail-tabs",children:[a.jsx("button",{className:n==="master"?"tab-button active":"tab-button",onClick:()=>r("master"),children:"Stammdaten"}),a.jsx("button",{className:n==="training"?"tab-button active":"tab-button",onClick:()=>r("training"),children:"Ausbildung"})]}),(n==="master"||u)&&a.jsxs("form",{className:"stack-form",onSubmit:v,children:[a.jsxs("label",{children:["Vorname",a.jsx("input",{value:d.first_name||"",onChange:f=>g({...d,first_name:f.target.value}),required:!0})]}),a.jsxs("label",{children:["Nachname",a.jsx("input",{value:d.last_name||"",onChange:f=>g({...d,last_name:f.target.value}),required:!0})]}),a.jsxs("label",{children:["Telefon",a.jsx("input",{value:d.phone||"",onChange:f=>g({...d,phone:f.target.value})})]}),a.jsxs("label",{children:["E-Mail",a.jsx("input",{type:"email",value:d.email||"",onChange:f=>g({...d,email:f.target.value})})]}),a.jsxs("label",{className:"checkbox-row",children:[a.jsx("input",{type:"checkbox",checked:!!d.needs_glasses,onChange:f=>g({...d,needs_glasses:f.target.checked?1:0})}),a.jsx("span",{children:"Benoetigt Brille beim Fahren"})]}),a.jsxs("label",{children:["Fuehrerscheinklasse",a.jsx("select",{value:d.template_id||"",onChange:f=>g({...d,template_id:f.target.value}),children:t.map(f=>a.jsx("option",{value:f.id,children:f.name},f.id))})]}),a.jsxs("label",{children:["Status",a.jsxs("select",{value:d.status||"active",onChange:f=>g({...d,status:f.target.value}),children:[a.jsx("option",{value:"active",children:"aktiv"}),a.jsx("option",{value:"inactive",children:"inaktiv"})]})]}),a.jsxs("label",{children:["Notiz",a.jsx("textarea",{rows:"5",value:d.notes||"",onChange:f=>g({...d,notes:f.target.value})})]}),a.jsxs("div",{className:"modal-actions",children:[!u&&o&&a.jsx("button",{type:"button",className:"danger-button",onClick:o,children:"Fahrschueler loeschen"}),a.jsx("button",{className:"primary-button",type:"submit",children:"Speichern"})]})]}),!u&&n==="training"&&a.jsxs("form",{className:"training-panel",onSubmit:D,children:[(e.progress||[]).length===0&&a.jsx("p",{className:"meta-line",children:"Noch keine Ausbildungsvorgaben hinterlegt."}),pa(h).map(f=>{const c=bc(f.rows);return a.jsxs("div",{className:"training-phase",children:[f.phase_label&&a.jsxs("div",{className:"phase-head",children:[a.jsx("strong",{children:f.phase_label}),a.jsx("span",{className:`phase-state ${c.status}`,children:Jr(c.open).text})]}),f.rows.map(p=>{const y=h.findIndex(C=>C.requirement_key===p.requirement_key),j=Number(p.required_units||0),M=Number(p.completed_units||0),S=Number(p.prior_completed_units||0),T=Number(p.appointment_completed_units||0),$=Number(p.planned_units||0),L=M+$,W=j>0?Math.min(100,Math.round(L/j*100)):100;return a.jsxs("div",{className:"training-row",children:[a.jsxs("div",{className:"training-row-head",children:[a.jsxs("div",{children:[a.jsx("strong",{children:p.label}),a.jsxs("p",{children:["Altbestand ",S," · Termine erledigt ",T," · Geplant ",$," · Soll ",j," · Offen ",p.open_units]})]}),a.jsxs("label",{className:"training-import-field",children:["Schon erledigt",a.jsx("input",{type:"number",min:"0",step:"1",value:p.prior_completed_units??0,onChange:C=>{const R=[...h];R[y]={...p,prior_completed_units:C.target.value},m(R)}})]})]}),a.jsx("div",{className:"training-meter","aria-label":`${W} Prozent`,children:a.jsx("span",{style:{width:`${W}%`}})})]},p.requirement_key)})]},f.phase_label||"default")}),(e.progress||[]).length>0&&a.jsx("div",{className:"modal-actions",children:a.jsx("button",{className:"primary-button",type:"submit",disabled:x,children:x?"Speichert ...":"Ausbildung speichern"})})]})]})}function qp({lessonTypes:e,templates:t,onSave:n,onDelete:r,onReorder:l,onSaveMatrix:i}){const[s,o]=F.useState(null),[u,d]=F.useState([]),[g,h]=F.useState(!1),[m,x]=F.useState(e),w=e.filter(c=>c.category==="student");F.useEffect(()=>{d(t.map(c=>({template_id:c.id,lesson_type_ids:c.lesson_type_ids||[]})))},[t]),F.useEffect(()=>{x(e)},[e]);function v(c,p){d(y=>y.map(j=>{if(j.template_id!==c)return j;const M=j.lesson_type_ids.includes(p);return{...j,lesson_type_ids:M?j.lesson_type_ids.filter(S=>S!==p):[...j.lesson_type_ids,p]}}))}function D(c,p){const y=[...m];if(c<0||p<0||c>=y.length||p>=y.length)return y;const[j]=y.splice(c,1);return y.splice(p,0,j),y}async function f(c){x(c),await l(c.map(p=>p.id))}return a.jsxs(a.Fragment,{children:[a.jsxs(a.Fragment,{children:[a.jsxs("section",{className:"list-panel",children:[a.jsxs("div",{className:"section-head",children:[a.jsxs("div",{children:[a.jsx("p",{className:"eyebrow",children:"Verwaltung"}),a.jsx("h2",{children:"Stundenarten"}),a.jsx("p",{className:"meta-line",children:"Mit Hoch-/Runter-Knoepfen umsortieren. Farben, Dauer und Kategorie gelten mandantenweit."})]}),a.jsx("button",{className:"primary-button",onClick:()=>o({name:"",color:"#0E5D80",default_duration:45,category:"student",requirement_key:"",is_billable:1,is_counted:1,is_rest_relevant:1,fixed_duration:0,sort_order:m.length+1,system_key:""}),children:"Neu"})]}),a.jsx("div",{className:"entity-list lesson-type-list",children:m.map((c,p)=>a.jsxs("div",{className:"entity-item lesson-type-row",children:[a.jsxs("div",{className:"lesson-type-actions",children:[a.jsx("button",{type:"button",className:"ghost-button compact reorder-button",disabled:p===0,onClick:async y=>{y.stopPropagation(),await f(D(p,p-1))},children:"Hoch"}),a.jsx("button",{type:"button",className:"ghost-button compact reorder-button",disabled:p===m.length-1,onClick:async y=>{y.stopPropagation(),await f(D(p,p+1))},children:"Runter"})]}),a.jsx("span",{className:"color-dot",style:{"--dot-color":c.color||"#67625a"}}),a.jsxs("span",{className:"lesson-type-main",children:[a.jsx("strong",{children:c.name}),a.jsxs("span",{className:"meta-line",children:[c.default_duration," min · ",Yc(c.category)]})]}),a.jsx("button",{type:"button",className:"ghost-button compact",onClick:()=>o(c),children:"Bearbeiten"})]},c.id))})]}),s&&a.jsx(Hl,{title:"Stundenart",initial:s,onClose:()=>o(null),onSubmit:async c=>{c.category=c.category||s.category,c.default_duration=Number(c.default_duration),c.sort_order=Number(c.sort_order),await n(c,s.id),o(null)},onDelete:s.id&&s.system_key!=="private_block"?async()=>{window.confirm(`Stundenart "${s.name}" wirklich loeschen?`)&&(await r(s.id),o(null))}:null,fields:[{name:"name",label:"Bezeichnung"},{name:"color",label:"Farbe",type:"color"},{name:"default_duration",label:"Dauer in Minuten",type:"number"},{name:"category",label:"Kategorie",type:"select",disabled:s.system_key==="private_block",options:[{value:"student",label:"Fahrschueler"},{value:"private",label:"Privat"},{value:"work",label:"Sonstige Arbeiten"},{value:"theory",label:"Theorie"},{value:"blocked",label:"Sperre"}]},{name:"requirement_key",label:"Pflichtbaustein-Key"},{name:"sort_order",label:"Sortierung",type:"number"},{name:"is_billable",label:"Abrechenbar",type:"checkbox"},{name:"is_counted",label:"Zaehlt fuer Pflichtstunden",type:"checkbox"},{name:"is_rest_relevant",label:"Ruhezeit-relevant",type:"checkbox"},{name:"fixed_duration",label:"Feste Dauer",type:"checkbox"}]})]}),w.length>0&&t.length>0&&a.jsxs("section",{className:"list-panel",children:[a.jsxs("div",{className:"section-head",children:[a.jsxs("div",{children:[a.jsx("p",{className:"eyebrow",children:"Sichtbarkeit"}),a.jsx("h2",{children:"Klassenmatrix"}),a.jsx("p",{className:"meta-line",children:"Fuehrerscheinklassen stehen vertikal, schuelerbezogene Stundenarten horizontal."})]}),a.jsx("button",{className:"primary-button",onClick:async()=>{h(!0);try{await i(u)}finally{h(!1)}},disabled:g,children:g?"Speichert ...":"Matrix speichern"})]}),a.jsx("div",{className:"visibility-matrix-wrap",children:a.jsxs("table",{className:"visibility-matrix",children:[a.jsx("thead",{children:a.jsxs("tr",{children:[a.jsx("th",{children:"Fuehrerscheinklasse"}),w.map(c=>a.jsx("th",{children:c.name},c.id))]})}),a.jsx("tbody",{children:t.map(c=>{const p=u.find(y=>y.template_id===c.id)||{lesson_type_ids:[]};return a.jsxs("tr",{children:[a.jsx("th",{children:c.name}),w.map(y=>a.jsx("td",{children:a.jsx("input",{type:"checkbox",checked:p.lesson_type_ids.includes(y.id),onChange:()=>v(c.id,y.id)})},y.id))]},c.id)})})]})})]})]})}function Yp({instructors:e,onSave:t}){const[n,r]=F.useState(null);return a.jsx(Vl,{title:"Fahrlehrer",subtitle:"Mehrere Ressourcen werden direkt im Kalender abgebildet.",items:e,columns:l=>`${l.first_name} ${l.last_name} · ${l.is_active?"aktiv":"inaktiv"} · Anfahrt ${Number(l.pre_start_buffer_minutes||0)} min`,onEdit:l=>r(l),onCreate:()=>r({first_name:"",last_name:"",color:"#0E5D80",notes:"",is_active:1,pre_start_buffer_minutes:0}),form:n&&a.jsx(Hl,{title:"Fahrlehrer",initial:n,onClose:()=>r(null),onSubmit:async l=>{await t(l,n.id),r(null)},fields:[{name:"first_name",label:"Vorname"},{name:"last_name",label:"Nachname"},{name:"color",label:"Farbe",type:"color"},{name:"pre_start_buffer_minutes",label:"Anfahrtszeit vor erstem Termin (Minuten)",type:"number"},{name:"notes",label:"Notiz",type:"textarea"},{name:"is_active",label:"Aktiv",type:"checkbox"}]})})}function Xp({templates:e,lessonTypes:t,onSave:n}){const[r,l]=F.useState(null);return a.jsx(Vl,{title:"Klassenvorlagen",subtitle:"Kombinationen wie C + CE sind eigene Vorlagen mit festen Sollwerten.",items:e,columns:i=>`${i.code} · ${Zr(i,t).map(s=>`${s.label}: ${s.required_units}`).join(", ")||"keine Pflichtstunden"}`,onEdit:i=>{const s=Bp(i);l({...s,requirements:Zr(s,t)})},onCreate:()=>l({code:"",name:"",is_combination:0,lesson_type_ids:t.filter(i=>i.category==="student").map(i=>i.id),requirements:Zr({lesson_type_ids:t.filter(i=>i.category==="student").map(i=>i.id),requirements:[]},t)}),form:r&&a.jsx(em,{template:r,setTemplate:l,lessonTypes:t,onClose:()=>l(null),onSubmit:async()=>{await n(r,r.id),l(null)}})})}function Gp({users:e,tenants:t,currentUser:n,onSave:r}){const[l,i]=F.useState(null);return a.jsx(Vl,{title:"Benutzer",subtitle:"Rollen und Zugriff werden serverseitig geprueft.",items:e,columns:s=>`${s.first_name} ${s.last_name} · ${s.role} · ${s.email}${s.tenant_name?` · ${s.tenant_name}`:""}`,onEdit:s=>i(s),onCreate:()=>{var s;return i({tenant_id:n.tenant_id||((s=t[0])==null?void 0:s.id)||"",role:"dispatcher",email:"",password:"",first_name:"",last_name:"",is_active:1})},form:l&&a.jsx(Hl,{title:"Benutzer",initial:l,onClose:()=>i(null),onSubmit:async s=>{await r(s,l.id),i(null)},fields:[...n.role==="superadmin"?[{name:"tenant_id",label:"Mandant",type:"select",options:t.map(s=>({value:s.id,label:s.name}))}]:[],{name:"role",label:"Rolle",type:"select",options:[{value:"tenant_admin",label:"Mandanten-Admin"},{value:"dispatcher",label:"Disponent"},{value:"instructor",label:"Fahrlehrer"},...n.role==="superadmin"?[{value:"superadmin",label:"Superadmin"}]:[]]},{name:"email",label:"E-Mail"},{name:"password",label:"Passwort"},{name:"first_name",label:"Vorname"},{name:"last_name",label:"Nachname"},{name:"is_active",label:"Aktiv",type:"checkbox"}]})})}function Zp({tenants:e,onSave:t}){const[n,r]=F.useState(null);return a.jsx(Vl,{title:"Mandanten",subtitle:"Superadmin-Verwaltung fuer Mandantenstatus und Zeitzone.",items:e,columns:l=>`${l.name} · ${l.timezone} · ${l.is_active?"aktiv":"inaktiv"}`,onEdit:l=>r(l),onCreate:()=>r({name:"",timezone:"Europe/Berlin",is_active:1}),form:n&&a.jsx(Hl,{title:"Mandant",initial:n,onClose:()=>r(null),onSubmit:async l=>{await t(l,n.id),r(null)},fields:[{name:"name",label:"Name"},{name:"timezone",label:"Zeitzone"},{name:"federal_state",label:"Bundesland",type:"select",options:Wc},{name:"location_label",label:"Ort / Filiale"},{name:"latitude",label:"Breitengrad",type:"number"},{name:"longitude",label:"Laengengrad",type:"number"},{name:"is_active",label:"Aktiv",type:"checkbox"}]})})}function Jp({tenant:e,onSave:t}){const[n,r]=F.useState(e),[l,i]=F.useState((e==null?void 0:e.location_label)||""),[s,o]=F.useState([]),[u,d]=F.useState(!1),[g,h]=F.useState("");F.useEffect(()=>{r(e),i((e==null?void 0:e.location_label)||""),o([]),h("")},[e]);const m=Number(n==null?void 0:n.latitude),x=Number(n==null?void 0:n.longitude),w=Number.isFinite(m)&&Number.isFinite(x),v=.025,D=w?`https://www.openstreetmap.org/export/embed.html?bbox=${x-v}%2C${m-v}%2C${x+v}%2C${m+v}&layer=mapnik&marker=${m}%2C${x}`:"";async function f(){const c=l.trim();if(!c){o([]),h("Bitte einen Ort oder eine Adresse eingeben.");return}d(!0),h("");try{const p=await fetch(`https://nominatim.openstreetmap.org/search?format=jsonv2&limit=5&q=${encodeURIComponent(c)}`);if(!p.ok)throw new Error(`Ortssuche fehlgeschlagen (${p.status})`);const y=await p.json();o(y),y.length||h("Kein passender Ort gefunden.")}catch(p){h(p.message),o([])}finally{d(!1)}}return a.jsxs("section",{className:"list-panel",children:[a.jsx("div",{className:"section-head",children:a.jsxs("div",{children:[a.jsx("p",{className:"eyebrow",children:"Fahrschule"}),a.jsx("h2",{children:"Standort und Zeit"}),a.jsx("p",{className:"meta-line",children:"Ort, Bundesland und Zeitzone steuern Sonnenzeiten, Feiertage und die Hell-Dunkel-Markierung im Kalender."})]})}),a.jsxs("form",{className:"stack-form",onSubmit:async c=>{c.preventDefault(),await t(n)},children:[a.jsxs("label",{children:["Name",a.jsx("input",{value:n.name||"",onChange:c=>r({...n,name:c.target.value})})]}),a.jsxs("label",{children:["Zeitzone",a.jsx("input",{value:n.timezone||"",onChange:c=>r({...n,timezone:c.target.value})})]}),a.jsxs("label",{children:["Bundesland",a.jsx("select",{value:n.federal_state||"BE",onChange:c=>r({...n,federal_state:c.target.value}),children:Wc.map(c=>a.jsx("option",{value:c.value,children:c.label},c.value))})]}),a.jsxs("label",{children:["Ort / Filiale",a.jsxs("div",{className:"location-search",children:[a.jsx("input",{value:l,onChange:c=>{const p=c.target.value;i(p),r({...n,location_label:p})},placeholder:"z. B. Duisburg, Bahnhofstrasse 12"}),a.jsx("button",{type:"button",className:"ghost-button",onClick:f,disabled:u,children:u?"Suche...":"Ort suchen"})]})]}),g&&a.jsx("p",{className:"form-error",children:g}),s.length>0&&a.jsx("div",{className:"location-result-list",children:s.map(c=>a.jsxs("button",{type:"button",className:"location-result",onClick:()=>{r({...n,location_label:c.display_name,latitude:Number(c.lat),longitude:Number(c.lon)}),i(c.display_name),o([]),h("")},children:[a.jsx("strong",{children:c.display_name}),a.jsxs("span",{children:[Number(c.lat).toFixed(6)," / ",Number(c.lon).toFixed(6)]})]},`${c.place_id}-${c.lat}-${c.lon}`))}),a.jsxs("div",{className:"coordinate-grid",children:[a.jsxs("label",{children:["Breitengrad",a.jsx("input",{type:"number",step:"0.000001",value:n.latitude??"",onChange:c=>r({...n,latitude:c.target.value})})]}),a.jsxs("label",{children:["Laengengrad",a.jsx("input",{type:"number",step:"0.000001",value:n.longitude??"",onChange:c=>r({...n,longitude:c.target.value})})]})]}),w&&a.jsxs("div",{className:"map-preview",children:[a.jsx("iframe",{title:"Standortkarte",src:D,loading:"lazy",referrerPolicy:"no-referrer-when-downgrade"}),a.jsx("p",{className:"meta-line",children:"Karte zeigt den derzeit gespeicherten Standort."})]}),a.jsxs("div",{className:"location-hint",children:[a.jsx("strong",{children:"Hinweis"}),a.jsx("span",{children:"Feiertage richten sich nach dem Bundesland. Sonnenaufgang und Sonnenuntergang richten sich nach dem gespeicherten Standort."})]}),a.jsx("button",{className:"primary-button",type:"submit",children:"Speichern"})]})]})}function Vl({title:e,subtitle:t,items:n,columns:r,onEdit:l,onCreate:i,form:s}){return a.jsxs(a.Fragment,{children:[a.jsxs("section",{className:"list-panel",children:[a.jsxs("div",{className:"section-head",children:[a.jsxs("div",{children:[a.jsx("p",{className:"eyebrow",children:"Verwaltung"}),a.jsx("h2",{children:e}),a.jsx("p",{className:"meta-line",children:t})]}),a.jsx("button",{className:"primary-button",onClick:i,children:"Neu"})]}),a.jsx("div",{className:"entity-list",children:n.map(o=>a.jsx("button",{className:"entity-item",onClick:()=>l(o),children:r(o)},o.id))})]}),s]})}function Hl({title:e,initial:t,fields:n,onSubmit:r,onClose:l,onDelete:i=null}){async function s(o){o.preventDefault();const u=new FormData(o.currentTarget),d=Object.fromEntries(u.entries());for(const g of n)g.type==="checkbox"&&(d[g.name]=u.get(g.name)?1:0);await r(d)}return a.jsx("div",{className:"modal-shell",children:a.jsxs("form",{className:"modal-card stack-form",onSubmit:s,children:[a.jsxs("div",{className:"section-head",children:[a.jsx("h2",{children:e}),a.jsx("button",{type:"button",className:"ghost-button",onClick:l,children:"Schliessen"})]}),n.map(o=>a.jsxs("label",{children:[o.label,o.type==="textarea"?a.jsx("textarea",{name:o.name,defaultValue:t[o.name]||"",rows:"4",disabled:o.disabled}):o.type==="select"?a.jsx("select",{name:o.name,defaultValue:String(t[o.name]??""),disabled:o.disabled,children:o.options.map(u=>a.jsx("option",{value:u.value,children:u.label},u.value))}):o.type==="checkbox"?a.jsx("input",{name:o.name,type:"checkbox",defaultChecked:!!t[o.name],disabled:o.disabled}):a.jsx("input",{name:o.name,type:o.type||"text",defaultValue:t[o.name]||"",disabled:o.disabled})]},o.name)),a.jsxs("div",{className:"modal-actions",children:[i&&a.jsx("button",{type:"button",className:"danger-button",onClick:i,children:"Loeschen"}),a.jsx("button",{className:"primary-button",type:"submit",children:"Speichern"})]})]})})}function em({template:e,setTemplate:t,lessonTypes:n,onClose:r,onSubmit:l}){function i(s){const o={...e,is_combination:s?1:0};t({...o,requirements:Zr(o,n)})}return a.jsx("div",{className:"modal-shell",children:a.jsxs("form",{className:"modal-card stack-form",onSubmit:s=>{s.preventDefault(),l()},children:[a.jsxs("div",{className:"section-head",children:[a.jsx("h2",{children:"Klassenvorlage"}),a.jsx("button",{type:"button",className:"ghost-button",onClick:r,children:"Schliessen"})]}),a.jsxs("label",{children:["Code",a.jsx("input",{value:e.code,onChange:s=>t({...e,code:s.target.value})})]}),a.jsxs("label",{children:["Name",a.jsx("input",{value:e.name,onChange:s=>t({...e,name:s.target.value})})]}),a.jsxs("label",{children:["Kombination",a.jsx("input",{type:"checkbox",checked:!!e.is_combination,onChange:s=>i(s.target.checked)})]}),pa(e.requirements).map(s=>a.jsxs("div",{className:"requirement-phase",children:[s.phase_label&&a.jsx("h3",{children:s.phase_label}),a.jsx("div",{className:"requirements-grid",children:s.rows.map(o=>{const u=e.requirements.findIndex(d=>d.requirement_key===o.requirement_key);return a.jsxs("div",{className:"requirement-editor",children:[a.jsxs("strong",{className:"requirement-title",children:[a.jsx("span",{className:"color-dot",style:{"--dot-color":o.color||"#67625a"}}),o.label]}),a.jsx("input",{type:"number",min:"0",value:o.required_units,onChange:d=>{const g=[...e.requirements];g[u]={...g[u],required_units:Number(d.target.value)},t({...e,requirements:g})}})]},o.requirement_key)})})]},s.phase_label||"default")),a.jsx("button",{className:"primary-button",type:"submit",children:"Speichern"})]})})}function Nl({label:e,value:t,onChange:n}){const[r,l]=F.useState(!1),[i,s]=F.useState("hour"),o=Number((t||"00:00").split(":")[0]||0),u=Number((t||"00:00").split(":")[1]||0);F.useEffect(()=>{r&&s("hour")},[r]);const d=Array.from({length:12},(v,D)=>D),g=Array.from({length:12},(v,D)=>D+12),h=Array.from({length:12},(v,D)=>D*5);function m(v,D){const f=v/12*Math.PI*2-Math.PI/2,c=Math.cos(f)*D,p=Math.sin(f)*D;return{left:`calc(50% + ${c}px)`,top:`calc(50% + ${p}px)`}}function x(v){n(`${String(v).padStart(2,"0")}:${String(u).padStart(2,"0")}`),s("minute")}function w(v){n(`${String(o).padStart(2,"0")}:${String(v).padStart(2,"0")}`),l(!1)}return a.jsxs("div",{className:"time-dial-field",children:[a.jsxs("button",{type:"button",className:"time-dial-button",onClick:()=>l(v=>!v),children:[a.jsx("span",{children:e}),a.jsx("strong",{children:t})]}),r&&a.jsxs("div",{className:"time-dial-popover",children:[a.jsxs("div",{className:"time-dial-head",children:[a.jsxs("div",{children:[a.jsx("p",{className:"eyebrow",children:"Uhrzeit"}),a.jsx("strong",{children:t})]}),a.jsx("button",{type:"button",className:"ghost-button compact",onClick:()=>l(!1),children:"Schliessen"})]}),a.jsxs("div",{className:"mode-switch",children:[a.jsx("button",{type:"button",className:i==="hour"?"tab-button active compact":"tab-button compact",onClick:()=>s("hour"),children:"Stunde"}),a.jsx("button",{type:"button",className:i==="minute"?"tab-button active compact":"tab-button compact",onClick:()=>s("minute"),children:"Minute"})]}),a.jsxs("div",{className:"time-dial-face",children:[a.jsx("div",{className:"time-dial-center",children:i==="hour"?String(o).padStart(2,"0"):String(u).padStart(2,"0")}),i==="hour"?a.jsxs(a.Fragment,{children:[g.map((v,D)=>a.jsx("button",{type:"button",className:["time-dial-point","outer-ring",v===o?"active":""].filter(Boolean).join(" "),style:m(D,112),onClick:()=>x(v),children:String(v).padStart(2,"0")},`outer-${v}`)),d.map((v,D)=>a.jsx("button",{type:"button",className:["time-dial-point","inner-ring",v===o?"active":""].filter(Boolean).join(" "),style:m(D,72),onClick:()=>x(v),children:String(v).padStart(2,"0")},`inner-${v}`))]}):h.map((v,D)=>a.jsx("button",{type:"button",className:["time-dial-point",v===u?"active":""].filter(Boolean).join(" "),style:m(D,108),onClick:()=>w(v),children:String(v).padStart(2,"0")},`${i}-${v}`))]}),a.jsxs("label",{className:"stack-form",children:["Direkt eingeben",a.jsx("input",{type:"time",step:"300",value:t,onChange:v=>n(v.target.value)})]})]})]})}function tm({draft:e,setDraft:t,lessonTypes:n,instructors:r,students:l,templates:i,onPickStudent:s,onSubmit:o,onDelete:u,onClose:d,isEditing:g}){const h=Up(n,e.student_id,l),m=n.find(N=>Number(N.id)===Number(e.lesson_type_id)),x=h.some(N=>Number(N.id)===Number(e.lesson_type_id))?h:m?[m,...h]:h,w=l.find(N=>Number(N.id)===Number(e.student_id)),v=i.find(N=>Number(N.id)===Number(w==null?void 0:w.template_id)),D=r.find(N=>Number(N.id)===Number(e.instructor_id)),f=v&&Number(v.is_combination)?Kc(v):[],c=ys(m,e.start_at,e.end_at),p=Si(e.start_at),y=Br(e.start_at),j=Br(e.end_at),M=Rp(w,D,e.start_at,e.end_at),S=Fp(w==null?void 0:w.phone),T=S&&M?`https://wa.me/${S}?text=${encodeURIComponent(M)}`:"",$=S&&M?`sms:${S}?body=${encodeURIComponent(M)}`:"",L=w!=null&&w.email&&M?`mailto:${encodeURIComponent(w.email)}?subject=${encodeURIComponent("Fahrstunde")}&body=${encodeURIComponent(M)}`:"";F.useEffect(()=>{x.length&&(x.some(N=>Number(N.id)===Number(e.lesson_type_id))||t(N=>({...N,lesson_type_id:x[0].id})))},[e.lesson_type_id,x,t]),F.useEffect(()=>{e.end_at&&Ul(e.start_at,e.end_at)>0||t(N=>({...N,end_at:_i(N.start_at,Math.max(1,Number((m==null?void 0:m.default_duration)||45)))}))},[m==null?void 0:m.default_duration,e.end_at,e.start_at,t]);function W(N){N&&t(V=>({...V,start_at:jt(N,Br(V.start_at)),end_at:jt(N,Br(V.end_at))}))}function C(N){t(V=>{const J=jt(Si(V.start_at),N);return{...V,start_at:J,end_at:_i(J,ys(m,V.start_at,V.end_at).duration)}})}function R(N){t(V=>({...V,end_at:jt(Si(V.start_at),N)}))}function A(N){const V=Number((m==null?void 0:m.fixed_duration)||0)===1?Math.max(1,Number((m==null?void 0:m.default_duration)||45)):Math.max(1,Number((m==null?void 0:m.default_duration)||45))*N;t(J=>({...J,units:N,end_at:_i(J.start_at,V)}))}return a.jsx("div",{className:"modal-shell",children:a.jsxs("form",{className:"modal-card stack-form",onSubmit:o,children:[a.jsxs("div",{className:"section-head",children:[a.jsxs("div",{children:[a.jsx("p",{className:"eyebrow",children:"Termin"}),a.jsx("h2",{children:g?"Termin bearbeiten":"Termin einplanen"})]}),a.jsx("button",{type:"button",className:"ghost-button",onClick:d,children:"Schliessen"})]}),a.jsxs("label",{children:["Fahrschueler",a.jsxs("select",{name:"student_id",value:e.student_id,onChange:N=>t({...e,student_id:N.target.value}),children:[a.jsx("option",{value:"",children:"Kein Fahrschueler"}),l.map(N=>a.jsxs("option",{value:N.id,children:[N.first_name," ",N.last_name]},N.id))]})]}),a.jsxs("label",{children:["Stundenart",a.jsx("select",{name:"lesson_type_id",value:e.lesson_type_id,onChange:N=>t({...e,lesson_type_id:N.target.value}),children:x.map(N=>a.jsx("option",{value:N.id,children:N.name},N.id))})]}),f.length>0&&a.jsxs("label",{children:["Ausbildungsteil",a.jsx("select",{name:"requirement_phase",value:e.requirement_phase||f[0],onChange:N=>t({...e,requirement_phase:N.target.value}),children:f.map(N=>a.jsx("option",{value:N,children:N},N))})]}),a.jsxs("label",{children:["Fahrlehrer",a.jsx("select",{name:"instructor_id",value:e.instructor_id,onChange:N=>t({...e,instructor_id:N.target.value}),children:r.map(N=>a.jsxs("option",{value:N.id,children:[N.first_name," ",N.last_name]},N.id))})]}),a.jsxs("div",{className:"time-editor-grid",children:[a.jsxs("label",{children:["Datum",a.jsx("input",{type:"date",value:p,onChange:N=>W(N.target.value)})]}),a.jsxs("div",{className:"stack-form",children:[a.jsx("span",{children:"Startzeit"}),a.jsx(Nl,{label:"Start",value:y,onChange:C})]}),a.jsxs("div",{className:"stack-form",children:[a.jsx("span",{children:"Endzeit"}),a.jsx(Nl,{label:"Ende",value:j,onChange:R})]})]}),a.jsxs("div",{className:"duration-preset-panel",children:[a.jsxs("div",{className:"section-head",children:[a.jsxs("div",{children:[a.jsx("p",{className:"eyebrow",children:"Dauer"}),a.jsx("h2",{children:"Fahrstunden"})]}),a.jsxs("span",{className:"badge",children:[c.duration," min · ",c.units," FS"]})]}),a.jsxs("div",{className:"duration-preset-row",children:[[1,2,3,4].map(N=>a.jsxs("button",{type:"button",className:c.units===N?"tab-button active compact":"tab-button compact",onClick:()=>A(N),children:[N," FS"]},N)),Number((m==null?void 0:m.fixed_duration)||0)===1&&a.jsxs("button",{type:"button",className:"ghost-button compact",onClick:()=>A(1),children:["Standard ",m==null?void 0:m.default_duration," min"]})]}),a.jsx("p",{className:"hint-line",children:"Start und Ende lassen sich direkt ueber die Uhr setzen. Die angerechneten Fahrstunden werden daraus automatisch berechnet."})]}),a.jsxs("div",{className:"share-panel",children:[a.jsx("div",{className:"section-head",children:a.jsxs("div",{children:[a.jsx("p",{className:"eyebrow",children:"Benachrichtigung"}),a.jsx("h2",{children:"Termin teilen"})]})}),a.jsxs("div",{className:"share-actions",children:[a.jsxs("a",{className:T?"share-button":"share-button disabled",href:T||void 0,target:"_blank",rel:"noreferrer","aria-disabled":!T,onClick:N=>{T||N.preventDefault()},children:[a.jsx("span",{className:"share-badge whatsapp",children:"WA"}),"WhatsApp"]}),a.jsxs("a",{className:$?"share-button":"share-button disabled",href:$||void 0,"aria-disabled":!$,onClick:N=>{$||N.preventDefault()},children:[a.jsx("span",{className:"share-badge sms",children:"SMS"}),"SMS"]}),a.jsxs("a",{className:L?"share-button":"share-button disabled",href:L||void 0,"aria-disabled":!L,onClick:N=>{L||N.preventDefault()},children:[a.jsx("span",{className:"share-badge email",children:"@"}),"E-Mail"]})]}),a.jsx("p",{className:"hint-line",children:w?"Die Nachricht wird mit Datum, Start- und Endzeit vorbefuellt.":"Fahrschueler waehlen, damit Kontakt-Buttons aktiv werden."})]}),a.jsxs("label",{children:["Status",a.jsxs("select",{name:"status",value:e.status,onChange:N=>t({...e,status:N.target.value}),children:[a.jsx("option",{value:"planned",children:"geplant"}),a.jsx("option",{value:"completed",children:"erledigt"}),a.jsx("option",{value:"cancelled",children:"abgesagt"})]})]}),a.jsxs("label",{children:["Notiz",a.jsx("textarea",{name:"notes",rows:"4",value:e.notes,onChange:N=>t({...e,notes:N.target.value})})]}),a.jsxs("div",{className:"modal-actions",children:[g&&a.jsx("button",{type:"button",className:"danger-button",onClick:u,children:"Termin loeschen"}),a.jsx("button",{type:"button",className:"ghost-button",onClick:()=>s==null?void 0:s(e.student_id),disabled:!e.student_id,children:"Fahrschueler waehlen"}),a.jsx("button",{className:"primary-button",type:"submit",children:"Speichern"})]})]})})}ki.createRoot(document.getElementById("root")).render(a.jsx(md.StrictMode,{children:a.jsx(Vp,{})})); diff --git a/www/fahrschultermin.de/public/index.html b/www/fahrschultermin.de/public/index.html new file mode 100644 index 0000000..06213e8 --- /dev/null +++ b/www/fahrschultermin.de/public/index.html @@ -0,0 +1,27 @@ + + + + + +fahrschultermin.de – Erfolgreich eingerichtet + + + +

✅ fahrschultermin.de ist betriebsbereit

+

Diese Seite wurde automatisch vom Webhosting‑Stack erzeugt.

+
+ Nächste Schritte: +
    +
  • Ersetze diesen Inhalt durch deine eigene Website.
  • +
  • Lade Dateien in das öffentliche Verzeichnis dieser Domain hoch.
  • +
  • Für PHP‑Anwendungen lege eine index.php an.
  • +
+
+

Server: core

+ + \ No newline at end of file diff --git a/www/fahrschultermin.de/public/index.php b/www/fahrschultermin.de/public/index.php new file mode 100644 index 0000000..3fbfe27 --- /dev/null +++ b/www/fahrschultermin.de/public/index.php @@ -0,0 +1,100 @@ +path(); + +if ($request->method() === 'OPTIONS') { + http_response_code(204); + exit; +} + +if (str_starts_with($path, '/api/v1')) { + $auth = new AuthController(); + $bootstrapController = new BootstrapController(); + $students = new StudentsController(); + $references = new ReferenceDataController(); + $appointments = new AppointmentsController(); + $users = new UsersController(); + $tenants = new TenantsController(); + $instructors = new InstructorsController(); + + $router->post('/api/v1/auth/login', [$auth, 'login']); + $router->post('/api/v1/auth/logout', [$auth, 'logout']); + $router->get('/api/v1/auth/me', [$auth, 'me']); + $router->get('/api/v1/bootstrap', $bootstrapController); + $router->get('/api/v1/students', [$students, 'index']); + $router->post('/api/v1/students', [$students, 'store']); + $router->patch('/api/v1/students/{id}/training', [$students, 'updateTraining']); + $router->patch('/api/v1/students/{id}', [$students, 'update']); + $router->delete('/api/v1/students/{id}', [$students, 'destroy']); + $router->get('/api/v1/lesson-types', [$references, 'lessonTypes']); + $router->post('/api/v1/lesson-types', [$references, 'storeLessonType']); + $router->post('/api/v1/lesson-types/reorder', [$references, 'reorderLessonTypes']); + $router->patch('/api/v1/lesson-types/{id}', [$references, 'updateLessonType']); + $router->delete('/api/v1/lesson-types/{id}', [$references, 'destroyLessonType']); + $router->post('/api/v1/lesson-types/visibility', [$references, 'updateLessonTypeMatrix']); + $router->get('/api/v1/license-class-templates', [$references, 'templates']); + $router->post('/api/v1/license-class-templates', [$references, 'storeTemplate']); + $router->patch('/api/v1/license-class-templates/{id}', [$references, 'updateTemplate']); + $router->get('/api/v1/instructors', [$instructors, 'index']); + $router->post('/api/v1/instructors', [$instructors, 'store']); + $router->patch('/api/v1/instructors/{id}', [$instructors, 'update']); + $router->get('/api/v1/users', [$users, 'index']); + $router->post('/api/v1/users', [$users, 'store']); + $router->patch('/api/v1/users/{id}', [$users, 'update']); + $router->get('/api/v1/tenants', [$tenants, 'index']); + $router->post('/api/v1/tenants', [$tenants, 'store']); + $router->patch('/api/v1/tenants/{id}', [$tenants, 'update']); + $router->patch('/api/v1/tenant/profile', [$tenants, 'updateOwn']); + $router->get('/api/v1/appointments', [$appointments, 'index']); + $router->post('/api/v1/appointments/validate', [$appointments, 'validate']); + $router->post('/api/v1/appointments', [$appointments, 'store']); + $router->patch('/api/v1/appointments/{id}', [$appointments, 'update']); + $router->delete('/api/v1/appointments/{id}', [$appointments, 'destroy']); + + $router->dispatch(); + exit; +} + +$assetManifestPath = __DIR__ . '/assets/.vite/manifest.json'; +$mainAsset = '/assets/index.js'; +$styleAsset = '/assets/index.css'; + +if (is_file($assetManifestPath)) { + $manifest = json_decode(file_get_contents($assetManifestPath), true); + $entry = $manifest['src/main.jsx'] ?? null; + if (is_array($entry)) { + $mainAsset = '/assets/' . ltrim((string) $entry['file'], '/'); + if (!empty($entry['css'][0])) { + $styleAsset = '/assets/' . ltrim((string) $entry['css'][0], '/'); + } + } +} + +?> + + + + + DriveTime Planner + + + +
+ + +