Slim API deployment - composer install + full CRUD endpoints

This commit is contained in:
Hermes Agent
2026-05-20 14:36:05 +02:00
parent 48bf9c7088
commit 8ab4e532fd
1727 changed files with 7746 additions and 7 deletions

View File

@@ -96,6 +96,11 @@ final class AppointmentsController
$student = null;
if (!empty($payload['student_id'])) {
// Reject student_id if lesson_type does not allow it
if ((int) $lessonType['allows_student'] !== 1) {
Response::json(['message' => 'Bei dieser Stundenart kann kein Fahrschüler ausgewählt werden'], 422);
}
$student = (new StudentRepository())->find($tenantId, (int) $payload['student_id']);
if ($student === null) {
Response::json(['message' => 'Unbekannter Fahrschueler'], 422);
@@ -154,6 +159,7 @@ final class AppointmentsController
'units' => $units,
'status' => $payload['status'] ?? 'planned',
'notes' => $payload['notes'] ?? '',
'notes_private' => $payload['notes_private'] ?? '',
'warning_acknowledged' => !empty($payload['warning_acknowledged']),
'units_breakdown' => $unitsBreakdown,
];

View File

@@ -10,7 +10,7 @@ final class AppointmentRepository extends BaseRepository
{
$sql = 'SELECT a.*, s.first_name AS student_first_name, s.last_name AS student_last_name, s.needs_glasses AS student_needs_glasses,
i.first_name AS instructor_first_name, i.last_name AS instructor_last_name,
lt.name AS lesson_type_name, lt.color AS lesson_type_color, lt.is_rest_relevant,
lt.name AS lesson_type_name, lt.color AS lesson_type_color, lt.is_rest_relevant, lt.allows_student, lt.category AS lesson_type_category,
(
SELECT au.requirement_key
FROM appointment_units au
@@ -61,9 +61,9 @@ final class AppointmentRepository extends BaseRepository
$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)
(tenant_id, student_id, instructor_id, lesson_type_id, title, category, start_at, end_at, units, status, notes, notes_private, 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)'
(:tenant_id, :student_id, :instructor_id, :lesson_type_id, :title, :category, :start_at, :end_at, :units, :status, :notes, :notes_private, :warning_acknowledged, :created_by, :updated_by, :created_at, :updated_at)'
);
$statement->execute([
'tenant_id' => $tenantId,
@@ -77,6 +77,7 @@ final class AppointmentRepository extends BaseRepository
'units' => (int) $data['units'],
'status' => $data['status'],
'notes' => $data['notes'] ?? '',
'notes_private' => $data['notes_private'] ?? '',
'warning_acknowledged' => !empty($data['warning_acknowledged']) ? 1 : 0,
'created_by' => $actorId,
'updated_by' => $actorId,
@@ -96,7 +97,7 @@ final class AppointmentRepository extends BaseRepository
'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,
status = :status, notes = :notes, notes_private = :notes_private, warning_acknowledged = :warning_acknowledged,
updated_by = :updated_by, updated_at = :updated_at
WHERE tenant_id = :tenant_id AND id = :id'
);
@@ -113,6 +114,7 @@ final class AppointmentRepository extends BaseRepository
'units' => (int) $data['units'],
'status' => $data['status'],
'notes' => $data['notes'] ?? '',
'notes_private' => $data['notes_private'] ?? '',
'warning_acknowledged' => !empty($data['warning_acknowledged']) ? 1 : 0,
'updated_by' => $actorId,
'updated_at' => $this->now(),

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
final class AppointmentRequestRepository extends BaseRepository
{
public function create(
int $studentUserId,
int $instructorId,
int $lessonTypeId,
string $start,
string $end,
string $notes = ''
): array {
$stmt = $this->db->prepare(
'INSERT INTO appointment_requests
(student_user_id, instructor_id, lesson_type_id, requested_start, requested_end, notes)
VALUES (:student_user_id, :instructor_id, :lesson_type_id, :start, :end, :notes)
RETURNING *'
);
$stmt->execute([
'student_user_id' => $studentUserId,
'instructor_id' => $instructorId,
'lesson_type_id' => $lessonTypeId,
'start' => $start,
'end' => $end,
'notes' => $notes,
]);
return $stmt->fetch();
}
public function findById(int $id): ?array
{
$stmt = $this->db->prepare('SELECT * FROM appointment_requests WHERE id = :id');
$stmt->execute(['id' => $id]);
$result = $stmt->fetch();
return $result ?: null;
}
public function getPendingForInstructor(int $instructorId): array
{
$stmt = $this->db->prepare(
'SELECT ar.*,
u.first_name AS student_first_name, u.last_name AS student_last_name,
lt.name AS lesson_type_name, lt.category
FROM appointment_requests ar
JOIN users u ON u.id = ar.student_user_id
JOIN lesson_types lt ON lt.id = ar.lesson_type_id
WHERE ar.instructor_id = :instructor_id AND ar.status = \'pending\'
ORDER BY ar.requested_start ASC'
);
$stmt->execute(['instructor_id' => $instructorId]);
return $stmt->fetchAll();
}
public function getForStudent(int $studentUserId): array
{
$stmt = $this->db->prepare(
'SELECT ar.*, lt.name AS lesson_type_name
FROM appointment_requests ar
JOIN lesson_types lt ON lt.id = ar.lesson_type_id
WHERE ar.student_user_id = :student_user_id
ORDER BY ar.requested_start DESC'
);
$stmt->execute(['student_user_id' => $studentUserId]);
return $stmt->fetchAll();
}
public function confirm(int $id, int $responseBy, int $appointmentId): bool
{
$stmt = $this->db->prepare(
'UPDATE appointment_requests
SET status = \'confirmed\', responded_at = NOW(), response_by = :response_by,
confirmed_appointment_id = :appointment_id
WHERE id = :id AND status = \'pending\'
RETURNING id'
);
$stmt->execute(['id' => $id, 'response_by' => $responseBy, 'appointment_id' => $appointmentId]);
return (bool) $stmt->fetch();
}
public function reject(int $id, int $responseBy): bool
{
$stmt = $this->db->prepare(
'UPDATE appointment_requests
SET status = \'rejected\', responded_at = NOW(), response_by = :response_by
WHERE id = :id AND status = \'pending\'
RETURNING id'
);
$stmt->execute(['id' => $id, 'response_by' => $responseBy]);
return (bool) $stmt->fetch();
}
public function cancel(int $id): bool
{
$stmt = $this->db->prepare(
'UPDATE appointment_requests SET status = \'cancelled\' WHERE id = :id RETURNING id'
);
$stmt->execute(['id' => $id]);
return (bool) $stmt->fetch();
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
final class InstructorAvailabilityRepository extends BaseRepository
{
public function getForInstructor(int $instructorId): array
{
$stmt = $this->db->prepare(
'SELECT * FROM instructor_availability
WHERE instructor_id = :instructor_id
ORDER BY weekday, time_from'
);
$stmt->execute(['instructor_id' => $instructorId]);
return $stmt->fetchAll();
}
public function getActiveForInstructor(int $instructorId): array
{
$stmt = $this->db->prepare(
'SELECT * FROM instructor_availability
WHERE instructor_id = :instructor_id AND is_active = 1
ORDER BY weekday, time_from'
);
$stmt->execute(['instructor_id' => $instructorId]);
return $stmt->fetchAll();
}
public function upsert(int $instructorId, array $entries): void
{
// Delete all existing, then insert new (full replace)
$this->db->beginTransaction();
try {
$stmt = $this->db->prepare('DELETE FROM instructor_availability WHERE instructor_id = :instructor_id');
$stmt->execute(['instructor_id' => $instructorId]);
$insert = $this->db->prepare(
'INSERT INTO instructor_availability (instructor_id, weekday, time_from, time_to, is_active)
VALUES (:instructor_id, :weekday, :time_from, :time_to, :is_active)'
);
foreach ($entries as $e) {
$insert->execute([
'instructor_id' => $instructorId,
'weekday' => (int) $e['weekday'],
'time_from' => $e['time_from'],
'time_to' => $e['time_to'],
'is_active' => (int) ($e['is_active'] ?? 1),
]);
}
$this->db->commit();
} catch (\Throwable $e) {
$this->db->rollBack();
throw $e;
}
}
public function delete(int $id): bool
{
$stmt = $this->db->prepare('DELETE FROM instructor_availability WHERE id = :id RETURNING id');
$stmt->execute(['id' => $id]);
return (bool) $stmt->fetch();
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
final class InstructorBreakRuleRepository extends BaseRepository
{
public function getForInstructor(int $instructorId): array
{
$stmt = $this->db->prepare(
'SELECT * FROM instructor_break_rules
WHERE instructor_id = :instructor_id
ORDER BY condition_type, threshold'
);
$stmt->execute(['instructor_id' => $instructorId]);
return $stmt->fetchAll();
}
public function getActiveForInstructor(int $instructorId): array
{
$stmt = $this->db->prepare(
'SELECT * FROM instructor_break_rules
WHERE instructor_id = :instructor_id AND is_active = 1
ORDER BY condition_type, threshold'
);
$stmt->execute(['instructor_id' => $instructorId]);
return $stmt->fetchAll();
}
public function create(
int $instructorId,
string $conditionType,
string $operator,
float $threshold,
int $breakMinutes,
bool $isActive = true
): array {
$stmt = $this->db->prepare(
'INSERT INTO instructor_break_rules
(instructor_id, condition_type, operator, threshold, break_minutes, is_active)
VALUES (:instructor_id, :condition_type, :operator, :threshold, :break_minutes, :is_active)
RETURNING *'
);
$stmt->execute([
'instructor_id' => $instructorId,
'condition_type' => $conditionType,
'operator' => $operator,
'threshold' => $threshold,
'break_minutes' => $breakMinutes,
'is_active' => $isActive ? 1 : 0,
]);
return $stmt->fetch();
}
public function update(
int $id,
string $conditionType,
string $operator,
float $threshold,
int $breakMinutes,
bool $isActive
): bool {
$stmt = $this->db->prepare(
'UPDATE instructor_break_rules
SET condition_type = :condition_type, operator = :operator,
threshold = :threshold, break_minutes = :break_minutes, is_active = :is_active
WHERE id = :id RETURNING id'
);
$stmt->execute([
'id' => $id,
'condition_type' => $conditionType,
'operator' => $operator,
'threshold' => $threshold,
'break_minutes' => $breakMinutes,
'is_active' => $isActive ? 1 : 0,
]);
return (bool) $stmt->fetch();
}
public function delete(int $id): bool
{
$stmt = $this->db->prepare('DELETE FROM instructor_break_rules WHERE id = :id RETURNING id');
$stmt->execute(['id' => $id]);
return (bool) $stmt->fetch();
}
public function setActive(int $id, bool $isActive): bool
{
$stmt = $this->db->prepare(
'UPDATE instructor_break_rules SET is_active = :is_active WHERE id = :id RETURNING id'
);
$stmt->execute(['id' => $id, 'is_active' => $isActive ? 1 : 0]);
return (bool) $stmt->fetch();
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
final class InstructorHomeToTenantRepository extends BaseRepository
{
public function getForInstructor(int $instructorId): array
{
$stmt = $this->db->prepare(
'SELECT h.*, t.name AS tenant_name
FROM instructor_home_to_tenant h
JOIN tenants t ON t.id = h.tenant_id
WHERE h.instructor_id = :instructor_id
ORDER BY t.name'
);
$stmt->execute(['instructor_id' => $instructorId]);
return $stmt->fetchAll();
}
public function getMinutes(int $instructorId, int $tenantId): ?int
{
$stmt = $this->db->prepare(
'SELECT minutes FROM instructor_home_to_tenant
WHERE instructor_id = :instructor_id AND tenant_id = :tenant_id'
);
$stmt->execute(['instructor_id' => $instructorId, 'tenant_id' => $tenantId]);
$result = $stmt->fetch();
return $result ? (int) $result['minutes'] : null;
}
public function upsert(int $instructorId, int $tenantId, int $minutes): array
{
$stmt = $this->db->prepare(
'INSERT INTO instructor_home_to_tenant (instructor_id, tenant_id, minutes)
VALUES (:instructor_id, :tenant_id, :minutes)
ON CONFLICT (instructor_id, tenant_id)
DO UPDATE SET minutes = :minutes
RETURNING *'
);
$stmt->execute([
'instructor_id' => $instructorId,
'tenant_id' => $tenantId,
'minutes' => $minutes,
]);
return $stmt->fetch();
}
public function delete(int $id): bool
{
$stmt = $this->db->prepare('DELETE FROM instructor_home_to_tenant WHERE id = :id RETURNING id');
$stmt->execute(['id' => $id]);
return (bool) $stmt->fetch();
}
}

View File

@@ -79,4 +79,41 @@ final class InstructorRepository extends BaseRepository
return $statement->fetch() ?: null;
}
public function findById(int $id): ?array
{
$statement = $this->db->prepare('SELECT * FROM instructors WHERE id = :id');
$statement->execute(['id' => $id]);
return $statement->fetch() ?: null;
}
public function listForTenant(int $tenantId): array
{
$statement = $this->db->prepare('SELECT * FROM instructors WHERE tenant_id = :tenant_id AND is_active = 1 ORDER BY last_name, first_name');
$statement->execute(['tenant_id' => $tenantId]);
return $statement->fetchAll();
}
public function updateSettings(int $instructorId, array $settings): bool
{
$allowed = ['max_daily_units', 'max_daily_override', 'max_weekly_units', 'max_weekly_override', 'booking_enabled'];
$sets = [];
$params = ['id' => $instructorId];
foreach ($allowed as $key) {
if (array_key_exists($key, $settings)) {
$sets[] = "{$key} = :{$key}";
$params[$key] = $settings[$key];
}
}
if (empty($sets)) {
return false;
}
$sql = 'UPDATE instructors SET ' . implode(', ', $sets) . ', updated_at = :updated_at WHERE id = :id';
$params['updated_at'] = $this->now();
$statement = $this->db->prepare($sql);
$statement->execute($params);
return (bool) $statement->rowCount();
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
final class InstructorTenantRepository extends BaseRepository
{
public function getTenantsForInstructor(int $instructorId): array
{
$stmt = $this->db->prepare(
'SELECT it.*, t.name AS tenant_name
FROM instructor_tenants it
JOIN tenants t ON t.id = it.tenant_id
WHERE it.instructor_id = :instructor_id
ORDER BY it.is_primary DESC, t.name'
);
$stmt->execute(['instructor_id' => $instructorId]);
return $stmt->fetchAll();
}
public function isPrimary(int $instructorId, int $tenantId): bool
{
$stmt = $this->db->prepare(
'SELECT 1 FROM instructor_tenants WHERE instructor_id = :instructor_id AND tenant_id = :tenant_id AND is_primary = 1'
);
$stmt->execute(['instructor_id' => $instructorId, 'tenant_id' => $tenantId]);
return (bool) $stmt->fetch();
}
public function add(int $instructorId, int $tenantId, bool $isPrimary = false): array
{
$stmt = $this->db->prepare(
'INSERT INTO instructor_tenants (instructor_id, tenant_id, is_primary)
VALUES (:instructor_id, :tenant_id, :is_primary)
ON CONFLICT (instructor_id, tenant_id) DO UPDATE SET is_primary = :is_primary
RETURNING *'
);
$stmt->execute([
'instructor_id' => $instructorId,
'tenant_id' => $tenantId,
'is_primary' => $isPrimary ? 1 : 0,
]);
return $stmt->fetch();
}
public function remove(int $instructorId, int $tenantId): bool
{
$stmt = $this->db->prepare(
'DELETE FROM instructor_tenants WHERE instructor_id = :instructor_id AND tenant_id = :tenant_id RETURNING id'
);
$stmt->execute(['instructor_id' => $instructorId, 'tenant_id' => $tenantId]);
return (bool) $stmt->fetch();
}
public function setPrimary(int $instructorId, int $tenantId): void
{
$this->db->beginTransaction();
try {
$stmt = $this->db->prepare('UPDATE instructor_tenants SET is_primary = 0 WHERE instructor_id = :instructor_id');
$stmt->execute(['instructor_id' => $instructorId]);
$stmt = $this->db->prepare('UPDATE instructor_tenants SET is_primary = 1 WHERE instructor_id = :instructor_id AND tenant_id = :tenant_id');
$stmt->execute(['instructor_id' => $instructorId, 'tenant_id' => $tenantId]);
$this->db->commit();
} catch (\Throwable $e) {
$this->db->rollBack();
throw $e;
}
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
final class InstructorTravelTimeRepository extends BaseRepository
{
public function getForInstructor(int $instructorId): array
{
$stmt = $this->db->prepare(
'SELECT t.*,
ft.name AS from_tenant_name,
tt.name AS to_tenant_name
FROM instructor_travel_times t
JOIN tenants ft ON ft.id = t.from_tenant_id
JOIN tenants tt ON tt.id = t.to_tenant_id
WHERE t.instructor_id = :instructor_id
ORDER BY ft.name, tt.name'
);
$stmt->execute(['instructor_id' => $instructorId]);
return $stmt->fetchAll();
}
public function getMinutes(int $instructorId, int $fromTenantId, int $toTenantId): ?int
{
$stmt = $this->db->prepare(
'SELECT minutes FROM instructor_travel_times
WHERE instructor_id = :instructor_id
AND from_tenant_id = :from_tenant_id
AND to_tenant_id = :to_tenant_id'
);
$stmt->execute([
'instructor_id' => $instructorId,
'from_tenant_id' => $fromTenantId,
'to_tenant_id' => $toTenantId,
]);
$result = $stmt->fetch();
return $result ? (int) $result['minutes'] : null;
}
public function upsert(int $instructorId, int $fromTenantId, int $toTenantId, int $minutes): array
{
$stmt = $this->db->prepare(
'INSERT INTO instructor_travel_times (instructor_id, from_tenant_id, to_tenant_id, minutes)
VALUES (:instructor_id, :from_tenant_id, :to_tenant_id, :minutes)
ON CONFLICT (instructor_id, from_tenant_id, to_tenant_id)
DO UPDATE SET minutes = :minutes
RETURNING *'
);
$stmt->execute([
'instructor_id' => $instructorId,
'from_tenant_id' => $fromTenantId,
'to_tenant_id' => $toTenantId,
'minutes' => $minutes,
]);
return $stmt->fetch();
}
public function delete(int $id): bool
{
$stmt = $this->db->prepare('DELETE FROM instructor_travel_times WHERE id = :id RETURNING id');
$stmt->execute(['id' => $id]);
return (bool) $stmt->fetch();
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
final class InstructorVacationRepository extends BaseRepository
{
public function getForInstructor(int $instructorId): array
{
$stmt = $this->db->prepare(
'SELECT * FROM instructor_vacations
WHERE instructor_id = :instructor_id
ORDER BY date_from ASC'
);
$stmt->execute(['instructor_id' => $instructorId]);
return $stmt->fetchAll();
}
public function getForInstructorInRange(int $instructorId, string $from, string $to): array
{
$stmt = $this->db->prepare(
'SELECT * FROM instructor_vacations
WHERE instructor_id = :instructor_id
AND date_from <= :to AND date_to >= :from
ORDER BY date_from ASC'
);
$stmt->execute(['instructor_id' => $instructorId, 'from' => $from, 'to' => $to]);
return $stmt->fetchAll();
}
public function create(int $instructorId, string $dateFrom, string $dateTo, string $notes = ''): array
{
$stmt = $this->db->prepare(
'INSERT INTO instructor_vacations (instructor_id, date_from, date_to, notes)
VALUES (:instructor_id, :date_from, :date_to, :notes)
RETURNING *'
);
$stmt->execute([
'instructor_id' => $instructorId,
'date_from' => $dateFrom,
'date_to' => $dateTo,
'notes' => $notes,
]);
return $stmt->fetch();
}
public function delete(int $id): bool
{
$stmt = $this->db->prepare('DELETE FROM instructor_vacations WHERE id = :id RETURNING id');
$stmt->execute(['id' => $id]);
return (bool) $stmt->fetch();
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
final class PrivateAppointmentRepository extends BaseRepository
{
public function getForInstructor(int $instructorId): array
{
$stmt = $this->db->prepare(
'SELECT * FROM private_appointments
WHERE instructor_id = :instructor_id
ORDER BY date ASC, time_from ASC'
);
$stmt->execute(['instructor_id' => $instructorId]);
return $stmt->fetchAll();
}
public function getForInstructorInRange(int $instructorId, string $from, string $to): array
{
$stmt = $this->db->prepare(
'SELECT * FROM private_appointments
WHERE instructor_id = :instructor_id
AND date >= :from AND date <= :to
ORDER BY date ASC, time_from ASC'
);
$stmt->execute(['instructor_id' => $instructorId, 'from' => $from, 'to' => $to]);
return $stmt->fetchAll();
}
public function create(int $instructorId, string $title, string $date, string $timeFrom, string $timeTo, string $color = '#9ca3af'): array
{
$stmt = $this->db->prepare(
'INSERT INTO private_appointments (instructor_id, title, date, time_from, time_to, color)
VALUES (:instructor_id, :title, :date, :time_from, :time_to, :color)
RETURNING *'
);
$stmt->execute([
'instructor_id' => $instructorId,
'title' => $title,
'date' => $date,
'time_from' => $timeFrom,
'time_to' => $timeTo,
'color' => $color,
]);
return $stmt->fetch();
}
public function delete(int $id): bool
{
$stmt = $this->db->prepare('DELETE FROM private_appointments WHERE id = :id RETURNING id');
$stmt->execute(['id' => $id]);
return (bool) $stmt->fetch();
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
final class StudentTenantRepository extends BaseRepository
{
public function findByUserAndTenant(int $userId, int $tenantId): ?array
{
$stmt = $this->db->prepare(
'SELECT * FROM student_tenants WHERE student_user_id = :user_id AND tenant_id = :tenant_id'
);
$stmt->execute(['user_id' => $userId, 'tenant_id' => $tenantId]);
$result = $stmt->fetch();
return $result ?: null;
}
public function create(int $userId, int $tenantId, string $status = 'pending'): array
{
$stmt = $this->db->prepare(
'INSERT INTO student_tenants (student_user_id, tenant_id, status)
VALUES (:user_id, :tenant_id, :status) RETURNING *'
);
$stmt->execute([
'user_id' => $userId,
'tenant_id' => $tenantId,
'status' => $status,
]);
return $stmt->fetch();
}
public function confirm(int $id, int $confirmedBy): bool
{
$stmt = $this->db->prepare(
'UPDATE student_tenants
SET status = \'active\', confirmed_at = NOW(), confirmed_by = :confirmed_by
WHERE id = :id RETURNING id'
);
$stmt->execute(['id' => $id, 'confirmed_by' => $confirmedBy]);
return (bool) $stmt->fetch();
}
public function suspend(int $id): bool
{
$stmt = $this->db->prepare(
'UPDATE student_tenants SET status = \'suspended\' WHERE id = :id RETURNING id'
);
$stmt->execute(['id' => $id]);
return (bool) $stmt->fetch();
}
public function getPendingForTenant(int $tenantId): array
{
$stmt = $this->db->prepare(
'SELECT st.*, u.email, u.first_name, u.last_name
FROM student_tenants st
JOIN users u ON u.id = st.student_user_id
WHERE st.tenant_id = :tenant_id AND st.status = \'pending\'
ORDER BY st.registered_at DESC'
);
$stmt->execute(['tenant_id' => $tenantId]);
return $stmt->fetchAll();
}
public function getActiveForUser(int $userId): array
{
$stmt = $this->db->prepare(
'SELECT st.*, t.name AS tenant_name
FROM student_tenants st
JOIN tenants t ON t.id = st.tenant_id
WHERE st.student_user_id = :user_id AND st.status = \'active\''
);
$stmt->execute(['user_id' => $userId]);
return $stmt->fetchAll();
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
final class TenantRegistrationCodeRepository extends BaseRepository
{
public function findByCode(string $code): ?array
{
$stmt = $this->db->prepare(
'SELECT trc.*, t.name AS tenant_name
FROM tenant_registration_codes trc
JOIN tenants t ON t.id = trc.tenant_id
WHERE trc.code = :code AND trc.is_active = 1'
);
$stmt->execute(['code' => $code]);
$result = $stmt->fetch();
return $result ?: null;
}
public function findByTenant(int $tenantId): ?array
{
$stmt = $this->db->prepare(
'SELECT * FROM tenant_registration_codes WHERE tenant_id = :tenant_id AND is_active = 1'
);
$stmt->execute(['tenant_id' => $tenantId]);
$result = $stmt->fetch();
return $result ?: null;
}
public function create(int $tenantId, string $code): array
{
$stmt = $this->db->prepare(
'INSERT INTO tenant_registration_codes (tenant_id, code) VALUES (:tenant_id, :code) RETURNING *'
);
$stmt->execute(['tenant_id' => $tenantId, 'code' => $code]);
return $stmt->fetch();
}
public function update(int $tenantId, string $code): bool
{
$stmt = $this->db->prepare(
'UPDATE tenant_registration_codes SET code = :code WHERE tenant_id = :tenant_id RETURNING id'
);
$stmt->execute(['tenant_id' => $tenantId, 'code' => $code]);
return (bool) $stmt->fetch();
}
public function incrementUsedCount(int $tenantId): void
{
$stmt = $this->db->prepare(
'UPDATE tenant_registration_codes SET used_count = used_count + 1 WHERE tenant_id = :tenant_id'
);
$stmt->execute(['tenant_id' => $tenantId]);
}
}

View File

@@ -0,0 +1,22 @@
-- Migration: 015_lesson_type_allows_student_and_notes_private.sql
-- Adds allows_student column to lesson_types and notes_private to appointments
-- 1. lesson_types.allows_student: which lesson types can be assigned a student
ALTER TABLE lesson_types ADD COLUMN IF NOT EXISTS allows_student integer NOT NULL DEFAULT 0;
COMMENT ON COLUMN lesson_types.allows_student IS '1 = lesson type can have a student_id (e.g. driving lessons), 0 = no student (theory, work, private)';
-- 2. appointments.notes_private: private note only visible to the instructor (not chef/office)
ALTER TABLE appointments ADD COLUMN IF NOT EXISTS notes_private text DEFAULT '';
-- Default: only 'student' category allows student_id, all other categories = 0
UPDATE lesson_types SET allows_student = 1 WHERE category = 'student';
UPDATE lesson_types SET allows_student = 0 WHERE category != 'student';
-- For existing appointments, clear student_id if the lesson_type doesn't allow it
-- (data cleanup - not normally needed but defensive)
UPDATE appointments
SET student_id = NULL
WHERE student_id IS NOT NULL
AND lesson_type_id IN (
SELECT id FROM lesson_types WHERE allows_student = 0
);

View File

@@ -0,0 +1,39 @@
-- Migration: 016_consolidate_lesson_types.sql
-- Reihen: tenant 1 und tenant 2 bereinigen, konsistente Einstellungen
-- ══════════════════════════════════════════════════════
-- TENANT 1
-- ══════════════════════════════════════════════════════
-- Tenant 1: Doppelte Einweisung (id=1, id=14 → id=14 renamed to avoid conflict via rename approach)
-- Already unique (no action needed)
-- Tenant 1: Doppelte Übungsstunde (id=2, id=15 → id=15 is in tenant 1 but was already unique)
-- Actually tenant 1 has only id=2 (Uebungsstunde) - no duplicate
-- Tenant 1 work type fixes:
-- Sonstige Arbeiten (id=8): is_rest_relevant=1 (zählt für Ruhezeit)
UPDATE lesson_types SET is_rest_relevant = 1, fixed_duration = 0 WHERE id = 8 AND tenant_id = 1;
-- Theorieunterricht (id=7): is_rest_relevant=1, is_billable=1, fixed_duration=0
UPDATE lesson_types SET is_rest_relevant = 1, fixed_duration = 0, is_billable = 1 WHERE id = 7 AND tenant_id = 1;
-- ══════════════════════════════════════════════════════
-- TENANT 2
-- ══════════════════════════════════════════════════════
-- Tenant 2: Doppelte Namen konsolidieren
-- Autobahnfahrt → Autobahn (id=13 → name='Autobahn')
UPDATE lesson_types SET name = 'Autobahn' WHERE id = 13 AND tenant_id = 2;
-- Übungsstunde (id=15 already named correctly)
-- Verify tenant 2 student types
-- Note: id=11 Nachtfahrten stays (different from tenant 1 which has no Nachtfahrten)
-- id=16 Praktische Prüfung stays
-- id=12 Überlandfahrt stays
-- ══════════════════════════════════════════════════════
-- ALL TENANTS: allows_student defaults
-- ══════════════════════════════════════════════════════
-- already done in migration 015
-- student=1, all others=0

View File

@@ -0,0 +1,202 @@
-- Migration: 017_student_booking_schema.sql
-- Fahrschüler-Buchungssystem + Multi-Tenant Instructor + Break-Rules
BEGIN;
-- ══════════════════════════════════════════════════════
-- 1. users.role ist bereits text (kein ENUM), keine Änderung nötig
-- ══════════════════════════════════════════════════════
-- ══════════════════════════════════════════════════════
-- 2. instructors erweitern: UE-Limits + Booking-Settings
-- ══════════════════════════════════════════════════════
ALTER TABLE instructors ADD COLUMN IF NOT EXISTS max_daily_units integer DEFAULT NULL;
ALTER TABLE instructors ADD COLUMN IF NOT EXISTS max_daily_override integer DEFAULT 0;
ALTER TABLE instructors ADD COLUMN IF NOT EXISTS max_weekly_units integer DEFAULT NULL;
ALTER TABLE instructors ADD COLUMN IF NOT EXISTS max_weekly_override integer DEFAULT 0;
ALTER TABLE instructors ADD COLUMN IF NOT EXISTS booking_enabled integer DEFAULT 1;
-- ══════════════════════════════════════════════════════
-- 3. students erweitern: user_id FK
-- ══════════════════════════════════════════════════════
ALTER TABLE students ADD COLUMN IF NOT EXISTS user_id bigint REFERENCES users(id) NULL;
-- ══════════════════════════════════════════════════════
-- 4. tenants erweitern: free_cancellation_hours
-- ══════════════════════════════════════════════════════
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS free_cancellation_hours integer DEFAULT 24;
-- ══════════════════════════════════════════════════════
-- 5. tenant_registration_codes
-- ══════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS tenant_registration_codes (
id bigserial PRIMARY KEY,
tenant_id bigint NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
code text NOT NULL UNIQUE,
is_active integer DEFAULT 1,
used_count integer DEFAULT 0,
created_at timestamptz DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_tenant_reg_codes_code ON tenant_registration_codes(code);
CREATE INDEX IF NOT EXISTS idx_tenant_reg_codes_tenant ON tenant_registration_codes(tenant_id);
-- ══════════════════════════════════════════════════════
-- 6. student_tenants
-- ══════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS student_tenants (
id bigserial PRIMARY KEY,
student_user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id bigint NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'active', 'suspended')),
registered_at timestamptz DEFAULT NOW(),
confirmed_at timestamptz NULL,
confirmed_by bigint NULL REFERENCES users(id),
UNIQUE(student_user_id, tenant_id)
);
CREATE INDEX IF NOT EXISTS idx_student_tenants_user ON student_tenants(student_user_id);
CREATE INDEX IF NOT EXISTS idx_student_tenants_tenant ON student_tenants(tenant_id);
-- ══════════════════════════════════════════════════════
-- 7. instructor_availability
-- ══════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS instructor_availability (
id bigserial PRIMARY KEY,
instructor_id bigint NOT NULL REFERENCES instructors(id) ON DELETE CASCADE,
weekday integer NOT NULL CHECK (weekday BETWEEN 0 AND 6),
time_from text NOT NULL,
time_to text NOT NULL,
is_active integer DEFAULT 1,
UNIQUE(instructor_id, weekday, time_from)
);
CREATE INDEX IF NOT EXISTS idx_instructor_avail_instructor ON instructor_availability(instructor_id);
-- ══════════════════════════════════════════════════════
-- 8. appointment_requests
-- ══════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS appointment_requests (
id bigserial PRIMARY KEY,
student_user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
instructor_id bigint NOT NULL REFERENCES instructors(id) ON DELETE CASCADE,
lesson_type_id bigint NOT NULL REFERENCES lesson_types(id),
requested_start timestamptz NOT NULL,
requested_end timestamptz NOT NULL,
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'rejected', 'cancelled')),
requested_at timestamptz DEFAULT NOW(),
responded_at timestamptz NULL,
response_by bigint NULL REFERENCES users(id),
notes text DEFAULT '',
confirmed_appointment_id bigint NULL REFERENCES appointments(id)
);
CREATE INDEX IF NOT EXISTS idx_appt_req_student ON appointment_requests(student_user_id);
CREATE INDEX IF NOT EXISTS idx_appt_req_instructor ON appointment_requests(instructor_id);
CREATE INDEX IF NOT EXISTS idx_appt_req_status ON appointment_requests(status);
-- ══════════════════════════════════════════════════════
-- 9. instructor_vacations
-- ══════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS instructor_vacations (
id bigserial PRIMARY KEY,
instructor_id bigint NOT NULL REFERENCES instructors(id) ON DELETE CASCADE,
date_from date NOT NULL,
date_to date NOT NULL,
notes text DEFAULT '',
created_at timestamptz DEFAULT NOW(),
UNIQUE(instructor_id, date_from, date_to)
);
CREATE INDEX IF NOT EXISTS idx_instructor_vacations_instructor ON instructor_vacations(instructor_id);
CREATE INDEX IF NOT EXISTS idx_instructor_vacations_dates ON instructor_vacations(date_from, date_to);
-- ══════════════════════════════════════════════════════
-- 10. private_appointments
-- ══════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS private_appointments (
id bigserial PRIMARY KEY,
instructor_id bigint NOT NULL REFERENCES instructors(id) ON DELETE CASCADE,
title text NOT NULL DEFAULT 'Privat',
date date NOT NULL,
time_from text NOT NULL,
time_to text NOT NULL,
color text DEFAULT '#9ca3af',
created_at timestamptz DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_private_appts_instructor ON private_appointments(instructor_id);
CREATE INDEX IF NOT EXISTS idx_private_appts_date ON private_appointments(date);
-- ══════════════════════════════════════════════════════
-- 11. instructor_tenants
-- ══════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS instructor_tenants (
id bigserial PRIMARY KEY,
instructor_id bigint NOT NULL REFERENCES instructors(id) ON DELETE CASCADE,
tenant_id bigint NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
is_primary integer DEFAULT 0,
created_at timestamptz DEFAULT NOW(),
UNIQUE(instructor_id, tenant_id)
);
CREATE INDEX IF NOT EXISTS idx_instructor_tenants_instructor ON instructor_tenants(instructor_id);
CREATE INDEX IF NOT EXISTS idx_instructor_tenants_tenant ON instructor_tenants(tenant_id);
-- ══════════════════════════════════════════════════════
-- 12. instructor_travel_times
-- ══════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS instructor_travel_times (
id bigserial PRIMARY KEY,
instructor_id bigint NOT NULL REFERENCES instructors(id) ON DELETE CASCADE,
from_tenant_id bigint NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
to_tenant_id bigint NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
minutes integer NOT NULL,
UNIQUE(instructor_id, from_tenant_id, to_tenant_id)
);
CREATE INDEX IF NOT EXISTS idx_travel_times_instructor ON instructor_travel_times(instructor_id);
-- ══════════════════════════════════════════════════════
-- 13. instructor_home_to_tenant
-- ══════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS instructor_home_to_tenant (
id bigserial PRIMARY KEY,
instructor_id bigint NOT NULL REFERENCES instructors(id) ON DELETE CASCADE,
tenant_id bigint NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
minutes integer NOT NULL,
UNIQUE(instructor_id, tenant_id)
);
CREATE INDEX IF NOT EXISTS idx_home_to_tenant_instructor ON instructor_home_to_tenant(instructor_id);
-- ══════════════════════════════════════════════════════
-- 14. instructor_break_rules
-- ══════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS instructor_break_rules (
id bigserial PRIMARY KEY,
instructor_id bigint NOT NULL REFERENCES instructors(id) ON DELETE CASCADE,
condition_type text NOT NULL CHECK (condition_type IN ('daily_units', 'weekly_units', 'hours_daily', 'between_appointments', 'continuous_minutes')),
operator text NOT NULL CHECK (operator IN ('greater_than', 'equals', 'at_least')),
threshold numeric NOT NULL,
break_minutes integer NOT NULL,
is_active integer DEFAULT 1,
created_at timestamptz DEFAULT NOW(),
UNIQUE(instructor_id, condition_type, operator, threshold)
);
CREATE INDEX IF NOT EXISTS idx_break_rules_instructor ON instructor_break_rules(instructor_id);
-- ══════════════════════════════════════════════════════
-- 15. Default Registration-Codes für bestehende Tenants
-- ══════════════════════════════════════════════════════
INSERT INTO tenant_registration_codes (tenant_id, code, is_active)
SELECT id, LPAD(id::text, 8, '0') || '-' || UPPER(SUBSTRING(MD5(id::text || 'salt') FROM 1 FOR 4)), 1
FROM tenants
WHERE id NOT IN (SELECT tenant_id FROM tenant_registration_codes)
ON CONFLICT (code) DO NOTHING;
-- ══════════════════════════════════════════════════════
-- 16. Default Availability für alle Instructors (Mo-So 08:00-19:00)
-- ══════════════════════════════════════════════════════
INSERT INTO instructor_availability (instructor_id, weekday, time_from, time_to, is_active)
SELECT i.id, w.day, '08:00', '19:00', 1
FROM instructors i
CROSS JOIN (VALUES (0),(1),(2),(3),(4),(5),(6)) AS w(day)
WHERE i.is_active = 1
AND NOT EXISTS (
SELECT 1 FROM instructor_availability ia
WHERE ia.instructor_id = i.id AND ia.weekday = w.day
);
COMMIT;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,541 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Arbeitszeitbericht DriveTime Planner</title>
<style>
: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;
--surface-soft: #efe6d7;
--surface-time: #f7f2ea;
--input-bg: rgba(255, 255, 255, .88);
--ghost-bg: rgba(255, 255, 255, .7);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Iowan Old Style', Palatino Linotype, Book Antiqua, serif;
background: var(--bg);
color: var(--ink);
min-height: 100vh;
}
.topbar {
background: var(--panel);
border-bottom: 1px solid var(--line);
padding: 12px 24px;
display: flex;
align-items: center;
gap: 16px;
backdrop-filter: blur(8px);
position: sticky;
top: 0;
z-index: 10;
}
.topbar-brand {
font-size: 1.1rem;
font-weight: bold;
color: var(--accent);
letter-spacing: .02em;
}
.topbar-user {
margin-left: auto;
color: var(--muted);
font-size: .9rem;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 32px 24px;
}
h1 {
font-size: 1.5rem;
margin-bottom: 24px;
color: var(--ink);
}
.filter-bar {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 20px 24px;
margin-bottom: 28px;
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: flex-end;
backdrop-filter: blur(8px);
}
.field { display: flex; flex-direction: column; gap: 6px; }
.field label {
font-size: .8rem;
color: var(--muted);
font-weight: 500;
letter-spacing: .03em;
}
.field select, .field input {
background: var(--input-bg);
border: 1px solid var(--line);
border-radius: 6px;
padding: 8px 12px;
font-size: .95rem;
color: var(--ink);
font-family: inherit;
min-width: 160px;
}
.field select:focus, .field input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(167, 72, 38, .15);
}
button.btn {
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
padding: 9px 20px;
font-size: .95rem;
cursor: pointer;
font-family: inherit;
transition: background .15s;
white-space: nowrap;
}
button.btn:hover { background: var(--accent-deep); }
button.btn-secondary {
background: var(--surface-soft);
color: var(--ink);
border: 1px solid var(--line);
}
button.btn-secondary:hover { background: #e5dcc8; }
.actions { display: flex; gap: 10px; align-items: flex-end; }
/* Table */
.timesheet-table {
width: 100%;
border-collapse: collapse;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
backdrop-filter: blur(8px);
font-size: .9rem;
}
.timesheet-table th {
background: var(--surface-soft);
padding: 10px 14px;
text-align: left;
font-size: .78rem;
letter-spacing: .04em;
color: var(--muted);
text-transform: uppercase;
border-bottom: 1px solid var(--line);
}
.timesheet-table td {
padding: 9px 14px;
border-bottom: 1px solid var(--line);
vertical-align: middle;
}
.timesheet-table tr:last-child td { border-bottom: none; }
.timesheet-table tr.day-total td {
background: var(--surface-soft);
font-weight: 600;
color: var(--teal);
font-size: .85rem;
}
.timesheet-table tr.separator td {
background: var(--ghost-bg);
height: 6px;
padding: 0;
}
.timesheet-table tr.appointment-row:hover td {
background: rgba(167, 72, 38, .04);
}
.ue { font-variant-numeric: tabular-nums; text-align: right; }
.minutes { font-variant-numeric: tabular-nums; text-align: right; }
.time { font-variant-numeric: tabular-nums; font-size: .85rem; color: var(--muted); }
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--muted);
}
.loading {
text-align: center;
padding: 40px;
color: var(--muted);
}
.error-msg {
background: #fef2f2;
border: 1px solid #fecaca;
color: var(--danger);
border-radius: 6px;
padding: 12px 16px;
margin-bottom: 20px;
}
/* Summary section */
.summary-section {
margin-top: 28px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
.summary-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 20px 24px;
backdrop-filter: blur(8px);
}
.summary-card h3 {
font-size: .8rem;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 14px;
}
.summary-card table { width: 100%; border-collapse: collapse; font-size: .9rem; }
.summary-card table th {
text-align: left;
font-size: .78rem;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--muted);
padding: 4px 8px 8px;
border-bottom: 1px solid var(--line);
}
.summary-card table td {
padding: 7px 8px;
border-bottom: 1px solid var(--line);
}
.summary-card table tr:last-child td { border-bottom: none; }
.total-row td { font-weight: 700; color: var(--teal); }
.grand-total td { font-weight: 800; font-size: 1.05rem; color: var(--accent); border-top: 2px solid var(--line); }
/* Print styles */
@media print {
.topbar, .filter-bar, button, .actions { display: none !important; }
body { background: #fff; }
.container { padding: 0; max-width: 100%; }
.timesheet-table { border: 1px solid #000; page-break-inside: avoid; }
.timesheet-table th { background: #eee !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.timesheet-table tr.day-total td { background: #e8f4f8 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.timesheet-table tr.separator td { background: #ddd !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.summary-card { border: 1px solid #000; page-break-inside: avoid; }
.summary-card h3 { color: #000 !important; }
@page { size: A4 portrait; margin: 15mm; }
}
.info-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
font-size: .85rem;
color: var(--muted);
}
.info-bar strong { color: var(--ink); }
</style>
</head>
<body>
<div class="topbar">
<span class="topbar-brand">DriveTime Planner</span>
<span class="topbar-brand"> Arbeitszeitbericht</span>
<span class="topbar-user" id="userName"></span>
</div>
<div class="container">
<h1>Arbeitszeitbericht</h1>
<div class="filter-bar">
<div class="field">
<label for="instructor">Fahrlehrer</label>
<select id="instructor">
<option value=""> bitte wählen </option>
</select>
</div>
<div class="field">
<label for="fromDate">Von</label>
<input type="date" id="fromDate">
</div>
<div class="field">
<label for="toDate">Bis</label>
<input type="date" id="toDate">
</div>
<div class="actions">
<button class="btn" onclick="loadReport()">Anzeigen</button>
<button class="btn btn-secondary" onclick="window.print()">Drucken</button>
</div>
</div>
<div id="errorBox" class="error-msg" style="display:none"></div>
<div id="reportContent" style="display:none">
<div class="info-bar">
<span id="reportInfo"></span>
<span id="reportDate"></span>
</div>
<table class="timesheet-table" id="timesheetTable">
<thead>
<tr>
<th>Datum</th>
<th>Typ</th>
<th>von</th>
<th>bis</th>
<th class="ue">Min</th>
<th class="ue">UE</th>
<th class="ue">TagesGesMin</th>
<th class="ue">TagesGesUE</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
<div class="summary-section" id="summarySection">
<div class="summary-card">
<h3>Zusammenfassung nach Typ</h3>
<table id="typeSummaryTable">
<thead>
<tr>
<th>Typ</th>
<th class="ue">Termine</th>
<th class="ue">Min gesamt</th>
<th class="ue">UE gesamt</th>
</tr>
</thead>
<tbody id="typeSummaryBody"></tbody>
</table>
</div>
<div class="summary-card">
<h3>Gesamtsumme</h3>
<table id="grandTotalTable">
<tbody id="grandTotalBody"></tbody>
</table>
</div>
</div>
</div>
<div id="loading" class="loading" style="display:none">Lade Daten…</div>
<div id="emptyState" class="empty-state" style="display:none">
<p>Keine Termine im gewählten Zeitraum.</p>
</div>
</div>
<script>
const API = 'https://api.fahrschultermin.de';
// ── Auth ────────────────────────────────────────────────────────────────────────
async function ensureAuth() {
const r = await fetch(API + '/api/v1/auth/me', { credentials: 'include' });
if (!r.ok) { window.location.href = '/'; return null; }
return r.json();
}
async function login(email, password) {
const r = await fetch(API + '/api/v1/auth/login', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!r.ok) throw new Error('Login failed');
return r.json();
}
// ── Instructors ────────────────────────────────────────────────────────────────
async function loadInstructors() {
const r = await fetch(API + '/api/v1/instructors', { credentials: 'include' });
if (!r.ok) return [];
const data = await r.json();
return data.data || [];
}
// ── Report ─────────────────────────────────────────────────────────────────────
async function loadReport() {
const instructorId = document.getElementById('instructor').value;
const from = document.getElementById('fromDate').value;
const to = document.getElementById('toDate').value;
const errorBox = document.getElementById('errorBox');
errorBox.style.display = 'none';
if (!instructorId) { showError('Bitte Fahrlehrer auswählen.'); return; }
if (!from || !to) { showError('Bitte Von- und Bis-Datum angeben.'); return; }
if (from > to) { showError('Das Von-Datum muss vor dem Bis-Datum liegen.'); return; }
document.getElementById('loading').style.display = 'block';
document.getElementById('reportContent').style.display = 'none';
document.getElementById('emptyState').style.display = 'none';
try {
const url = `${API}/api/v1/reports/work-timesheet?instructor_id=${encodeURIComponent(instructorId)}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`;
const r = await fetch(url, { credentials: 'include' });
if (!r.ok) {
const err = await r.json();
throw new Error(err.message || 'Fehler beim Laden');
}
const json = await r.json();
renderReport(json.data);
} catch (e) {
showError(e.message);
} finally {
document.getElementById('loading').style.display = 'none';
}
}
function showError(msg) {
const box = document.getElementById('errorBox');
box.textContent = msg;
box.style.display = 'block';
}
function fmtDate(d) {
// d is YYYY-MM-DD
const [y, m, day] = d.split('-');
return `${day}.${m}.${y}`;
}
function fmtMin(n) { return String(n); }
function fmtUe(n) { return String(n).replace('.', ','); }
function renderReport(data) {
document.getElementById('reportContent').style.display = 'block';
document.getElementById('emptyState').style.display = 'none';
const instructor = data.instructor;
document.getElementById('reportInfo').innerHTML =
`<strong>${instructor.first_name} ${instructor.last_name}</strong> · ${fmtDate(data.period.from)} ${fmtDate(data.period.to)}`;
document.getElementById('reportDate').textContent = new Date().toLocaleDateString('de-DE');
const tbody = document.getElementById('tbody');
tbody.innerHTML = '';
if (!data.days || data.days.length === 0) {
document.getElementById('reportContent').style.display = 'none';
document.getElementById('emptyState').style.display = 'block';
return;
}
data.days.forEach((day, di) => {
const isLastDay = di === data.days.length - 1;
day.appointments.forEach((apt, ai) => {
const isLastAptOfDay = ai === day.appointments.length - 1;
const tr = document.createElement('tr');
tr.className = 'appointment-row';
tr.innerHTML = `
<td>${fmtDate(day.date)}</td>
<td>${escHtml(apt.type)}</td>
<td class="time">${apt.start_at}</td>
<td class="time">${apt.end_at}</td>
<td class="minutes">${fmtMin(apt.duration_minutes)}</td>
<td class="ue">${fmtUe(apt.ue)}</td>
<td class="ue">${isLastAptOfDay ? fmtMin(day.day_total_minutes) : ''}</td>
<td class="ue">${isLastAptOfDay ? fmtUe(day.day_total_ue) : ''}</td>
`;
tbody.appendChild(tr);
});
// Day total row
const dayTotalTr = document.createElement('tr');
dayTotalTr.className = 'day-total';
dayTotalTr.innerHTML = `
<td colspan="4">TagesSumme</td>
<td class="ue">${fmtMin(day.day_total_minutes)}</td>
<td class="ue">${fmtUe(day.day_total_ue)}</td>
<td class="ue"></td>
<td class="ue"></td>
`;
tbody.appendChild(dayTotalTr);
// Separator (except after last day)
if (!isLastDay) {
const sep = document.createElement('tr');
sep.className = 'separator';
sep.innerHTML = '<td colspan="8"></td>';
tbody.appendChild(sep);
}
});
// Type summary
const tsBody = document.getElementById('typeSummaryBody');
tsBody.innerHTML = '';
(data.type_summary || []).forEach(row => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${escHtml(row.type)}</td>
<td class="ue">${row.count}</td>
<td class="ue">${fmtMin(row.total_minutes)}</td>
<td class="ue">${fmtUe(row.total_ue)}</td>
`;
tsBody.appendChild(tr);
});
// Grand total
const gtBody = document.getElementById('grandTotalBody');
gtBody.innerHTML = `
<tr class="grand-total">
<td>Gesamt</td>
<td class="ue">${fmtMin(data.summary.total_minutes)} min</td>
<td class="ue">${fmtUe(data.summary.total_ue)}</td>
</tr>
`;
}
function escHtml(s) {
if (s === null || s === undefined) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ── Init ───────────────────────────────────────────────────────────────────────
async function init() {
let user;
try {
user = await ensureAuth();
} catch {
user = null;
}
if (!user || !user.user) {
// Try demo login silently
try {
await login('admin@nordring.test', 'secret123');
user = await ensureAuth();
} catch {
document.body.innerHTML = '<div class="container" style="margin-top:40px;text-align:center;color:var(--muted);">Bitte zuerst in der Hauptanwendung einloggen.</div>';
return;
}
}
document.getElementById('userName').textContent = user.user.first_name + ' ' + user.user.last_name;
// Load instructors
const instructors = await loadInstructors();
const sel = document.getElementById('instructor');
instructors.forEach(i => {
const opt = document.createElement('option');
opt.value = i.id;
opt.textContent = `${i.first_name} ${i.last_name}`;
sel.appendChild(opt);
});
// Default date range: current month
const now = new Date();
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0];
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().split('T')[0];
document.getElementById('fromDate').value = firstDay;
document.getElementById('toDate').value = lastDay;
}
init();
</script>
</body>
</html>

Binary file not shown.

View File

@@ -0,0 +1 @@
user_id|i:6;

View File

@@ -0,0 +1 @@
user_id|i:6;

View File

@@ -0,0 +1 @@
user_id|i:2;

Some files were not shown because too many files have changed in this diff Show More