Browse Source

add new UI

main
Christoph Marzell 1 month ago
parent
commit
8a38ddc580
  1. 7
      .dockerignore
  2. 15
      Dockerfile
  3. 19
      Gemfile
  4. 14
      Gemfile.lock
  5. 29
      MIGRATION_FEATURES.md
  6. 85
      README.md
  7. 25
      app/controllers/admin/application_controller.rb
  8. 46
      app/controllers/admin/entries_controller.rb
  9. 46
      app/controllers/admin/mileage_rates_controller.rb
  10. 46
      app/controllers/admin/training_watch_hits_controller.rb
  11. 46
      app/controllers/admin/training_watch_sources_controller.rb
  12. 46
      app/controllers/admin/users_controller.rb
  13. 15
      app/controllers/api/v1/admin/base_controller.rb
  14. 114
      app/controllers/api/v1/admin/database_controller.rb
  15. 24
      app/controllers/api/v1/admin/entries_controller.rb
  16. 12
      app/controllers/api/v1/admin/training_watch_controller.rb
  17. 17
      app/controllers/api/v1/admin/training_watch_hits_controller.rb
  18. 25
      app/controllers/api/v1/admin/training_watch_sources_controller.rb
  19. 40
      app/controllers/api/v1/admin/users_controller.rb
  20. 88
      app/controllers/api/v1/auth_controller.rb
  21. 33
      app/controllers/api/v1/base_controller.rb
  22. 16
      app/controllers/api/v1/calendar_controller.rb
  23. 52
      app/controllers/api/v1/dashboard_controller.rb
  24. 77
      app/controllers/api/v1/entries_controller.rb
  25. 19
      app/controllers/api/v1/mileage_rates_controller.rb
  26. 12
      app/controllers/api/v1/reports_controller.rb
  27. 19
      app/controllers/api/v1/settings_controller.rb
  28. 14
      app/controllers/application_controller.rb
  29. 81
      app/dashboards/entry_dashboard.rb
  30. 66
      app/dashboards/mileage_rate_dashboard.rb
  31. 78
      app/dashboards/training_watch_hit_dashboard.rb
  32. 87
      app/dashboards/training_watch_source_dashboard.rb
  33. 90
      app/dashboards/user_dashboard.rb
  34. 2
      app/mailers/application_mailer.rb
  35. 2
      app/mailers/dump_mailer.rb
  36. 38
      app/mailers/user_mailer.rb
  37. 2
      app/models/user.rb
  38. 4
      app/views/devise/mailer/confirmation_instructions.html.erb
  39. 6
      app/views/devise/mailer/confirmation_instructions.text.erb
  40. 3
      app/views/devise/mailer/reset_password_instructions.html.erb
  41. 6
      app/views/devise/mailer/reset_password_instructions.text.erb
  42. 2
      bin/docker-entrypoint
  43. 1
      config/application.rb
  44. 24
      config/environments/development.rb
  45. 26
      config/environments/production.rb
  46. 3
      config/initializers/assets.rb
  47. 25
      config/initializers/authenticated_pg_hero.rb
  48. 2
      config/initializers/build_version.rb
  49. 4
      config/initializers/devise.rb
  50. 2
      config/initializers/good_job.rb
  51. 71
      config/routes.rb
  52. 6
      db/migrate/20260815000000_add_api_token_digest_to_users.rb
  53. 6
      db/migrate/20260816000000_ensure_api_token_digest_on_users.rb
  54. 74
      docker-compose.yml
  55. 11
      frontend/Dockerfile
  56. 9
      frontend/angular.json
  57. 20
      frontend/nginx.conf
  58. 6883
      frontend/package-lock.json
  59. 31
      frontend/package.json
  60. 1
      frontend/proxy.conf.json
  61. 239
      frontend/src/app/app.component.ts
  62. 117
      frontend/src/app/app.routes.ts
  63. 206
      frontend/src/app/core/api.service.ts
  64. 72
      frontend/src/app/core/austrian-date-adapter.ts
  65. 2
      frontend/src/app/core/auth.interceptor.ts
  66. 71
      frontend/src/app/core/models.ts
  67. 368
      frontend/src/app/pages/admin-database.component.ts
  68. 84
      frontend/src/app/pages/calculator.component.ts
  69. 238
      frontend/src/app/pages/calendar.component.ts
  70. 3
      frontend/src/app/pages/confirm-email.component.ts
  71. 232
      frontend/src/app/pages/dashboard.component.ts
  72. 331
      frontend/src/app/pages/entries.component.ts
  73. 346
      frontend/src/app/pages/entry-form.component.ts
  74. 3
      frontend/src/app/pages/forgot-password.component.ts
  75. 2
      frontend/src/app/pages/impressum.component.ts
  76. 144
      frontend/src/app/pages/login.component.ts
  77. 95
      frontend/src/app/pages/mileage-rates.component.ts
  78. 9
      frontend/src/app/pages/register.component.ts
  79. 109
      frontend/src/app/pages/reports.component.ts
  80. 3
      frontend/src/app/pages/resend-confirmation.component.ts
  81. 3
      frontend/src/app/pages/reset-password.component.ts
  82. 222
      frontend/src/app/pages/settings.component.ts
  83. 2
      frontend/src/app/shared/material.ts
  84. 1
      frontend/src/index.html
  85. 10
      frontend/src/main.ts
  86. 80
      frontend/src/styles.scss
  87. 1
      frontend/tsconfig.app.json
  88. 24
      frontend/tsconfig.json
  89. 0
      log/.keep
  90. 16
      node_modules/.package-lock.json
  91. 1
      node_modules/controllers/.npmignore
  92. 21
      node_modules/controllers/Cakefile
  93. 127
      node_modules/controllers/README.md
  94. 343
      node_modules/controllers/lib/controllers.js
  95. 16
      node_modules/controllers/package.json
  96. 324
      node_modules/controllers/src/controllers.coffee
  97. 56
      setup-env.ps1
  98. 0
      storage/.keep
  99. 0
      tmp/.keep
  100. 0
      tmp/pids/.keep

7
.dockerignore

@ -35,3 +35,10 @@
/app/assets/builds/* /app/assets/builds/*
!/app/assets/builds/.keep !/app/assets/builds/.keep
/public/assets /public/assets
# Legacy Rails HTML/Administrate code. These directories belonged to the old
# monolithic frontend and must never be eager-loaded by the API image. Keeping
# the exclusions also makes upgrades safe when a ZIP is unpacked over an older
# project directory, because archive extraction does not delete stale files.
/app/controllers/admin/
/app/dashboards/

15
Dockerfile

@ -33,21 +33,21 @@ COPY . .
# Precompile bootsnap code for faster boot times # Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile app/ lib/ RUN bundle exec bootsnap precompile app/ lib/
# Fail the image build early when a removed legacy dependency is still
# referenced by an eagerly loaded Rails constant.
RUN SECRET_KEY_BASE_DUMMY=1 bundle exec rails zeitwerk:check
# Adjust binfiles to be executable on Linux # Adjust binfiles to be executable on Linux
RUN chmod +x bin/* && \ RUN chmod +x bin/* && \
sed -i "s/\r$//g" bin/* && \ sed -i "s/\r$//g" bin/* && \
sed -i 's/ruby\.exe$/ruby/' bin/* sed -i 's/ruby\.exe$/ruby/' bin/*
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Final stage for app image # Final stage for app image
FROM base FROM base
# Install packages needed for deployment # Install packages needed for deployment
RUN apt-get update -qq && \ RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libvips postgresql-client-17 libyaml-dev zip && \
apt-get install --no-install-recommends -y curl libvips postgresql-client-17 libyaml-dev zip unzip && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Copy built artifacts: gems, application # Copy built artifacts: gems, application
@ -56,6 +56,7 @@ COPY --from=build /rails /rails
# Run and own only the runtime files as a non-root user for security # Run and own only the runtime files as a non-root user for security
RUN useradd rails --create-home --shell /bin/bash && \ RUN useradd rails --create-home --shell /bin/bash && \
mkdir -p db log storage tmp/pids tmp/cache tmp/sockets && \
chown -R rails:rails db log storage tmp chown -R rails:rails db log storage tmp
USER rails:rails USER rails:rails
@ -63,8 +64,8 @@ USER rails:rails
ENTRYPOINT ["/rails/bin/docker-entrypoint"] ENTRYPOINT ["/rails/bin/docker-entrypoint"]
HEALTHCHECK --interval=5m --timeout=3s \ HEALTHCHECK --interval=5m --timeout=3s \
CMD curl -f http://localhost:3000 || exit 1
CMD curl -f http://localhost:3000/up || exit 1
# Start the server by default, this can be overwritten at runtime # Start the server by default, this can be overwritten at runtime
#EXPOSE 3000 #EXPOSE 3000
#CMD ["./bin/rails", "server"]
CMD ["./bin/rails", "server", "-b", "0.0.0.0"]

19
Gemfile

@ -6,25 +6,18 @@ source "https://rubygems.org"
gem "rails", "~> 7.1.5", ">= 7.1.5.2" gem "rails", "~> 7.1.5", ">= 7.1.5.2"
# The original asset pipeline for Rails [https://github.com/rails/sprockets-rails] # The original asset pipeline for Rails [https://github.com/rails/sprockets-rails]
gem "sprockets-rails"
# Use postgresql as the database for Active Record # Use postgresql as the database for Active Record
gem "pg", "~> 1.1" gem "pg", "~> 1.1"
# Use the Puma web server [https://github.com/puma/puma] # Use the Puma web server [https://github.com/puma/puma]
gem "puma", ">= 5.0" gem "puma", ">= 5.0"
gem 'turbolinks', '~> 5'
# Build JSON APIs with ease [https://github.com/rails/jbuilder] # Build JSON APIs with ease [https://github.com/rails/jbuilder]
gem "jbuilder"
# CSV is no longer bundled by default in newer Ruby versions. The API uses it
# for the entry export, therefore keep it as an explicit dependency.
gem "csv", "~> 3.3"
gem 'tzinfo-data' gem 'tzinfo-data'
gem 'jquery-rails'
# Use jquery-ui for pretty UI
gem 'jquery-ui-rails'
# Use Sass to process CSS
gem "sassc-rails"
gem "redis" gem "redis"
# Use Redis adapter to run Action Cable in production # Use Redis adapter to run Action Cable in production
# gem "redis", ">= 4.0.1" # gem "redis", ">= 4.0.1"
@ -44,7 +37,6 @@ gem "bootsnap", require: false
# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
# gem "image_processing", "~> 1.2" # gem "image_processing", "~> 1.2"
gem 'administrate'
group :development do group :development do
# Use console on exceptions pages [https://github.com/rails/web-console] # Use console on exceptions pages [https://github.com/rails/web-console]
@ -63,9 +55,4 @@ gem 'pghero'
gem "faraday" gem "faraday"
gem "nokogiri" gem "nokogiri"
gem 'importmap-rails'
gem "good_job", "~> 4.13" gem "good_job", "~> 4.13"
gem "importmap-rails"
gem "turbo-rails"
gem "stimulus-rails"
gem "chartkick"

14
Gemfile.lock

@ -98,6 +98,8 @@ GEM
concurrent-ruby (1.3.5) concurrent-ruby (1.3.5)
connection_pool (2.5.4) connection_pool (2.5.4)
crass (1.0.6) crass (1.0.6)
csv (3.3.2)
stringio
date (3.5.0) date (3.5.0)
devise (4.9.4) devise (4.9.4)
bcrypt (~> 3.0) bcrypt (~> 3.0)
@ -320,16 +322,11 @@ PLATFORMS
x86_64-linux x86_64-linux
DEPENDENCIES DEPENDENCIES
administrate
bootsnap bootsnap
chartkick
csv (~> 3.3)
devise devise
faraday faraday
good_job (~> 4.13) good_job (~> 4.13)
importmap-rails
jbuilder
jquery-rails
jquery-ui-rails
nokogiri nokogiri
pg (~> 1.1) pg (~> 1.1)
pghero pghero
@ -337,11 +334,6 @@ DEPENDENCIES
rails (~> 7.1.5, >= 7.1.5.2) rails (~> 7.1.5, >= 7.1.5.2)
redis redis
rufus-scheduler rufus-scheduler
sassc-rails
sprockets-rails
stimulus-rails
turbo-rails
turbolinks (~> 5)
tzinfo-data tzinfo-data
web-console web-console

29
MIGRATION_FEATURES.md

@ -0,0 +1,29 @@
# Funktionsabgleich mit dem ursprünglichen Rails-Projekt
| Ursprüngliche Funktion | Neue Umsetzung |
|---|---|
| Anmeldung und Abmeldung | Angular Material + Token-API |
| Registrierung und E-Mail-Bestätigung | Angular-Seiten, API und Devise-Mailer |
| Bestätigungslink erneut senden | Angular-Seite und API |
| Passwort vergessen/zurücksetzen | Angular-Seiten, API und E-Mail-Link |
| Profil, E-Mail und Passwort ändern | Einstellungen |
| Eigene Einträge erstellen, bearbeiten und löschen | Eintragsliste und Material-Formular |
| Arbeitszeit aus Beginn/Ende und Mittagspause | Eintragsformular |
| Minuteneinträge summieren | Minutenrechner |
| Laufenden Timer starten/stoppen | Eintragsseite |
| CSV-Export | Eintragsseite und Rails-CSV-API |
| Monatsbericht | Berichte |
| Monats- und Wochenkalender | Kalender-Umschalter |
| Ziel- und Wochenstundenmatrizen | Einstellungen |
| Fortschritt, Restzeit, tatsächliche Wochenleistung und Enddatum | Dashboard |
| Fahrt-, Fortbildungs-, Selbsterfahrungs-, Supervisions- und Gesamtkosten | Dashboard |
| Mediation-Präsenztage | Dashboard |
| Kilometersätze pro Jahr | Kilometersätze |
| Datenbank-Dump herunterladen/einspielen | Administration, ZIP und SQL |
| Benutzer und globale Einträge verwalten | Administration |
| Ausbildungsstellen-Watcher, Quellen und Treffer | Administration + GoodJob |
| Täglicher Backupversand und Watcher-Cron | GoodJob-Konfiguration |
| PgHero | `/admin/pghero`, geschützt mit HTTP Basic Auth |
| Impressum | Öffentliche Angular-Seite |
Administrationsrechte erhält die Adresse aus `ADMIN_EMAIL`. SMTP-, Benachrichtigungs- und PgHero-Zugangsdaten werden ausschließlich über `.env` konfiguriert.

85
README.md

@ -1,24 +1,83 @@
# README
# Ausbildungsnachweis
This README would normally document whatever steps are necessary to get the
application up and running.
Die bisherige Rails-Webanwendung wurde in zwei klar getrennte Anwendungen umgebaut:
Things you may want to cover:
- Rails 7.1 als JSON-API unter `/api/v1`
- Angular 22 mit Angular Material im Ordner `frontend/`
- PostgreSQL, Redis und Nginx über Docker Compose
* Ruby version
## Start mit Docker
* System dependencies
```bash
cp .env.example .env
# CHANGE_ME-Werte in .env durch sichere Zufallswerte ersetzen
docker compose build
docker compose up -d
docker compose exec api bin/rails db:prepare
```
* Configuration
Unter Windows zuerst die `.env` mit sicheren Zufallswerten erzeugen:
* Database creation
```powershell
powershell -ExecutionPolicy Bypass -File .\setup-env.ps1
docker compose up -d --build
```
* Database initialization
Falls Port `13131` bereits verwendet wird, in `.env` beispielsweise Folgendes setzen:
* How to run the test suite
```dotenv
FRONTEND_PORT=13132
FRONTEND_ORIGINS=http://localhost:13132
```
* Services (job queues, cache servers, search engines, etc.)
Frontend: <http://localhost:13131>
API: <http://localhost:13000/api/v1>
Healthcheck: <http://localhost:13000/up>
* Deployment instructions
Ein Secret kann mit `docker compose run --rm api bin/rails secret` erzeugt werden.
* ...
## Lokale Entwicklung
Backend:
```bash
bundle install
bin/rails db:prepare
bin/rails server
```
Frontend:
```bash
cd frontend
npm install
npm start
```
Der Angular-Devserver leitet `/api` über `proxy.conf.json` an Rails auf Port 3000 weiter.
## API-Bereiche
- `auth`: Login, Logout und aktueller Benutzer
- `entries`: CRUD, Timer und CSV-Export
- `dashboard`: Fortschritt, Kilometer und Kosten
- `calendar`: Einträge für frei wählbare Datumsbereiche
- `monthly_report`: Monatsauswertung
- `settings`: Sollstunden, Wochenziele und Ausbildungsstatus
- `mileage_rates`: Kilometersätze
`csv` ist im `Gemfile` ausdrücklich als Gem eingetragen und zusätzlich im Export-Controller sowie im Modell geladen.
Der vollständige Abgleich mit den Funktionen des ursprünglichen Rails-Projekts steht in `MIGRATION_FEATURES.md`.
## E-Mail und Administration
In `.env` müssen insbesondere `SMTP_USERNAME`, `SMTP_PASSWORD`, `FRONTEND_URL`, `ADMIN_EMAIL` und die PgHero-Zugangsdaten gesetzt werden. Nach Änderungen an `.env` die Container neu erstellen:
```bash
docker compose up -d --build --force-recreate
```
## Bestehende Daten
Die vorhandenen Tabellen und Modelle bleiben erhalten. Beim ersten Start wird nur `api_token_digest` an `users` ergänzt. Vor dem Upgrade sollte wie üblich ein PostgreSQL-Backup erstellt werden.

25
app/controllers/admin/application_controller.rb

@ -1,25 +0,0 @@
# All Administrate controllers inherit from this
# `Administrate::ApplicationController`, making it the ideal place to put
# authentication logic or other before_actions.
#
# If you want to add pagination or other controller-level concerns,
# you're free to overwrite the RESTful controller actions.
module Admin
class ApplicationController < Administrate::ApplicationController
before_action :authenticate_admin
def authenticate_admin
redirect_to root_path, alert: "Kein Zugriff!" unless current_user.email =="christoph@marzell.net"
end
def is_admin?
current_user&.email =="christoph@marzell.net"
end
# Override this value to specify the number of elements to display at a time
# on index pages. Defaults to 20.
# def records_per_page
# params[:per_page] || 20
# end
end
end

46
app/controllers/admin/entries_controller.rb

@ -1,46 +0,0 @@
module Admin
class EntriesController < Admin::ApplicationController
# Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated.
#
# def update
# super
# send_foo_updated_email(requested_resource)
# end
# Override this method to specify custom lookup behavior.
# This will be used to set the resource for the `show`, `edit`, and `update`
# actions.
#
# def find_resource(param)
# Foo.find_by!(slug: param)
# end
# The result of this lookup will be available as `requested_resource`
# Override this if you have certain roles that require a subset
# this will be used to set the records shown on the `index` action.
#
# def scoped_resource
# if current_user.super_admin?
# resource_class
# else
# resource_class.with_less_stuff
# end
# end
# Override `resource_params` if you want to transform the submitted
# data before it's persisted. For example, the following would turn all
# empty values into nil values. It uses other APIs such as `resource_class`
# and `dashboard`:
#
# def resource_params
# params.require(resource_class.model_name.param_key).
# permit(dashboard.permitted_attributes(action_name)).
# transform_values { |value| value == "" ? nil : value }
# end
# See https://administrate-demo.herokuapp.com/customizing_controller_actions
# for more information
end
end

46
app/controllers/admin/mileage_rates_controller.rb

@ -1,46 +0,0 @@
module Admin
class MileageRatesController < Admin::ApplicationController
# Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated.
#
# def update
# super
# send_foo_updated_email(requested_resource)
# end
# Override this method to specify custom lookup behavior.
# This will be used to set the resource for the `show`, `edit`, and `update`
# actions.
#
# def find_resource(param)
# Foo.find_by!(slug: param)
# end
# The result of this lookup will be available as `requested_resource`
# Override this if you have certain roles that require a subset
# this will be used to set the records shown on the `index` action.
#
# def scoped_resource
# if current_user.super_admin?
# resource_class
# else
# resource_class.with_less_stuff
# end
# end
# Override `resource_params` if you want to transform the submitted
# data before it's persisted. For example, the following would turn all
# empty values into nil values. It uses other APIs such as `resource_class`
# and `dashboard`:
#
# def resource_params
# params.require(resource_class.model_name.param_key).
# permit(dashboard.permitted_attributes(action_name)).
# transform_values { |value| value == "" ? nil : value }
# end
# See https://administrate-demo.herokuapp.com/customizing_controller_actions
# for more information
end
end

46
app/controllers/admin/training_watch_hits_controller.rb

@ -1,46 +0,0 @@
module Admin
class TrainingWatchHitsController < Admin::ApplicationController
# Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated.
#
# def update
# super
# send_foo_updated_email(requested_resource)
# end
# Override this method to specify custom lookup behavior.
# This will be used to set the resource for the `show`, `edit`, and `update`
# actions.
#
# def find_resource(param)
# Foo.find_by!(slug: param)
# end
# The result of this lookup will be available as `requested_resource`
# Override this if you have certain roles that require a subset
# this will be used to set the records shown on the `index` action.
#
# def scoped_resource
# if current_user.super_admin?
# resource_class
# else
# resource_class.with_less_stuff
# end
# end
# Override `resource_params` if you want to transform the submitted
# data before it's persisted. For example, the following would turn all
# empty values into nil values. It uses other APIs such as `resource_class`
# and `dashboard`:
#
# def resource_params
# params.require(resource_class.model_name.param_key).
# permit(dashboard.permitted_attributes(action_name)).
# transform_values { |value| value == "" ? nil : value }
# end
# See https://administrate-demo.herokuapp.com/customizing_controller_actions
# for more information
end
end

46
app/controllers/admin/training_watch_sources_controller.rb

@ -1,46 +0,0 @@
module Admin
class TrainingWatchSourcesController < Admin::ApplicationController
# Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated.
#
# def update
# super
# send_foo_updated_email(requested_resource)
# end
# Override this method to specify custom lookup behavior.
# This will be used to set the resource for the `show`, `edit`, and `update`
# actions.
#
# def find_resource(param)
# Foo.find_by!(slug: param)
# end
# The result of this lookup will be available as `requested_resource`
# Override this if you have certain roles that require a subset
# this will be used to set the records shown on the `index` action.
#
# def scoped_resource
# if current_user.super_admin?
# resource_class
# else
# resource_class.with_less_stuff
# end
# end
# Override `resource_params` if you want to transform the submitted
# data before it's persisted. For example, the following would turn all
# empty values into nil values. It uses other APIs such as `resource_class`
# and `dashboard`:
#
# def resource_params
# params.require(resource_class.model_name.param_key).
# permit(dashboard.permitted_attributes(action_name)).
# transform_values { |value| value == "" ? nil : value }
# end
# See https://administrate-demo.herokuapp.com/customizing_controller_actions
# for more information
end
end

46
app/controllers/admin/users_controller.rb

@ -1,46 +0,0 @@
module Admin
class UsersController < Admin::ApplicationController
# Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated.
#
# def update
# super
# send_foo_updated_email(requested_resource)
# end
# Override this method to specify custom lookup behavior.
# This will be used to set the resource for the `show`, `edit`, and `update`
# actions.
#
# def find_resource(param)
# Foo.find_by!(slug: param)
# end
# The result of this lookup will be available as `requested_resource`
# Override this if you have certain roles that require a subset
# this will be used to set the records shown on the `index` action.
#
# def scoped_resource
# if current_user.super_admin?
# resource_class
# else
# resource_class.with_less_stuff
# end
# end
# Override `resource_params` if you want to transform the submitted
# data before it's persisted. For example, the following would turn all
# empty values into nil values. It uses other APIs such as `resource_class`
# and `dashboard`:
#
# def resource_params
# params.require(resource_class.model_name.param_key).
# permit(dashboard.permitted_attributes(action_name)).
# transform_values { |value| value == "" ? nil : value }
# end
# See https://administrate-demo.herokuapp.com/customizing_controller_actions
# for more information
end
end

15
app/controllers/api/v1/admin/base_controller.rb

@ -0,0 +1,15 @@
module Api
module V1
module Admin
class BaseController < Api::V1::BaseController
before_action :require_admin!
private
def require_admin!
render json: { error: "Keine Administratorberechtigung" }, status: :forbidden unless current_user.is_admin?
end
end
end
end
end

114
app/controllers/api/v1/admin/database_controller.rb

@ -0,0 +1,114 @@
require "open3"
module Api
module V1
module Admin
class DatabaseController < BaseController
MAX_UPLOAD_SIZE = 250.megabytes
def backup
Dir.mktmpdir("praktikum-backup") do |directory|
sql_path = File.join(directory, "praktikum-#{Time.current.strftime('%Y%m%d-%H%M%S')}.sql")
run_database_command!(pg_dump_command(sql_path))
zip_path = "#{sql_path}.zip"
stdout, stderr, status = Open3.capture3("zip", "-j", zip_path, sql_path)
raise "ZIP-Erstellung fehlgeschlagen: #{stderr.presence || stdout}" unless status.success?
send_data File.binread(zip_path), filename: File.basename(zip_path), type: "application/zip"
end
rescue StandardError => error
Rails.logger.error("Backup failed: #{error.class}: #{error.message}")
render json: { error: "Datenbanksicherung konnte nicht erstellt werden" }, status: :internal_server_error
end
def restore
upload = params[:file]
return render json: { error: "Keine Datei ausgewählt" }, status: :bad_request unless upload
return render json: { error: "Datei ist größer als 250 MB" }, status: :payload_too_large if upload.size.to_i > MAX_UPLOAD_SIZE
Dir.mktmpdir("praktikum-restore") do |directory|
uploaded_path = File.join(directory, File.basename(upload.original_filename.to_s))
File.binwrite(uploaded_path, upload.read)
sql_path = extract_sql(uploaded_path, directory)
restore_path = build_atomic_restore_file(sql_path, directory)
Rails.logger.info("Atomic database restore v2026.08.16.8 using #{File.basename(restore_path)}")
run_database_command!(psql_command(restore_path))
# The restored schema invalidates prepared statements and the schema
# cache of already open Rails connections. Reconnect cleanly before
# the next request uses the restored database.
ActiveRecord::Base.connection_handler.clear_all_connections!
run_pending_migrations!
ActiveRecord::Base.connection_handler.clear_all_connections!
end
render json: { message: "Datenbank wurde erfolgreich wiederhergestellt" }
rescue StandardError => error
Rails.logger.error("Restore failed: #{error.class}: #{error.message}")
render json: { error: "Wiederherstellung fehlgeschlagen: #{error.message}" }, status: :unprocessable_content
end
private
def extract_sql(uploaded_path, directory)
return uploaded_path if File.extname(uploaded_path).downcase == ".sql"
raise "Erlaubt sind nur .zip- oder .sql-Dateien" unless File.extname(uploaded_path).downcase == ".zip"
list, stderr, status = Open3.capture3("unzip", "-Z1", uploaded_path)
raise "ZIP-Datei ist ungültig: #{stderr}" unless status.success?
entries = list.lines.map(&:strip).reject(&:blank?)
sql_entries = entries.select { |entry| File.extname(entry).downcase == ".sql" && !entry.include?("..") && !entry.start_with?("/") }
raise "ZIP muss genau eine SQL-Datei enthalten" unless sql_entries.one?
sql_path = File.join(directory, "restore.sql")
File.binwrite(sql_path, Open3.capture3("unzip", "-p", uploaded_path, sql_entries.first).then { |out, err, result| raise("SQL konnte nicht entpackt werden: #{err}") unless result.success?; out })
sql_path
end
def database_config = ActiveRecord::Base.connection_db_config.configuration_hash
# Use one physical SQL input so resetting the existing schema always
# happens before the first CREATE TABLE from legacy plain-text dumps.
# psql --single-transaction rolls the reset back as well when any
# statement from the uploaded dump fails.
def build_atomic_restore_file(sql_path, directory)
restore_path = File.join(directory, "atomic_restore.sql")
File.open(restore_path, "wb") do |restore|
restore.write("DROP SCHEMA IF EXISTS public CASCADE;\n")
restore.write("CREATE SCHEMA public;\n")
File.open(sql_path, "rb") { |source| IO.copy_stream(source, restore) }
end
restore_path
end
def pg_dump_command(path)
config = database_config
["pg_dump", "-U", config[:username].to_s, "-h", (config[:host] || "localhost").to_s, "-p", (config[:port] || 5432).to_s, "-d", config[:database].to_s, "--clean", "--if-exists", "--no-owner", "--no-privileges", "-f", path]
end
def psql_command(path)
config = database_config
[
"psql",
"-v", "ON_ERROR_STOP=1",
"--single-transaction",
"-U", config[:username].to_s,
"-h", (config[:host] || "localhost").to_s,
"-p", (config[:port] || 5432).to_s,
"-d", config[:database].to_s,
"-f", path
]
end
def run_database_command!(command)
_stdout, stderr, status = Open3.capture3({ "PGPASSWORD" => database_config[:password].to_s }, *command)
raise stderr.presence || "Datenbankbefehl fehlgeschlagen" unless status.success?
end
def run_pending_migrations!
stdout, stderr, status = Open3.capture3(
"bundle", "exec", "rails", "db:migrate",
chdir: Rails.root.to_s
)
Rails.logger.info("Post-restore migrations completed: #{stdout.lines.last.to_s.strip}") if status.success?
raise "Migrationen nach dem Restore fehlgeschlagen: #{stderr.presence || stdout}" unless status.success?
end
end
end
end
end

24
app/controllers/api/v1/admin/entries_controller.rb

@ -0,0 +1,24 @@
module Api
module V1
module Admin
class EntriesController < BaseController
before_action :set_entry, only: %i[show update destroy]
def index
entries = Entry.includes(:user).order(date: :desc).limit(2_000)
render json: entries.map { |entry| entry.as_json.merge(user_email: entry.user.email) }
end
def show = render json: @entry.as_json.merge(user_email: @entry.user.email)
def update
@entry.update(entry_params) ? render(json: @entry) : render_validation(@entry)
end
def destroy
@entry.destroy!
head :no_content
end
private
def set_entry = @entry = Entry.find(params[:id])
def entry_params = params.require(:entry).permit(:date, :hours, :minutes, :praktikums_typ, :entry_art, :distance_km, :beschreibung, :kosten, :start_time, :end_time, :lunch_break_minutes, :zaehlt_als_fortbildung)
end
end
end
end

12
app/controllers/api/v1/admin/training_watch_controller.rb

@ -0,0 +1,12 @@
module Api
module V1
module Admin
class TrainingWatchController < BaseController
def run
results = TrainingWatch::Checker.new.run!
render json: { message: "Prüfung abgeschlossen", new_hits: results.size, results: results }
end
end
end
end
end

17
app/controllers/api/v1/admin/training_watch_hits_controller.rb

@ -0,0 +1,17 @@
module Api
module V1
module Admin
class TrainingWatchHitsController < BaseController
before_action :set_hit, only: %i[show destroy]
def index = render json: TrainingWatchHit.includes(:training_watch_source).order(created_at: :desc).limit(1_000).map { |hit| hit.as_json.merge(source_name: hit.training_watch_source.name) }
def show = render json: @hit.as_json.merge(source_name: @hit.training_watch_source.name)
def destroy
@hit.destroy!
head :no_content
end
private
def set_hit = @hit = TrainingWatchHit.find(params[:id])
end
end
end
end

25
app/controllers/api/v1/admin/training_watch_sources_controller.rb

@ -0,0 +1,25 @@
module Api
module V1
module Admin
class TrainingWatchSourcesController < BaseController
before_action :set_source, only: %i[show update destroy]
def index = render json: TrainingWatchSource.order(:name)
def show = render json: @source
def create
source = TrainingWatchSource.new(source_params)
source.save ? render(json: source, status: :created) : render_validation(source)
end
def update
@source.update(source_params) ? render(json: @source) : render_validation(@source)
end
def destroy
@source.destroy!
head :no_content
end
private
def set_source = @source = TrainingWatchSource.find(params[:id])
def source_params = params.require(:training_watch_source).permit(:name, :url, :kind, :match_regex, :enabled)
end
end
end
end

40
app/controllers/api/v1/admin/users_controller.rb

@ -0,0 +1,40 @@
module Api
module V1
module Admin
class UsersController < BaseController
before_action :set_user, only: %i[show update destroy]
def index = render json: User.order(:email).map { |user| payload(user) }
def show = render json: payload(@user)
def create
user = User.new(email: params.dig(:user, :email), password: params.dig(:user, :password), password_confirmation: params.dig(:user, :password))
user.skip_confirmation! if ActiveModel::Type::Boolean.new.cast(params.dig(:user, :confirmed))
user.save ? render(json: payload(user), status: :created) : render_validation(user)
end
def update
attributes = params.require(:user).permit(:email, :praepedeutikum_done, :confirmed, :password)
password = attributes.delete(:password)
confirmed = attributes.delete(:confirmed)
@user.assign_attributes(attributes)
@user.password = password if password.present?
@user.password_confirmation = password if password.present?
unless confirmed.nil?
if confirmed
@user.confirm
else
@user.confirmed_at = nil
end
end
@user.save ? render(json: payload(@user)) : render_validation(@user)
end
def destroy
return render json: { error: "Das eigene Administratorkonto kann nicht gelöscht werden" }, status: :unprocessable_entity if @user == current_user
@user.destroy!
head :no_content
end
private
def set_user = @user = User.find(params[:id])
def payload(user) = user.as_json(only: %i[id email confirmed_at praepedeutikum_done created_at updated_at]).merge(is_admin: user.is_admin?, entries_count: user.entries.count)
end
end
end
end

88
app/controllers/api/v1/auth_controller.rb

@ -0,0 +1,88 @@
module Api
module V1
class AuthController < BaseController
skip_before_action :authenticate_api_user!, only: %i[login register confirm resend_confirmation forgot_password reset_password]
def login
user = User.find_for_database_authentication(email: params[:email].to_s.downcase.strip)
return render json: { error: "E-Mail oder Passwort ist falsch" }, status: :unauthorized unless user&.valid_password?(params[:password])
return render json: { error: "E-Mail-Adresse wurde noch nicht bestätigt" }, status: :forbidden unless user.confirmed?
token = SecureRandom.hex(32)
user.update!(api_token_digest: Digest::SHA256.hexdigest(token))
render json: { token:, user: user_payload(user) }
end
def register
user = User.new(email: params[:email], password: params[:password], password_confirmation: params[:password_confirmation])
if user.save
render json: { message: "Registrierung erfolgreich. Bitte E-Mail bestätigen." }, status: :created
else
render_validation(user)
end
end
def confirm
user = User.confirm_by_token(params[:token].to_s)
if user.errors.empty?
render json: { message: "E-Mail-Adresse wurde bestätigt. Du kannst dich jetzt anmelden." }
else
render json: { errors: user.errors.full_messages }, status: :unprocessable_entity
end
end
def resend_confirmation
user = User.find_by(email: params[:email].to_s.downcase.strip)
user&.send_confirmation_instructions unless user&.confirmed?
render json: { message: "Falls ein unbestätigtes Konto existiert, wurde ein neuer Bestätigungslink versendet." }
end
def forgot_password
user = User.find_by(email: params[:email].to_s.downcase.strip)
user&.send_reset_password_instructions
render json: { message: "Falls ein Konto existiert, wurde eine E-Mail zum Zurücksetzen versendet." }
end
def reset_password
user = User.reset_password_by_token(
reset_password_token: params[:token],
password: params[:password],
password_confirmation: params[:password_confirmation]
)
if user.errors.empty?
user.update!(api_token_digest: nil)
render json: { message: "Passwort wurde geändert. Du kannst dich jetzt anmelden." }
else
render json: { errors: user.errors.full_messages }, status: :unprocessable_entity
end
end
def change_password
unless current_user.valid_password?(params[:current_password])
return render json: { error: "Das aktuelle Passwort ist falsch" }, status: :unprocessable_entity
end
if current_user.update(password: params[:password], password_confirmation: params[:password_confirmation])
current_user.update!(api_token_digest: nil)
render json: { message: "Passwort wurde geändert. Bitte melde dich erneut an." }
else
render_validation(current_user)
end
end
def logout
current_user.update!(api_token_digest: nil)
head :no_content
end
def me
render json: user_payload(current_user)
end
private
def user_payload(user)
{ id: user.id, email: user.email, praepedeutikum_done: user.praepedeutikum_done, is_admin: user.is_admin? }
end
end
end
end

33
app/controllers/api/v1/base_controller.rb

@ -0,0 +1,33 @@
module Api
module V1
class BaseController < ApplicationController
before_action :authenticate_api_user!
after_action :expose_build_version
rescue_from ActiveRecord::RecordNotFound, with: :not_found
private
attr_reader :current_user
def authenticate_api_user!
token = request.authorization.to_s.delete_prefix("Bearer ").strip
digest = Digest::SHA256.hexdigest(token) if token.present?
@current_user = User.find_by(api_token_digest: digest)
render json: { error: "Nicht angemeldet" }, status: :unauthorized unless @current_user
end
def not_found
render json: { error: "Nicht gefunden" }, status: :not_found
end
def render_validation(record)
render json: { errors: record.errors.full_messages }, status: :unprocessable_content
end
def expose_build_version
response.set_header("X-Praktikum-Build", Rails.application.config.x.build_version)
end
end
end
end

16
app/controllers/api/v1/calendar_controller.rb

@ -0,0 +1,16 @@
module Api
module V1
class CalendarController < BaseController
def index
from = Date.iso8601(params.fetch(:from, Date.current.beginning_of_month.to_s))
to = Date.iso8601(params.fetch(:to, Date.current.end_of_month.to_s))
scope = current_user.entries.where(date: from..to).order(:date, :start_time)
scope = scope.where(praktikums_typ: params[:typ]) if params[:typ].present?
scope = scope.where(entry_art: params[:art]) if params[:art].present?
render json: scope.as_json(only: %i[id date hours minutes praktikums_typ entry_art beschreibung start_time end_time])
rescue Date::Error
render json: { error: "Ungültiger Datumsbereich" }, status: :bad_request
end
end
end
end

52
app/controllers/api/v1/dashboard_controller.rb

@ -0,0 +1,52 @@
module Api
module V1
class DashboardController < BaseController
def show
current_user.update_required_matrices!
entries = current_user.entries.where("date <= ?", Date.current)
progress = User::PRAKTIKUMSTYPEN.flat_map do |typ|
User.entry_arten_for(typ).map do |art|
spent = entries.where(praktikums_typ: typ, entry_art: art).sum("COALESCE(hours, 0) * 60 + COALESCE(minutes, 0)").to_i
target = current_user.required_hours_for(typ, art).to_f * 60
remaining = [target - spent, 0].max.to_i
weekly_target = current_user.weekly_target_for(typ, art).to_f
dates = entries.where(praktikums_typ: typ, entry_art: art).where("COALESCE(hours, 0) > 0 OR COALESCE(minutes, 0) > 0")
first_date = dates.minimum(:date)
last_date = dates.maximum(:date)
period_end = spent >= target && last_date ? last_date : Date.current
weeks = first_date ? [[(period_end - first_date).to_i + 1, 7].max / 7.0, 1].max : nil
actual_weekly = weeks ? ((spent / 60.0) / weeks).round(2) : nil
estimated_end = weekly_target.positive? && remaining.positive? ? Date.current + (remaining / 60.0 / weekly_target).ceil.weeks : nil
{ typ:, art:, spent_minutes: spent, target_minutes: target.to_i, remaining_minutes: remaining,
percent: target.positive? ? [(spent / target * 100).round(1), 100].min : 0,
weekly_target:, actual_weekly:, estimated_end: }
end
end
total_spent = progress.sum { |row| row[:spent_minutes] }
total_target = progress.sum { |row| row[:target_minutes] }
render json: { total_minutes: total_spent, target_minutes: total_target, remaining_minutes: [total_target - total_spent, 0].max,
completed_percent: total_target.positive? ? (total_spent.to_f / total_target * 100).round(1) : 0,
total_distance_km: current_user.entries.sum(:distance_km), costs_by_year: costs_by_year,
running_entry: current_user.entries.find_by(end_time: nil, beschreibung: "Timer"),
mediation_presence_days: current_user.mediation_praesenzmodule_completed,
mediation_presence_days_required: current_user.mediation_praesenzmodule_required,
last_entry: current_user.entries.where("date <= ?", Date.current).order(date: :desc).first, progress: }
end
private
def costs_by_year
years = current_user.entries.pluck(Arel.sql("DISTINCT EXTRACT(YEAR FROM date)::int")).compact.sort.reverse
km = Entry.total_kilometer_cost_by_year(current_user)
total = Entry.total_gesamtkosten_by_year(current_user)
training = Entry.total_fortbildungskosten_by_year(current_user)
experience = Entry.total_selbsterfahrungskosten_by_year(current_user)
supervision = Entry.total_supervision_by_year(current_user)
semester = Entry.total_semesterkosten_by_year(current_user)
years.map { |year| { year:, kilometer: km[year].to_f, fortbildung: training[year].to_f,
selbsterfahrung: experience[year].to_f, supervision: supervision[year].to_f,
semester: semester[year].to_f, total: total[year].to_f } }
end
end
end
end

77
app/controllers/api/v1/entries_controller.rb

@ -0,0 +1,77 @@
require "csv"
module Api
module V1
class EntriesController < BaseController
before_action :set_entry, only: %i[show update destroy stop_timer]
def index
scope = current_user.entries.order(date: :desc, created_at: :desc)
scope = scope.where(praktikums_typ: params[:typ]) if params[:typ].present?
scope = scope.where(entry_art: params[:art]) if params[:art].present?
scope = scope.where("beschreibung ILIKE ?", "%#{ActiveRecord::Base.sanitize_sql_like(params[:search])}%") if params[:search].present?
render json: scope.map { |entry| payload(entry) }
end
def show
render json: payload(@entry)
end
def create
entry = current_user.entries.new(entry_params)
return render json: { error: "Das Propädeutikum ist bereits abgeschlossen" }, status: :unprocessable_entity if blocked?(entry)
entry.save ? render(json: payload(entry), status: :created) : render_validation(entry)
end
def update
return render json: { error: "Das Propädeutikum ist bereits abgeschlossen" }, status: :unprocessable_entity if params.dig(:entry, :praktikums_typ) == "propädeutikum" && current_user.praepedeutikum_abgeschlossen?
@entry.update(entry_params) ? render(json: payload(@entry)) : render_validation(@entry)
end
def destroy
@entry.destroy!
head :no_content
end
def start_timer
return render json: { error: "Es läuft bereits ein Timer" }, status: :unprocessable_entity if current_user.entries.exists?(end_time: nil, beschreibung: "Timer")
entry = current_user.entries.new(date: Date.current, start_time: Time.current, beschreibung: "Timer", praktikums_typ: params[:typ], entry_art: params[:art])
return render json: { error: "Das Propädeutikum ist bereits abgeschlossen" }, status: :unprocessable_entity if blocked?(entry)
entry.save ? render(json: payload(entry), status: :created) : render_validation(entry)
end
def stop_timer
return render json: { error: "Dieser Eintrag besitzt keine Startzeit" }, status: :unprocessable_entity unless @entry.start_time
@entry.end_time = Time.current
@entry.lunch_break_minutes = ActiveModel::Type::Boolean.new.cast(params[:lunch_break]) ? 30 : 0
minutes = @entry.total_minutes_including_break.to_i
@entry.hours, @entry.minutes = minutes.divmod(60)
@entry.save!
render json: payload(@entry)
end
def export_csv
data = current_user.entries.order(date: :desc).to_csv
send_data data, filename: "eintraege-#{Date.current}.csv", type: "text/csv; charset=utf-8"
end
private
def set_entry
@entry = current_user.entries.find(params[:id])
end
def entry_params
params.require(:entry).permit(:date, :hours, :minutes, :praktikums_typ, :entry_art, :distance_km, :beschreibung, :kosten, :start_time, :end_time, :lunch_break_minutes, :zaehlt_als_fortbildung)
end
def blocked?(entry)
current_user.praepedeutikum_abgeschlossen? && entry.praktikums_typ == "propädeutikum"
end
def payload(entry)
entry.as_json(only: %i[id date hours minutes praktikums_typ entry_art distance_km beschreibung kosten start_time end_time lunch_break_minutes zaehlt_als_fortbildung created_at updated_at]).merge(kilometer_pauschale: entry.kilometer_pauschale)
end
end
end
end

19
app/controllers/api/v1/mileage_rates_controller.rb

@ -0,0 +1,19 @@
module Api
module V1
class MileageRatesController < BaseController
before_action :set_rate, only: %i[show update]
def index = render json: MileageRate.order(year: :desc)
def show = render json: @rate
def create
rate = MileageRate.new(rate_params)
rate.save ? render(json: rate, status: :created) : render_validation(rate)
end
def update
@rate.update(rate_params) ? render(json: @rate) : render_validation(@rate)
end
private
def set_rate = @rate = MileageRate.find(params[:id])
def rate_params = params.require(:mileage_rate).permit(:year, :rate_per_km)
end
end
end

12
app/controllers/api/v1/reports_controller.rb

@ -0,0 +1,12 @@
module Api
module V1
class ReportsController < BaseController
def monthly
rows = current_user.entries.group(Arel.sql("DATE_TRUNC('month', date)"), :praktikums_typ, :entry_art)
.order(Arel.sql("DATE_TRUNC('month', date) DESC"))
.pluck(Arel.sql("DATE_TRUNC('month', date)"), :praktikums_typ, :entry_art, Arel.sql("SUM(COALESCE(hours, 0) * 60 + COALESCE(minutes, 0))"))
render json: rows.map { |month, typ, art, minutes| { month: month.to_date, typ:, art:, total_minutes: minutes.to_i } }
end
end
end
end

19
app/controllers/api/v1/settings_controller.rb

@ -0,0 +1,19 @@
module Api
module V1
class SettingsController < BaseController
def show
current_user.update_required_matrices!
render json: payload
end
def update
attributes = params.require(:settings).permit(:email, :total_required_hours, :weekly_target_hours, :praepedeutikum_done, required_hours_matrix: {}, weekly_target_matrix: {})
current_user.update(attributes) ? render(json: payload) : render_validation(current_user)
end
private
def payload
current_user.as_json(only: %i[email total_required_hours weekly_target_hours required_hours_matrix weekly_target_matrix praepedeutikum_done]).merge(
praktikums_typen: User::PRAKTIKUMSTYPEN, entry_arten: User::ENTRY_ARTEN, entry_arten_by_typ: User::ENTRY_ARTEN_BY_TYP)
end
end
end
end

14
app/controllers/application_controller.rb

@ -1,14 +1,2 @@
class ApplicationController < ActionController::Base
before_action :configure_permitted_parameters, if: :devise_controller?
def authenticate_admin
redirect_to root_path, alert: "Kein Zugriff!" unless current_user.email =="christoph@marzell.net"
end
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:account_update, keys: [:total_required_hours, :weekly_target_hours, weekly_target_matrix: {}, required_hours_matrix: {}])
end
def is_admin?
current_user&.email =="christoph@marzell.net"
end
class ApplicationController < ActionController::API
end end

81
app/dashboards/entry_dashboard.rb

@ -1,81 +0,0 @@
require "administrate/base_dashboard"
class EntryDashboard < Administrate::BaseDashboard
# ATTRIBUTE_TYPES
# a hash that describes the type of each of the model's fields.
#
# Each different type represents an Administrate::Field object,
# which determines how the attribute is displayed
# on pages throughout the dashboard.
ATTRIBUTE_TYPES = {
id: Field::Number,
date: Field::Date,
distance_km: Field::Number,
entry_art: Field::String,
hours: Field::Number,
minutes: Field::Number,
praktikums_typ: Field::String,
user: Field::BelongsTo,
created_at: Field::DateTime,
updated_at: Field::DateTime,
}.freeze
# COLLECTION_ATTRIBUTES
# an array of attributes that will be displayed on the model's index page.
#
# By default, it's limited to four items to reduce clutter on index pages.
# Feel free to add, remove, or rearrange items.
COLLECTION_ATTRIBUTES = %i[
id
date
distance_km
entry_art
].freeze
# SHOW_PAGE_ATTRIBUTES
# an array of attributes that will be displayed on the model's show page.
SHOW_PAGE_ATTRIBUTES = %i[
id
date
distance_km
entry_art
hours
minutes
praktikums_typ
user
created_at
updated_at
].freeze
# FORM_ATTRIBUTES
# an array of attributes that will be displayed
# on the model's form (`new` and `edit`) pages.
FORM_ATTRIBUTES = %i[
date
distance_km
entry_art
hours
minutes
praktikums_typ
user
].freeze
# COLLECTION_FILTERS
# a hash that defines filters that can be used while searching via the search
# field of the dashboard.
#
# For example to add an option to search for open resources by typing "open:"
# in the search field:
#
# COLLECTION_FILTERS = {
# open: ->(resources) { resources.where(open: true) }
# }.freeze
COLLECTION_FILTERS = {}.freeze
# Overwrite this method to customize how entries are displayed
# across all pages of the admin dashboard.
#
# def display_resource(entry)
# "Entry ##{entry.id}"
# end
end

66
app/dashboards/mileage_rate_dashboard.rb

@ -1,66 +0,0 @@
require "administrate/base_dashboard"
class MileageRateDashboard < Administrate::BaseDashboard
# ATTRIBUTE_TYPES
# a hash that describes the type of each of the model's fields.
#
# Each different type represents an Administrate::Field object,
# which determines how the attribute is displayed
# on pages throughout the dashboard.
ATTRIBUTE_TYPES = {
id: Field::Number,
rate_per_km: Field::String.with_options(searchable: false),
year: Field::Number,
created_at: Field::DateTime,
updated_at: Field::DateTime,
}.freeze
# COLLECTION_ATTRIBUTES
# an array of attributes that will be displayed on the model's index page.
#
# By default, it's limited to four items to reduce clutter on index pages.
# Feel free to add, remove, or rearrange items.
COLLECTION_ATTRIBUTES = %i[
id
rate_per_km
year
created_at
].freeze
# SHOW_PAGE_ATTRIBUTES
# an array of attributes that will be displayed on the model's show page.
SHOW_PAGE_ATTRIBUTES = %i[
id
rate_per_km
year
created_at
updated_at
].freeze
# FORM_ATTRIBUTES
# an array of attributes that will be displayed
# on the model's form (`new` and `edit`) pages.
FORM_ATTRIBUTES = %i[
rate_per_km
year
].freeze
# COLLECTION_FILTERS
# a hash that defines filters that can be used while searching via the search
# field of the dashboard.
#
# For example to add an option to search for open resources by typing "open:"
# in the search field:
#
# COLLECTION_FILTERS = {
# open: ->(resources) { resources.where(open: true) }
# }.freeze
COLLECTION_FILTERS = {}.freeze
# Overwrite this method to customize how mileage rates are displayed
# across all pages of the admin dashboard.
#
# def display_resource(mileage_rate)
# "MileageRate ##{mileage_rate.id}"
# end
end

78
app/dashboards/training_watch_hit_dashboard.rb

@ -1,78 +0,0 @@
require "administrate/base_dashboard"
class TrainingWatchHitDashboard < Administrate::BaseDashboard
# ATTRIBUTE_TYPES
# a hash that describes the type of each of the model's fields.
#
# Each different type represents an Administrate::Field object,
# which determines how the attribute is displayed
# on pages throughout the dashboard.
ATTRIBUTE_TYPES = {
id: Field::Number,
fingerprint: Field::String,
hit_url: Field::String,
published_at: Field::DateTime,
snippet: Field::Text,
title: Field::String,
training_watch_source: Field::BelongsTo,
created_at: Field::DateTime,
updated_at: Field::DateTime,
}.freeze
# COLLECTION_ATTRIBUTES
# an array of attributes that will be displayed on the model's index page.
#
# By default, it's limited to four items to reduce clutter on index pages.
# Feel free to add, remove, or rearrange items.
COLLECTION_ATTRIBUTES = %i[
id
fingerprint
hit_url
published_at
].freeze
# SHOW_PAGE_ATTRIBUTES
# an array of attributes that will be displayed on the model's show page.
SHOW_PAGE_ATTRIBUTES = %i[
id
fingerprint
hit_url
published_at
snippet
title
training_watch_source
created_at
updated_at
].freeze
# FORM_ATTRIBUTES
# an array of attributes that will be displayed
# on the model's form (`new` and `edit`) pages.
FORM_ATTRIBUTES = %i[
fingerprint
hit_url
published_at
snippet
title
training_watch_source
].freeze
# COLLECTION_FILTERS
# a hash that defines filters that can be used while searching via the search
# field of the dashboard.
#
# For example to add an option to search for open resources by typing "open:"
# in the search field:
#
# COLLECTION_FILTERS = {
# open: ->(resources) { resources.where(open: true) }
# }.freeze
COLLECTION_FILTERS = {}.freeze
# Overwrite this method to customize how training watch hits are displayed
# across all pages of the admin dashboard.
#
# def display_resource(training_watch_hit)
# "TrainingWatchHit ##{training_watch_hit.id}"
# end
end

87
app/dashboards/training_watch_source_dashboard.rb

@ -1,87 +0,0 @@
require "administrate/base_dashboard"
class TrainingWatchSourceDashboard < Administrate::BaseDashboard
# ATTRIBUTE_TYPES
# a hash that describes the type of each of the model's fields.
#
# Each different type represents an Administrate::Field object,
# which determines how the attribute is displayed
# on pages throughout the dashboard.
ATTRIBUTE_TYPES = {
id: Field::Number,
enabled: Field::Boolean,
kind: Field::String,
last_checked_at: Field::DateTime,
last_etag: Field::String,
last_modified: Field::String,
match_regex: Field::String,
name: Field::String,
training_watch_hits: Field::HasMany,
url: Field::String,
created_at: Field::DateTime,
updated_at: Field::DateTime,
}.freeze
# COLLECTION_ATTRIBUTES
# an array of attributes that will be displayed on the model's index page.
#
# By default, it's limited to four items to reduce clutter on index pages.
# Feel free to add, remove, or rearrange items.
COLLECTION_ATTRIBUTES = %i[
id
enabled
kind
last_checked_at
].freeze
# SHOW_PAGE_ATTRIBUTES
# an array of attributes that will be displayed on the model's show page.
SHOW_PAGE_ATTRIBUTES = %i[
id
enabled
kind
last_checked_at
last_etag
last_modified
match_regex
name
training_watch_hits
url
created_at
updated_at
].freeze
# FORM_ATTRIBUTES
# an array of attributes that will be displayed
# on the model's form (`new` and `edit`) pages.
FORM_ATTRIBUTES = %i[
enabled
kind
last_checked_at
last_etag
last_modified
match_regex
name
training_watch_hits
url
].freeze
# COLLECTION_FILTERS
# a hash that defines filters that can be used while searching via the search
# field of the dashboard.
#
# For example to add an option to search for open resources by typing "open:"
# in the search field:
#
# COLLECTION_FILTERS = {
# open: ->(resources) { resources.where(open: true) }
# }.freeze
COLLECTION_FILTERS = {}.freeze
# Overwrite this method to customize how training watch sources are displayed
# across all pages of the admin dashboard.
#
# def display_resource(training_watch_source)
# "TrainingWatchSource ##{training_watch_source.id}"
# end
end

90
app/dashboards/user_dashboard.rb

@ -1,90 +0,0 @@
require "administrate/base_dashboard"
class UserDashboard < Administrate::BaseDashboard
# ATTRIBUTE_TYPES
# a hash that describes the type of each of the model's fields.
#
# Each different type represents an Administrate::Field object,
# which determines how the attribute is displayed
# on pages throughout the dashboard.
ATTRIBUTE_TYPES = {
id: Field::Number,
email: Field::String,
encrypted_password: Field::String,
entries: Field::HasMany,
remember_created_at: Field::DateTime,
required_hours_matrix: Field::String.with_options(searchable: false),
reset_password_sent_at: Field::DateTime,
reset_password_token: Field::String,
total_required_hours: Field::Number,
weekly_target_hours: Field::Number,
weekly_target_matrix: Field::String.with_options(searchable: false),
created_at: Field::DateTime,
updated_at: Field::DateTime,
}.freeze
# COLLECTION_ATTRIBUTES
# an array of attributes that will be displayed on the model's index page.
#
# By default, it's limited to four items to reduce clutter on index pages.
# Feel free to add, remove, or rearrange items.
COLLECTION_ATTRIBUTES = %i[
id
email
encrypted_password
entries
].freeze
# SHOW_PAGE_ATTRIBUTES
# an array of attributes that will be displayed on the model's show page.
SHOW_PAGE_ATTRIBUTES = %i[
id
email
encrypted_password
entries
remember_created_at
required_hours_matrix
reset_password_sent_at
reset_password_token
total_required_hours
weekly_target_hours
weekly_target_matrix
created_at
updated_at
].freeze
# FORM_ATTRIBUTES
# an array of attributes that will be displayed
# on the model's form (`new` and `edit`) pages.
FORM_ATTRIBUTES = %i[
email
encrypted_password
entries
remember_created_at
required_hours_matrix
reset_password_sent_at
reset_password_token
total_required_hours
weekly_target_hours
weekly_target_matrix
].freeze
# COLLECTION_FILTERS
# a hash that defines filters that can be used while searching via the search
# field of the dashboard.
#
# For example to add an option to search for open resources by typing "open:"
# in the search field:
#
# COLLECTION_FILTERS = {
# open: ->(resources) { resources.where(open: true) }
# }.freeze
COLLECTION_FILTERS = {}.freeze
# Overwrite this method to customize how users are displayed
# across all pages of the admin dashboard.
#
# def display_resource(user)
# "User ##{user.id}"
# end
end

2
app/mailers/application_mailer.rb

@ -1,4 +1,4 @@
class ApplicationMailer < ActionMailer::Base class ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
default from: -> { ENV.fetch("MAILER_FROM", "praktikum@marzell.net") }
layout "mailer" layout "mailer"
end end

2
app/mailers/dump_mailer.rb

@ -4,7 +4,7 @@ class DumpMailer < ApplicationMailer
def daily_dump(zip_path) def daily_dump(zip_path)
attachments[File.basename(zip_path)] = File.read(zip_path) attachments[File.basename(zip_path)] = File.read(zip_path)
mail( mail(
to: "christoph@marzell.net",
to: ENV.fetch("BACKUP_NOTIFY_EMAIL", ENV.fetch("ADMIN_EMAIL", "christoph@marzell.net")),
subject: "📦 Täglicher SQL Dump" subject: "📦 Täglicher SQL Dump"
) do |format| ) do |format|
format.text { render plain: "Dump im Anhang." } format.text { render plain: "Dump im Anhang." }

38
app/mailers/user_mailer.rb

@ -0,0 +1,38 @@
require "cgi"
# Devise calls these methods with (record, token, options). Using a regular
# ActionMailer class keeps the API independent from Devise's controller/route
# mapping, which is not present in an API-only application.
class UserMailer < ApplicationMailer
def confirmation_instructions(record, token, opts = {})
@resource = record
@token = token
@confirmation_url = "#{frontend_url}/confirm-email?token=#{CGI.escape(token)}"
mail(
to: record.email,
subject: opts[:subject].presence || "E-Mail-Adresse bestätigen",
template_path: "devise/mailer",
template_name: "confirmation_instructions"
)
end
def reset_password_instructions(record, token, opts = {})
@resource = record
@token = token
@reset_password_url = "#{frontend_url}/reset-password?token=#{CGI.escape(token)}"
mail(
to: record.email,
subject: opts[:subject].presence || "Passwort zurücksetzen",
template_path: "devise/mailer",
template_name: "reset_password_instructions"
)
end
private
def frontend_url
ENV.fetch("FRONTEND_URL", "http://localhost:13131").delete_suffix("/")
end
end

2
app/models/user.rb

@ -106,7 +106,7 @@ class User < ApplicationRecord
end end
def is_admin? def is_admin?
email == "christoph@marzell.net"
email.casecmp?(ENV.fetch("ADMIN_EMAIL", "christoph@marzell.net"))
end end
def self.entry_arten_for(typ) def self.entry_arten_for(typ)

4
app/views/devise/mailer/confirmation_instructions.html.erb

@ -0,0 +1,4 @@
<p>Willkommen beim Ausbildungsnachweis!</p>
<p>Bitte bestätige deine E-Mail-Adresse über den folgenden Link:</p>
<p><a href="<%= @confirmation_url %>">E-Mail-Adresse bestätigen</a></p>
<p>Falls du dich nicht registriert hast, kannst du diese Nachricht ignorieren.</p>

6
app/views/devise/mailer/confirmation_instructions.text.erb

@ -0,0 +1,6 @@
Willkommen beim Ausbildungsnachweis!
Bitte bestätige deine E-Mail-Adresse:
<%= @confirmation_url %>
Falls du dich nicht registriert hast, kannst du diese Nachricht ignorieren.

3
app/views/devise/mailer/reset_password_instructions.html.erb

@ -0,0 +1,3 @@
<p>Für dein Konto wurde das Zurücksetzen des Passworts angefordert.</p>
<p><a href="<%= @reset_password_url %>">Neues Passwort festlegen</a></p>
<p>Falls du das nicht angefordert hast, kannst du diese Nachricht ignorieren.</p>

6
app/views/devise/mailer/reset_password_instructions.text.erb

@ -0,0 +1,6 @@
Für dein Konto wurde das Zurücksetzen des Passworts angefordert.
Neues Passwort festlegen:
<%= @reset_password_url %>
Falls du das nicht angefordert hast, kannst du diese Nachricht ignorieren.

2
bin/docker-entrypoint

@ -15,7 +15,7 @@ bundle exec rails db:create RAILS_ENV=${RAILS_ENV} || true
echo =========================================== echo ===========================================
echo ${RAILS_ENV} echo ${RAILS_ENV}
echo =========================================== echo ===========================================
bundle exec rails db:migrate RAILS_ENV=${RAILS_ENV} || true
bundle exec rails db:migrate RAILS_ENV=${RAILS_ENV}
echo =========================================== echo ===========================================
echo ${RAILS_ENV} echo ${RAILS_ENV}
echo =========================================== echo ===========================================

1
config/application.rb

@ -22,6 +22,7 @@ module Praktikum
class Application < Rails::Application class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version. # Initialize configuration defaults for originally generated Rails version.
config.load_defaults 7.1 config.load_defaults 7.1
config.api_only = true
config.time_zone = 'Europe/Vienna' config.time_zone = 'Europe/Vienna'
config.active_record.default_timezone = :utc config.active_record.default_timezone = :utc
# Please, add to the `ignore` list any other `lib` subdirectories that do # Please, add to the `ignore` list any other `lib` subdirectories that do

24
config/environments/development.rb

@ -43,21 +43,17 @@ Rails.application.configure do
# Don't care if the mailer can't send. # Don't care if the mailer can't send.
config.action_mailer.perform_caching = false config.action_mailer.perform_caching = false
config.action_mailer.default_url_options = { :host => "localhost:3000", :protocol => 'http', from: 'praktikum@marzell.net' }
config.action_mailer.default_url_options = { host: ENV.fetch("APP_HOST", "localhost:4200"), protocol: ENV.fetch("APP_PROTOCOL", "http") }
config.action_mailer.delivery_method = :smtp config.action_mailer.delivery_method = :smtp
# SMTP settings for gmail
config.action_mailer.perform_deliveries = true
config.action_mailer.perform_deliveries = ENV.fetch("SMTP_ENABLED", "false") == "true"
config.action_mailer.smtp_settings = { config.action_mailer.smtp_settings = {
:address => 'smtp.ionos.de',
:port => 587,
domain: "marzell.net",
:user_name => 'praktikum@marzell.net',
:password => 'CSSABXGRhSzUFK9',
authentication: 'plain',
enable_starttls_auto: true
address: ENV.fetch("SMTP_ADDRESS", "smtp.ionos.de"),
port: ENV.fetch("SMTP_PORT", "587").to_i,
domain: ENV.fetch("SMTP_DOMAIN", "marzell.net"),
user_name: ENV["SMTP_USERNAME"],
password: ENV["SMTP_PASSWORD"],
authentication: ENV.fetch("SMTP_AUTHENTICATION", "plain"),
enable_starttls_auto: ENV.fetch("SMTP_STARTTLS", "true") == "true"
} }
# Print deprecation notices to the Rails logger. # Print deprecation notices to the Rails logger.
@ -79,10 +75,8 @@ Rails.application.configure do
config.active_job.verbose_enqueue_logs = true config.active_job.verbose_enqueue_logs = true
# Suppress logger output for asset requests. # Suppress logger output for asset requests.
config.assets.quiet = true
config.assets.digest = true
# Raises error for missing translations. # Raises error for missing translations.
# config.i18n.raise_on_missing_translations = true # config.i18n.raise_on_missing_translations = true

26
config/environments/production.rb

@ -27,7 +27,6 @@ Rails.application.configure do
# config.assets.css_compressor = :sass # config.assets.css_compressor = :sass
# Do not fall back to assets pipeline if a precompiled asset is missed. # Do not fall back to assets pipeline if a precompiled asset is missed.
config.assets.compile = false
# Enable serving of images, stylesheets, and JavaScripts from an asset server. # Enable serving of images, stylesheets, and JavaScripts from an asset server.
# config.asset_host = "http://assets.example.com" # config.asset_host = "http://assets.example.com"
@ -99,20 +98,21 @@ Rails.application.configure do
config.action_mailer.perform_caching = false config.action_mailer.perform_caching = false
config.action_mailer.default_url_options = { :host => "praktikum.marzell.net", :protocol => 'https', from: 'praktikum@marzell.net' }
config.action_mailer.default_url_options = {
host: ENV.fetch("APP_HOST", "localhost:13131"),
protocol: ENV.fetch("APP_PROTOCOL", "http")
}
config.action_mailer.delivery_method = :smtp config.action_mailer.delivery_method = :smtp
# SMTP settings for gmail
config.action_mailer.perform_deliveries = true
config.action_mailer.perform_deliveries = ENV.fetch("SMTP_ENABLED", "true") == "true"
config.action_mailer.raise_delivery_errors = true
config.action_mailer.smtp_settings = { config.action_mailer.smtp_settings = {
:address => 'smtp.ionos.de',
:port => 587,
:user_name => 'praktikum@marzell.net',
:password => 'CSSABXGRhSzUFK9',
domain: "marzell.net",
authentication: 'plain',
enable_starttls_auto: true
address: ENV.fetch("SMTP_ADDRESS", "smtp.ionos.de"),
port: ENV.fetch("SMTP_PORT", "587").to_i,
user_name: ENV["SMTP_USERNAME"],
password: ENV["SMTP_PASSWORD"],
domain: ENV.fetch("SMTP_DOMAIN", "marzell.net"),
authentication: ENV.fetch("SMTP_AUTHENTICATION", "plain"),
enable_starttls_auto: ENV.fetch("SMTP_STARTTLS", "true") == "true"
} }

3
config/initializers/assets.rb

@ -1,8 +1,7 @@
# Be sure to restart your server when you modify this file. # Be sure to restart your server when you modify this file.
# Version of your assets, change this if you want to expire all your assets. # Version of your assets, change this if you want to expire all your assets.
Rails.application.config.assets.version = "1.0"
Rails.application.config.assets.precompile += %w( application.js jquery.min.js jquery_ujs.js jquery-ui.min.js )
# API-only application: static assets are built and served by Angular/Nginx.
# Add additional assets to the asset load path. # Add additional assets to the asset load path.
# Rails.application.config.assets.paths << Emoji.images_path # Rails.application.config.assets.paths << Emoji.images_path

25
config/initializers/authenticated_pg_hero.rb

@ -0,0 +1,25 @@
class AuthenticatedPgHero
def initialize(app)
@app = app
end
def call(environment)
request = Rack::Auth::Basic::Request.new(environment)
if valid_credentials?(request)
@app.call(environment)
else
[401, { "Content-Type" => "text/plain", "WWW-Authenticate" => 'Basic realm="PgHero Administration"' }, ["Anmeldung erforderlich"]]
end
end
private
def valid_credentials?(request)
return false unless request.provided? && request.basic?
expected_user = ENV.fetch("PGHERO_USERNAME", "admin")
expected_password = ENV["PGHERO_PASSWORD"].to_s
supplied_user, supplied_password = request.credentials
return false if expected_password.blank?
ActiveSupport::SecurityUtils.secure_compare(supplied_user.to_s, expected_user) && ActiveSupport::SecurityUtils.secure_compare(supplied_password.to_s, expected_password)
end
end

2
config/initializers/build_version.rb

@ -0,0 +1,2 @@
Rails.application.config.x.build_version = "2026.08.16.8"
Rails.logger.info("Praktikum API build 2026.08.16.8 loaded")

4
config/initializers/devise.rb

@ -24,10 +24,10 @@ Devise.setup do |config|
# Configure the e-mail address which will be shown in Devise::Mailer, # Configure the e-mail address which will be shown in Devise::Mailer,
# note that it will be overwritten if you use your own mailer class # note that it will be overwritten if you use your own mailer class
# with default "from" parameter. # with default "from" parameter.
config.mailer_sender = 'praktikum@marzell.net'
config.mailer_sender = ENV.fetch("MAILER_FROM", "praktikum@marzell.net")
# Configure the class responsible to send e-mails. # Configure the class responsible to send e-mails.
# config.mailer = 'Devise::Mailer'
config.mailer = 'UserMailer'
# Configure the parent class responsible to send e-mails. # Configure the parent class responsible to send e-mails.
# config.parent_mailer = 'ActionMailer::Base' # config.parent_mailer = 'ActionMailer::Base'

2
config/initializers/good_job.rb

@ -17,7 +17,7 @@ Rails.application.configure do
}, },
training_watch: { training_watch: {
cron: "0 0 * * * Europe/Vienna", cron: "0 0 * * * Europe/Vienna",
class: "TrainingWatch",
class: "TrainingWatchJob",
description: "TrainingWatch starten" description: "TrainingWatch starten"
} }
} }

71
config/routes.rb

@ -1,54 +1,39 @@
Rails.application.routes.draw do Rails.application.routes.draw do
namespace :admin do
resources :entries
resources :users
resources :mileage_rates
resources :training_watch_hits
resources :training_watch_sources
root to: "entries#index"
end
authenticate :user, ->(u) { u.is_admin? } do
scope :admin do
mount PgHero::Engine, at: "pghero", as: :pghero
end
end
get "/calendar/month/:year/:month", to: "calendar#month", as: :calendar_month
get "/calendar/week/:year/:week", to: "calendar#week", as: :calendar_week
resources :mileage_rates, only: [:index, :new, :create, :edit, :update]
resource :dashboard, only: [:show]
namespace :api do
namespace :v1 do
post "auth/login", to: "auth#login"
post "auth/register", to: "auth#register"
post "auth/confirm", to: "auth#confirm"
post "auth/confirmation/resend", to: "auth#resend_confirmation"
post "auth/password/forgot", to: "auth#forgot_password"
patch "auth/password/reset", to: "auth#reset_password"
patch "auth/password/change", to: "auth#change_password"
delete "auth/logout", to: "auth#logout"
get "auth/me", to: "auth#me"
resources :entries do resources :entries do
post :start_timer, on: :collection post :start_timer, on: :collection
member do
post :stop_timer
post :stop_timer, on: :member
get :export_csv, on: :collection
end
get "dashboard", to: "dashboard#show"
get "calendar", to: "calendar#index"
get "monthly_report", to: "reports#monthly"
resources :mileage_rates, except: %i[new edit destroy]
resource :settings, only: %i[show update]
namespace :admin do
get "backup", to: "database#backup"
post "restore", to: "database#restore"
resources :users, only: %i[index show create update destroy]
resources :entries, only: %i[index show update destroy]
resources :training_watch_sources
resources :training_watch_hits, only: %i[index show destroy]
post "training_watch/run", to: "training_watch#run"
end end
collection do
get :export_csv
end end
end end
resource :user_goal, only: [:update]
root 'entries#index'
devise_for :users, controllers: {
registrations: 'users/registrations'
}
get '/impressum', to: 'static_pages#impressum'
get "/monthly_report", to: "entries#monthly_report", as: :monthly_report_entries
post "/db_dump/restore", to: "db_dump#restore"
get "/db_dump/dump", to: "db_dump#dump"
get "/db_dump", to: "db_dump#index"
get "/rechner", to: "calculations#new", as: :rechner
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
mount AuthenticatedPgHero.new(PgHero::Engine), at: "/admin/pghero"
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
# Can be used by load balancers and uptime monitors to verify that the app is live.
get "up" => "rails/health#show", as: :rails_health_check get "up" => "rails/health#show", as: :rails_health_check
# Defines the root path route ("/")
# root "posts#index"
end end

6
db/migrate/20260815000000_add_api_token_digest_to_users.rb

@ -0,0 +1,6 @@
class AddApiTokenDigestToUsers < ActiveRecord::Migration[7.1]
def change
add_column :users, :api_token_digest, :string
add_index :users, :api_token_digest, unique: true
end
end

6
db/migrate/20260816000000_ensure_api_token_digest_on_users.rb

@ -0,0 +1,6 @@
class EnsureApiTokenDigestOnUsers < ActiveRecord::Migration[7.1]
def change
add_column :users, :api_token_digest, :string unless column_exists?(:users, :api_token_digest)
add_index :users, :api_token_digest, unique: true unless index_exists?(:users, :api_token_digest)
end
end

74
docker-compose.yml

@ -1,55 +1,55 @@
version: "3.3"
services: services:
db: db:
image: postgres:17 image: postgres:17
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_PASSWORD: password
POSTGRES_DB: praktikum
POSTGRES_USER: praktikum POSTGRES_USER: praktikum
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- '35432:35432'
POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-password}
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck: healthcheck:
test: [ "CMD", "pg_isready", "-q" ]
timeout: 45s
test: ["CMD-SHELL", "pg_isready -U praktikum -d praktikum"]
interval: 10s interval: 10s
timeout: 5s
retries: 10 retries: 10
command: -p 35432
networks:
- praktikum-network
redis: redis:
image: 'redis'
command: redis-server
volumes:
- 'redis:/data'
networks:
- praktikum-network
environment:
- ALLOW_EMPTY_PASSWORD=yes
ports:
- '6379:6379'
web:
image: redis:7-alpine
restart: unless-stopped
volumes: [redis:/data]
api:
build: . build: .
restart: unless-stopped restart: unless-stopped
command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -b 0.0.0.0"
volumes:
- .:/app
ports:
- "13131:3000"
depends_on: depends_on:
- db
db: { condition: service_healthy }
environment: environment:
DATABASE_URL: postgres://praktikum:password@db:35432/postgres
DATABASE_URL: postgres://praktikum:${DATABASE_PASSWORD:-password}@db:5432/praktikum
REDIS_URL: redis://redis:6379/0
RAILS_ENV: production RAILS_ENV: production
SECRET_KEY_BASE: 5e1494fafd054d854b52661265e64e50d69787e9d4495173606f36fa3e5a685bd0264a41d6e138ea430f24008d5c07bcdf2a62a69de3c1b1cc042f05721432a0
DB: db
networks:
- praktikum-network
SECRET_KEY_BASE: ${SECRET_KEY_BASE:?SECRET_KEY_BASE fehlt. Bitte zuerst setup-env.ps1 ausfuehren oder eine .env anlegen.}
FRONTEND_ORIGINS: ${FRONTEND_ORIGINS:-http://localhost:13131}
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:13131}
APP_HOST: ${APP_HOST:-localhost:13131}
APP_PROTOCOL: ${APP_PROTOCOL:-http}
ADMIN_EMAIL: ${ADMIN_EMAIL:-christoph@marzell.net}
MAILER_FROM: ${MAILER_FROM:-praktikum@marzell.net}
SMTP_ENABLED: ${SMTP_ENABLED:-true}
SMTP_ADDRESS: ${SMTP_ADDRESS:-smtp.ionos.de}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USERNAME: ${SMTP_USERNAME:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_DOMAIN: ${SMTP_DOMAIN:-marzell.net}
SMTP_AUTHENTICATION: ${SMTP_AUTHENTICATION:-plain}
SMTP_STARTTLS: ${SMTP_STARTTLS:-true}
BACKUP_NOTIFY_EMAIL: ${BACKUP_NOTIFY_EMAIL:-christoph@marzell.net}
TRAINING_WATCH_NOTIFY_EMAIL: ${TRAINING_WATCH_NOTIFY_EMAIL:-christoph@marzell.net}
PGHERO_USERNAME: ${PGHERO_USERNAME:-admin}
PGHERO_PASSWORD: ${PGHERO_PASSWORD:-}
ports: ["${API_PORT:-13000}:3000"]
frontend:
build: ./frontend
restart: unless-stopped
depends_on: [api]
ports: ["${FRONTEND_PORT:-13131}:80"]
volumes: volumes:
pgdata: pgdata:
redis: redis:
networks:
praktikum-network:
driver: bridge

11
frontend/Dockerfile

@ -0,0 +1,11 @@
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.29-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist/praktikum/browser /usr/share/nginx/html
EXPOSE 80

9
frontend/angular.json

@ -0,0 +1,9 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json", "version": 1, "newProjectRoot": "projects",
"projects": { "praktikum": { "projectType": "application", "root": "", "sourceRoot": "src", "prefix": "app",
"architect": { "build": { "builder": "@angular/build:application", "options": { "browser": "src/main.ts", "tsConfig": "tsconfig.app.json", "assets": [{"glob":"**/*","input":"public"}], "styles": ["node_modules/@fontsource-variable/inter/index.css", "src/styles.scss"] },
"configurations": { "production": { "outputHashing": "all", "budgets": [{"type":"initial","maximumWarning":"1MB","maximumError":"2MB"}] } }, "defaultConfiguration": "production" },
"serve": { "builder": "@angular/build:dev-server", "configurations": { "production": { "buildTarget": "praktikum:build:production" }, "development": { "buildTarget": "praktikum:build" } }, "defaultConfiguration": "development" }
}
} }, "cli": { "analytics": false }
}

20
frontend/nginx.conf

@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
client_max_body_size 250m;
client_body_buffer_size 250m;
root /usr/share/nginx/html;
location /api/ {
proxy_pass http://api:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /admin/pghero {
proxy_pass http://api:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / { try_files $uri $uri/ /index.html; }
}

6883
frontend/package-lock.json
File diff suppressed because it is too large
View File

31
frontend/package.json

@ -0,0 +1,31 @@
{
"name": "praktikum-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "ng serve --proxy-config proxy.conf.json",
"build": "ng build",
"test": "ng test --watch=false"
},
"dependencies": {
"@angular/animations": "^22.0.0",
"@angular/cdk": "^22.0.0",
"@angular/common": "^22.0.0",
"@angular/compiler": "^22.0.0",
"@angular/core": "^22.0.0",
"@angular/forms": "^22.0.0",
"@angular/material": "^22.0.0",
"@angular/platform-browser": "^22.0.0",
"@angular/router": "^22.0.0",
"@fontsource-variable/inter": "^5.3.0",
"material-icons": "^1.13.14",
"rxjs": "~7.8.2",
"tslib": "^2.8.1"
},
"devDependencies": {
"@angular/build": "^22.0.0",
"@angular/cli": "^22.0.0",
"@angular/compiler-cli": "^22.0.0",
"typescript": "~6.0.0"
}
}

1
frontend/proxy.conf.json

@ -0,0 +1 @@
{"/api":{"target":"http://localhost:3000","secure":false,"changeOrigin":true},"/up":{"target":"http://localhost:3000","secure":false}}

239
frontend/src/app/app.component.ts

@ -0,0 +1,239 @@
import { Component, OnInit, signal } from "@angular/core";
import {
Router,
RouterLink,
RouterLinkActive,
RouterOutlet,
} from "@angular/router";
import { MATERIAL } from "./shared/material";
import { ApiService } from "./core/api.service";
@Component({
selector: "app-root",
standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive, ...MATERIAL],
template: `
@if (loggedIn()) {
<mat-sidenav-container class="shell"
><mat-sidenav
#drawer
[mode]="mobile() ? 'over' : 'side'"
[opened]="!mobile()"
class="nav"
>
<div class="nav-layout">
<div class="brand">
<span class="brand-mark">A</span>
<div>
<strong>Ausbildungsnachweis</strong
><small>Praxis & Weiterbildung</small>
</div>
</div>
<mat-nav-list class="nav-links"
><a mat-list-item routerLink="/dashboard" routerLinkActive="active"
><mat-icon matListItemIcon>dashboard</mat-icon
><span matListItemTitle>Dashboard</span></a
>
<a mat-list-item routerLink="/entries" routerLinkActive="active"
><mat-icon matListItemIcon>schedule</mat-icon
><span matListItemTitle>Einträge</span></a
>
<a mat-list-item routerLink="/calendar" routerLinkActive="active"
><mat-icon matListItemIcon>calendar_month</mat-icon
><span matListItemTitle>Kalender</span></a
>
<a mat-list-item routerLink="/reports" routerLinkActive="active"
><mat-icon matListItemIcon>analytics</mat-icon
><span matListItemTitle>Berichte</span></a
>
<a mat-list-item routerLink="/calculator" routerLinkActive="active"
><mat-icon matListItemIcon>calculate</mat-icon
><span matListItemTitle>Minutenrechner</span></a
>
<a
mat-list-item
routerLink="/mileage-rates"
routerLinkActive="active"
><mat-icon matListItemIcon>route</mat-icon
><span matListItemTitle>Kilometersätze</span></a
>
<a mat-list-item routerLink="/settings" routerLinkActive="active"
><mat-icon matListItemIcon>tune</mat-icon
><span matListItemTitle>Einstellungen</span></a
>
@if (api.user()?.is_admin) {
<a
mat-list-item
routerLink="/admin/database"
routerLinkActive="active"
><mat-icon matListItemIcon>admin_panel_settings</mat-icon
><span matListItemTitle>Administration</span></a
>
}
</mat-nav-list>
<div class="nav-bottom">
<a mat-button routerLink="/impressum"
><mat-icon>info</mat-icon> Impressum</a
>
<button mat-button (click)="logout()">
<mat-icon>logout</mat-icon> Abmelden
</button>
<small class="build-version">Build 2026.08.16.8</small>
</div>
</div>
</mat-sidenav
><mat-sidenav-content
><mat-toolbar
><button
mat-icon-button
(click)="drawer.toggle()"
class="mobile-only"
>
<mat-icon>menu</mat-icon></button
><span class="spacer"></span
><button mat-icon-button (click)="toggleTheme()">
<mat-icon>{{
dark() ? "light_mode" : "dark_mode"
}}</mat-icon></button
><span class="user">{{ api.user()?.email }}</span></mat-toolbar
><router-outlet /></mat-sidenav-content
></mat-sidenav-container>
} @else {
<router-outlet />
}
`,
styles: [
`
.shell {
height: 100dvh;
min-height: 0;
overflow: hidden;
}
.nav {
width: 276px;
border-right: 1px solid var(--app-border);
background: var(--app-surface);
color: var(--app-text);
overflow: hidden;
}
.nav-layout {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 10px 10px 8px;
}
.brand {
display: flex;
gap: 12px;
align-items: center;
padding: 4px 8px 10px;
}
.brand-mark {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 13px;
background: linear-gradient(135deg, #155bd7, #31a6f4);
color: white;
font-weight: 800;
font-size: 20px;
}
.brand div {
display: flex;
flex-direction: column;
}
.brand small {
color: var(--app-muted);
margin-top: 3px;
}
.active {
background: color-mix(in srgb, var(--app-primary) 15%, transparent) !important;
border-radius: 12px;
}
.nav-links {
flex: 1 1 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
padding: 0 6px 6px;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--app-muted) 55%, transparent) transparent;
}
.nav-links a {
--mdc-list-list-item-one-line-container-height: 43px;
margin-bottom: 2px;
border-radius: 12px;
}
.nav-bottom {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: stretch;
padding: 5px 6px 0;
border-top: 1px solid var(--app-border);
}
.nav-bottom a, .nav-bottom button { justify-content:flex-start; width:100%; min-height:36px; }
.build-version { color:var(--app-muted);font-size:11px;padding:5px 12px 0; }
mat-toolbar {
position: sticky;
top: 0;
z-index: 10;
background: color-mix(in srgb, var(--app-surface) 88%, transparent);
backdrop-filter: blur(14px);
border-bottom: 1px solid var(--app-border);
color: var(--app-text);
}
.spacer {
flex: 1;
}
.user {
font-size: 14px;
margin-left: 8px;
}
.mobile-only {
display: none;
}
@media (max-width: 850px) {
.mobile-only {
display: inline-flex;
}
.user {
display: none;
}
}
`,
],
})
export class AppComponent implements OnInit {
readonly dark = signal(localStorage.getItem("theme") === "dark");
readonly mobile = signal(innerWidth < 850);
constructor(
readonly api: ApiService,
private router: Router,
) {
document.body.classList.toggle("dark", this.dark());
addEventListener("resize", () => this.mobile.set(innerWidth < 850));
}
ngOnInit() {
if (localStorage.getItem("praktikum_token"))
this.api.me().subscribe({ error: () => this.logout(false) });
}
loggedIn() {
return !!localStorage.getItem("praktikum_token");
}
toggleTheme() {
this.dark.update((v) => !v);
localStorage.setItem("theme", this.dark() ? "dark" : "light");
document.body.classList.toggle("dark", this.dark());
}
logout(call = true) {
if (call)
this.api.logout().subscribe(() => this.router.navigate(["/login"]));
else {
localStorage.removeItem("praktikum_token");
this.router.navigate(["/login"]);
}
}
}

117
frontend/src/app/app.routes.ts

@ -0,0 +1,117 @@
import { Routes } from "@angular/router";
import { inject } from "@angular/core";
import { Router } from "@angular/router";
const authGuard = () =>
localStorage.getItem("praktikum_token")
? true
: inject(Router).createUrlTree(["/login"]);
export const routes: Routes = [
{
path: "login",
loadComponent: () =>
import("./pages/login.component").then((m) => m.LoginComponent),
},
{
path: "register",
loadComponent: () =>
import("./pages/register.component").then((m) => m.RegisterComponent),
},
{
path: "forgot-password",
loadComponent: () =>
import("./pages/forgot-password.component").then(
(m) => m.ForgotPasswordComponent,
),
},
{
path: "reset-password",
loadComponent: () =>
import("./pages/reset-password.component").then(
(m) => m.ResetPasswordComponent,
),
},
{
path: "confirm-email",
loadComponent: () =>
import("./pages/confirm-email.component").then(
(m) => m.ConfirmEmailComponent,
),
},
{ path: "resend-confirmation", loadComponent: () => import("./pages/resend-confirmation.component").then((m) => m.ResendConfirmationComponent) },
{
path: "",
canActivate: [authGuard],
children: [
{
path: "dashboard",
loadComponent: () =>
import("./pages/dashboard.component").then(
(m) => m.DashboardComponent,
),
},
{
path: "entries",
loadComponent: () =>
import("./pages/entries.component").then((m) => m.EntriesComponent),
},
{
path: "entries/new",
loadComponent: () =>
import("./pages/entry-form.component").then(
(m) => m.EntryFormComponent,
),
},
{
path: "entries/:id/edit",
loadComponent: () =>
import("./pages/entry-form.component").then(
(m) => m.EntryFormComponent,
),
},
{
path: "calendar",
loadComponent: () =>
import("./pages/calendar.component").then((m) => m.CalendarComponent),
},
{
path: "reports",
loadComponent: () =>
import("./pages/reports.component").then((m) => m.ReportsComponent),
},
{
path: "settings",
loadComponent: () =>
import("./pages/settings.component").then((m) => m.SettingsComponent),
},
{
path: "mileage-rates",
loadComponent: () =>
import("./pages/mileage-rates.component").then(
(m) => m.MileageRatesComponent,
),
},
{
path: "calculator",
loadComponent: () =>
import("./pages/calculator.component").then(
(m) => m.CalculatorComponent,
),
},
{
path: "admin/database",
loadComponent: () =>
import("./pages/admin-database.component").then(
(m) => m.AdminDatabaseComponent,
),
},
{ path: "", pathMatch: "full", redirectTo: "dashboard" },
],
},
{
path: "impressum",
loadComponent: () =>
import("./pages/impressum.component").then((m) => m.ImpressumComponent),
},
{ path: "**", redirectTo: "" },
];

206
frontend/src/app/core/api.service.ts

@ -0,0 +1,206 @@
import { Injectable, signal } from "@angular/core";
import { HttpClient, HttpParams } from "@angular/common/http";
import { tap } from "rxjs";
import {
Dashboard,
Entry,
EntryInput,
MileageRate,
Settings,
User,
} from "./models";
@Injectable({ providedIn: "root" })
export class ApiService {
private readonly base = "/api/v1";
readonly user = signal<User | null>(null);
constructor(private readonly http: HttpClient) {}
login(email: string, password: string) {
return this.http
.post<{ token: string; user: User }>(`${this.base}/auth/login`, {
email,
password,
})
.pipe(
tap((r) => {
localStorage.setItem("praktikum_token", r.token);
this.user.set(r.user);
}),
);
}
register(email: string, password: string, password_confirmation: string) {
return this.http.post<{ message: string }>(`${this.base}/auth/register`, {
email,
password,
password_confirmation,
});
}
confirmEmail(token: string) {
return this.http.post<{ message: string }>(`${this.base}/auth/confirm`, {
token,
});
}
resendConfirmation(email: string) {
return this.http.post<{ message: string }>(`${this.base}/auth/confirmation/resend`, { email });
}
forgotPassword(email: string) {
return this.http.post<{ message: string }>(
`${this.base}/auth/password/forgot`,
{ email },
);
}
resetPassword(
token: string,
password: string,
password_confirmation: string,
) {
return this.http.patch<{ message: string }>(
`${this.base}/auth/password/reset`,
{ token, password, password_confirmation },
);
}
changePassword(
current_password: string,
password: string,
password_confirmation: string,
) {
return this.http.patch<{ message: string }>(
`${this.base}/auth/password/change`,
{ current_password, password, password_confirmation },
);
}
logout() {
return this.http.delete<void>(`${this.base}/auth/logout`).pipe(
tap(() => {
localStorage.removeItem("praktikum_token");
this.user.set(null);
}),
);
}
me() {
return this.http
.get<User>(`${this.base}/auth/me`)
.pipe(tap((u) => this.user.set(u)));
}
dashboard() {
return this.http.get<Dashboard>(`${this.base}/dashboard`);
}
entries(filters: Record<string, string> = {}) {
return this.http.get<Entry[]>(`${this.base}/entries`, {
params: new HttpParams({ fromObject: filters }),
});
}
entry(id: number) {
return this.http.get<Entry>(`${this.base}/entries/${id}`);
}
saveEntry(value: Partial<EntryInput>, id?: number) {
return id
? this.http.patch<Entry>(`${this.base}/entries/${id}`, { entry: value })
: this.http.post<Entry>(`${this.base}/entries`, { entry: value });
}
deleteEntry(id: number) {
return this.http.delete<void>(`${this.base}/entries/${id}`);
}
startTimer(typ: string, art: string) {
return this.http.post<Entry>(`${this.base}/entries/start_timer`, {
typ,
art,
});
}
stopTimer(id: number, lunch_break: boolean) {
return this.http.post<Entry>(`${this.base}/entries/${id}/stop_timer`, {
lunch_break,
});
}
calendar(from: string, to: string) {
return this.http.get<Entry[]>(`${this.base}/calendar`, {
params: { from, to },
});
}
settings() {
return this.http.get<Settings>(`${this.base}/settings`);
}
saveSettings(settings: Partial<Settings>) {
return this.http.patch<Settings>(`${this.base}/settings`, { settings });
}
mileageRates() {
return this.http.get<MileageRate[]>(`${this.base}/mileage_rates`);
}
saveMileageRate(value: Partial<MileageRate>) {
return value.id
? this.http.patch<MileageRate>(`${this.base}/mileage_rates/${value.id}`, {
mileage_rate: value,
})
: this.http.post<MileageRate>(`${this.base}/mileage_rates`, {
mileage_rate: value,
});
}
monthlyReport() {
return this.http.get<
{ month: string; typ: string; art: string; total_minutes: number }[]
>(`${this.base}/monthly_report`);
}
exportCsv() {
return this.http.get(`${this.base}/entries/export_csv`, {
responseType: "blob",
});
}
databaseBackup() {
return this.http.get(`${this.base}/admin/backup`, { responseType: "blob" });
}
databaseRestore(file: File) {
const data = new FormData();
data.append("file", file);
return this.http.post<{ message: string }>(
`${this.base}/admin/restore`,
data,
);
}
adminUsers() {
return this.http.get<any[]>(`${this.base}/admin/users`);
}
createAdminUser(user: any) {
return this.http.post<any>(`${this.base}/admin/users`, { user });
}
updateAdminUser(id: number, user: any) {
return this.http.patch<any>(`${this.base}/admin/users/${id}`, { user });
}
deleteAdminUser(id: number) {
return this.http.delete<void>(`${this.base}/admin/users/${id}`);
}
adminEntries() {
return this.http.get<any[]>(`${this.base}/admin/entries`);
}
deleteAdminEntry(id: number) {
return this.http.delete<void>(`${this.base}/admin/entries/${id}`);
}
trainingSources() {
return this.http.get<any[]>(`${this.base}/admin/training_watch_sources`);
}
saveTrainingSource(value: any) {
return value.id
? this.http.patch<any>(
`${this.base}/admin/training_watch_sources/${value.id}`,
{ training_watch_source: value },
)
: this.http.post<any>(`${this.base}/admin/training_watch_sources`, {
training_watch_source: value,
});
}
deleteTrainingSource(id: number) {
return this.http.delete<void>(
`${this.base}/admin/training_watch_sources/${id}`,
);
}
trainingHits() {
return this.http.get<any[]>(`${this.base}/admin/training_watch_hits`);
}
deleteTrainingHit(id: number) {
return this.http.delete<void>(
`${this.base}/admin/training_watch_hits/${id}`,
);
}
runTrainingWatch() {
return this.http.post<any>(`${this.base}/admin/training_watch/run`, {});
}
}

72
frontend/src/app/core/austrian-date-adapter.ts

@ -0,0 +1,72 @@
import {
MatDateFormats,
NativeDateAdapter,
} from "@angular/material/core";
export const AUSTRIAN_DATE_FORMATS: MatDateFormats = {
parse: {
dateInput: "DD.MM.YYYY",
timeInput: "HH:mm",
},
display: {
dateInput: {
day: "2-digit",
month: "2-digit",
year: "numeric",
},
monthYearLabel: {
month: "long",
year: "numeric",
},
dateA11yLabel: {
day: "2-digit",
month: "long",
year: "numeric",
},
monthYearA11yLabel: {
month: "long",
year: "numeric",
},
timeInput: {
hour: "2-digit",
minute: "2-digit",
hour12: false,
},
timeOptionLabel: {
hour: "2-digit",
minute: "2-digit",
hour12: false,
},
},
};
export class AustrianDateAdapter extends NativeDateAdapter {
override parse(value: unknown): Date | null {
if (value instanceof Date) return value;
if (typeof value === "string") {
const match = value.trim().match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (match) {
const day = Number(match[1]);
const month = Number(match[2]) - 1;
const year = Number(match[3]);
const date = new Date(year, month, day);
return date.getFullYear() === year &&
date.getMonth() === month &&
date.getDate() === day
? date
: this.invalid();
}
}
return super.parse(value);
}
override parseTime(value: unknown): Date | null {
if (value instanceof Date) return value;
if (typeof value !== "string") return this.invalid();
const match = value.trim().match(/^([01]?\d|2[0-3])[:.]([0-5]\d)$/);
if (!match) return this.invalid();
const date = this.today();
date.setHours(Number(match[1]), Number(match[2]), 0, 0);
return date;
}
}

2
frontend/src/app/core/auth.interceptor.ts

@ -0,0 +1,2 @@
import { HttpInterceptorFn } from '@angular/common/http';
export const authInterceptor:HttpInterceptorFn=(req,next)=>{const token=localStorage.getItem('praktikum_token');return next(token?req.clone({setHeaders:{Authorization:`Bearer ${token}`}}):req);};

71
frontend/src/app/core/models.ts

@ -0,0 +1,71 @@
export interface User {
id: number;
email: string;
praepedeutikum_done: boolean;
is_admin: boolean;
}
export interface Entry {
id: number;
date: string;
hours: number;
minutes: number;
praktikums_typ: string;
entry_art: string;
distance_km: number;
beschreibung: string | null;
kosten: number | null;
start_time: string | null;
end_time: string | null;
lunch_break_minutes: number;
zaehlt_als_fortbildung: boolean;
kilometer_pauschale: number;
}
export type EntryInput = Omit<Entry, "id" | "kilometer_pauschale">;
export interface Progress {
typ: string;
art: string;
spent_minutes: number;
target_minutes: number;
remaining_minutes: number;
percent: number;
weekly_target: number;
actual_weekly: number | null;
estimated_end: string | null;
}
export interface Dashboard {
total_minutes: number;
target_minutes: number;
remaining_minutes: number;
completed_percent: number;
total_distance_km: number;
costs_by_year: {
year: number;
kilometer: number;
fortbildung: number;
selbsterfahrung: number;
supervision: number;
semester: number;
total: number;
}[];
last_entry: Entry | null;
running_entry: Entry | null;
mediation_presence_days: number;
mediation_presence_days_required: number;
progress: Progress[];
}
export interface Settings {
email: string;
total_required_hours: number;
weekly_target_hours: number;
praepedeutikum_done: boolean;
required_hours_matrix: Record<string, Record<string, number>>;
weekly_target_matrix: Record<string, Record<string, number>>;
praktikums_typen: string[];
entry_arten: string[];
entry_arten_by_typ: Record<string, string[]>;
}
export interface MileageRate {
id: number;
year: number;
rate_per_km: number;
}

368
frontend/src/app/pages/admin-database.component.ts

@ -0,0 +1,368 @@
import { Component, OnInit, signal } from "@angular/core";
import { DatePipe } from "@angular/common";
import { FormsModule } from "@angular/forms";
import { Router } from "@angular/router";
import { MatSnackBar } from "@angular/material/snack-bar";
import { ApiService } from "../core/api.service";
import { MATERIAL } from "../shared/material";
@Component({
standalone: true,
imports: [FormsModule, DatePipe, ...MATERIAL],
template: `<section class="page">
<div class="page-head">
<div>
<h1>Administration</h1>
<p class="muted">
Benutzer, Daten, Sicherungen und Ausbildungsstellen-Watcher
</p>
</div>
</div>
<div class="admin-nav">
@for (item of sections; track item.id) {
<button
mat-stroked-button
[class.selected]="section() === item.id"
(click)="section.set(item.id)"
>
<mat-icon>{{ item.icon }}</mat-icon
>{{ item.label }}
</button>
}
</div>
@if (section() === "database") {
<div class="grid cards">
<mat-card
><mat-card-header
><mat-card-title>Datenbank sichern</mat-card-title></mat-card-header
><mat-card-content
><p>Aktuellen PostgreSQL-Dump als ZIP herunterladen.</p>
<button mat-flat-button (click)="backup()">
<mat-icon>download</mat-icon> Backup herunterladen
</button></mat-card-content
></mat-card
><mat-card><mat-card-header><mat-card-title>PgHero</mat-card-title></mat-card-header><mat-card-content><p>Datenbankabfragen, Speicher und Performance analysieren.</p><a mat-stroked-button href="/admin/pghero" target="_blank"><mat-icon>monitoring</mat-icon> PgHero öffnen</a></mat-card-content></mat-card
><mat-card class="danger"
><mat-card-header
><mat-card-title
>Datenbank wiederherstellen</mat-card-title
></mat-card-header
><mat-card-content
><p>
Eine Sicherung überschreibt den aktuellen Datenbestand. Vorher
unbedingt ein Backup herunterladen.
</p>
<input
#fileInput
type="file"
accept=".zip,.sql"
(change)="selectFile($event)"
/><button
mat-flat-button
color="warn"
[disabled]="!restoreFile() || busy()"
(click)="restore()"
>
<mat-icon>restore</mat-icon> Sicherung einspielen
</button></mat-card-content
></mat-card
>
</div>
}
@if (section() === "users") {
<mat-card class="source-form"><mat-card-content><mat-form-field appearance="outline"><mat-label>E-Mail</mat-label><input matInput type="email" [(ngModel)]="newUser.email"></mat-form-field><mat-form-field appearance="outline"><mat-label>Startpasswort</mat-label><input matInput type="password" [(ngModel)]="newUser.password"></mat-form-field><mat-checkbox [(ngModel)]="newUser.confirmed">Sofort bestätigen</mat-checkbox><button mat-flat-button (click)="createUser()">Benutzer anlegen</button></mat-card-content></mat-card>
<mat-card
><div class="table-wrap">
<table mat-table [dataSource]="users()">
<ng-container matColumnDef="email"
><th mat-header-cell *matHeaderCellDef>E-Mail</th>
<td mat-cell *matCellDef="let u">
{{ u.email }}
@if (u.is_admin) {
<strong>Admin</strong>
}
</td></ng-container
><ng-container matColumnDef="confirmed"
><th mat-header-cell *matHeaderCellDef>Bestätigt</th>
<td mat-cell *matCellDef="let u">
<mat-checkbox
[checked]="!!u.confirmed_at"
(change)="setConfirmed(u, $event.checked)"
></mat-checkbox></td></ng-container
><ng-container matColumnDef="entries"
><th mat-header-cell *matHeaderCellDef>Einträge</th>
<td mat-cell *matCellDef="let u">
{{ u.entries_count }}
</td></ng-container
><ng-container matColumnDef="actions"
><th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let u">
<button
mat-icon-button
(click)="removeUser(u)"
[disabled]="u.is_admin"
>
<mat-icon>delete</mat-icon>
</button>
</td></ng-container
>
<tr mat-header-row *matHeaderRowDef="userColumns"></tr>
<tr mat-row *matRowDef="let row; columns: userColumns"></tr>
</table></div
></mat-card>
}
@if (section() === "entries") {
<mat-card
><div class="table-wrap">
<table mat-table [dataSource]="entries()">
<ng-container matColumnDef="date"
><th mat-header-cell *matHeaderCellDef>Datum</th>
<td mat-cell *matCellDef="let e">
{{ e.date | date: "dd.MM.yyyy" }}
</td></ng-container
><ng-container matColumnDef="user"
><th mat-header-cell *matHeaderCellDef>Benutzer</th>
<td mat-cell *matCellDef="let e">
{{ e.user_email }}
</td></ng-container
><ng-container matColumnDef="art"
><th mat-header-cell *matHeaderCellDef>Art</th>
<td mat-cell *matCellDef="let e">
{{ e.praktikums_typ }} / {{ e.entry_art }}
</td></ng-container
><ng-container matColumnDef="time"
><th mat-header-cell *matHeaderCellDef>Zeit</th>
<td mat-cell *matCellDef="let e">
{{ e.hours }} h {{ e.minutes }} min
</td></ng-container
><ng-container matColumnDef="actions"
><th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let e">
<button mat-icon-button (click)="removeEntry(e)">
<mat-icon>delete</mat-icon>
</button>
</td></ng-container
>
<tr mat-header-row *matHeaderRowDef="entryColumns"></tr>
<tr mat-row *matRowDef="let row; columns: entryColumns"></tr>
</table></div
></mat-card>
}
@if (section() === "watcher") {
<div class="page-head">
<h2>Ausbildungsstellen-Watcher</h2>
<button mat-flat-button (click)="runWatcher()">
<mat-icon>refresh</mat-icon> Jetzt prüfen
</button>
</div>
<mat-card class="source-form"
><mat-card-content
><mat-form-field appearance="outline"
><mat-label>Name</mat-label
><input matInput [(ngModel)]="sourceDraft.name" /></mat-form-field
><mat-form-field appearance="outline"
><mat-label>URL</mat-label
><input matInput [(ngModel)]="sourceDraft.url" /></mat-form-field
><mat-form-field appearance="outline"
><mat-label>Typ</mat-label
><mat-select [(ngModel)]="sourceDraft.kind"
><mat-option value="html">HTML</mat-option
><mat-option value="rss">RSS</mat-option></mat-select
></mat-form-field
><mat-checkbox [(ngModel)]="sourceDraft.enabled">Aktiv</mat-checkbox
><button mat-flat-button (click)="saveSource()">
Quelle speichern
</button></mat-card-content
></mat-card
>
<div class="grid cards">
@for (s of sources(); track s.id) {
<mat-card
><mat-card-content
><strong>{{ s.name }}</strong
><a [href]="s.url" target="_blank">{{ s.url }}</a
><small
>Zuletzt geprüft:
{{
s.last_checked_at
? (s.last_checked_at | date: "dd.MM.yyyy HH:mm")
: "–"
}}</small
>
<div class="actions">
<button mat-button (click)="editSource(s)">Bearbeiten</button
><button mat-icon-button (click)="removeSource(s)">
<mat-icon>delete</mat-icon>
</button>
</div></mat-card-content
></mat-card
>
}
</div>
<h2>Gefundene Treffer</h2>
@for (h of hits(); track h.id) {
<mat-card class="hit"
><mat-card-content
><strong>{{ h.title }}</strong
><span>{{ h.source_name }}</span>
<p>{{ h.snippet }}</p>
<div class="actions">
<a mat-button [href]="h.hit_url" target="_blank">Öffnen</a
><button mat-icon-button (click)="removeHit(h)">
<mat-icon>delete</mat-icon>
</button>
</div></mat-card-content
></mat-card
>
}
}
</section>`,
styles: [
`
.admin-nav {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 22px;
}
.selected {
background: #dce9ff !important;
}
.danger {
border: 1px solid #e1a4a4;
}
.danger input {
margin: 10px 0 20px;
}
.source-form {
margin-bottom: 22px;
}
.source-form mat-card-content {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.source-form mat-form-field {
min-width: 220px;
}
.hit {
margin-bottom: 10px;
}
.hit mat-card-content,
.cards mat-card-content {
display: flex;
flex-direction: column;
gap: 8px;
}
table {
width: 100%;
}
`,
],
})
export class AdminDatabaseComponent implements OnInit {
readonly sections = [
{ id: "database", label: "Backup & Restore", icon: "database" },
{ id: "users", label: "Benutzer", icon: "group" },
{ id: "entries", label: "Alle Einträge", icon: "list_alt" },
{ id: "watcher", label: "Ausbildungs-Watcher", icon: "travel_explore" },
];
section = signal("database");
restoreFile = signal<File | null>(null);
busy = signal(false);
users = signal<any[]>([]);
entries = signal<any[]>([]);
sources = signal<any[]>([]);
hits = signal<any[]>([]);
userColumns = ["email", "confirmed", "entries", "actions"];
entryColumns = ["date", "user", "art", "time", "actions"];
sourceDraft: any = { name: "", url: "", kind: "html", enabled: true };
newUser: any = { email: "", password: "", confirmed: true };
constructor(
private api: ApiService,
private snack: MatSnackBar,
router: Router,
) {
if (api.user() && !api.user()!.is_admin) router.navigate(["/dashboard"]);
}
ngOnInit() {
this.reload();
}
reload() {
this.api.adminUsers().subscribe((v) => this.users.set(v));
this.api.adminEntries().subscribe((v) => this.entries.set(v));
this.api.trainingSources().subscribe((v) => this.sources.set(v));
this.api.trainingHits().subscribe((v) => this.hits.set(v));
}
selectFile(event: Event) {
this.restoreFile.set((event.target as HTMLInputElement).files?.[0] || null);
}
backup() {
this.api.databaseBackup().subscribe((blob) => {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `praktikum-backup-${new Date().toISOString().slice(0, 10)}.zip`;
a.click();
URL.revokeObjectURL(a.href);
});
}
restore() {
const file = this.restoreFile();
if (
!file ||
!confirm(
"Aktuelle Datenbank wirklich mit dieser Sicherung überschreiben?",
)
)
return;
this.busy.set(true);
this.api.databaseRestore(file).subscribe({
next: (r) => {
this.busy.set(false);
this.snack.open(r.message, "OK");
this.reload();
},
error: (e) => {
this.busy.set(false);
this.snack.open(e.error?.error || "Restore fehlgeschlagen", "OK");
},
});
}
setConfirmed(u: any, value: boolean) {
this.api
.updateAdminUser(u.id, { confirmed: value })
.subscribe(() => this.reload());
}
createUser(){this.api.createAdminUser(this.newUser).subscribe({next:()=>{this.newUser={email:"",password:"",confirmed:true};this.reload();},error:e=>this.snack.open(e.error?.errors?.join(", ")||"Benutzer konnte nicht angelegt werden","OK")});}
removeUser(u: any) {
if (confirm(`${u.email} wirklich löschen?`))
this.api.deleteAdminUser(u.id).subscribe(() => this.reload());
}
removeEntry(e: any) {
if (confirm("Eintrag wirklich löschen?"))
this.api.deleteAdminEntry(e.id).subscribe(() => this.reload());
}
editSource(s: any) {
this.sourceDraft = { ...s };
}
saveSource() {
this.api.saveTrainingSource(this.sourceDraft).subscribe(() => {
this.sourceDraft = { name: "", url: "", kind: "html", enabled: true };
this.reload();
});
}
removeSource(s: any) {
if (confirm("Quelle und zugehörige Treffer löschen?"))
this.api.deleteTrainingSource(s.id).subscribe(() => this.reload());
}
removeHit(h: any) {
this.api.deleteTrainingHit(h.id).subscribe(() => this.reload());
}
runWatcher() {
this.api.runTrainingWatch().subscribe((r) => {
this.snack.open(`${r.new_hits} neue Treffer`, "OK");
this.reload();
});
}
}

84
frontend/src/app/pages/calculator.component.ts

@ -0,0 +1,84 @@
import { Component } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { MATERIAL } from "../shared/material";
@Component({
standalone: true,
imports: [FormsModule, ...MATERIAL],
template: `<section class="page narrow">
<div class="page-head">
<div>
<h1>Minutenrechner</h1>
<p class="muted">Beliebig viele Zeitwerte addieren</p>
</div>
</div>
<mat-card
><mat-card-content
><mat-form-field appearance="outline" class="full"
><mat-label>Minutenwerte</mat-label
><textarea
matInput
rows="6"
[(ngModel)]="input"
placeholder="45, 60, 30 oder jeweils eine Zeile"
></textarea
><mat-hint
>Trennung durch Komma, Leerzeichen, Semikolon oder
Zeilenumbruch</mat-hint
></mat-form-field
>
<div class="result">
<mat-icon>schedule</mat-icon>
<div>
<span>Gesamtdauer</span
><strong>{{ hours() }} h {{ minutes() }} min</strong
><small>{{ total() }} Minuten</small>
</div>
</div>
<button mat-stroked-button (click)="input = ''">
Zurücksetzen
</button></mat-card-content
></mat-card
>
</section>`,
styles: [
`
.narrow {
max-width: 760px;
}
.result {
display: flex;
gap: 18px;
align-items: center;
padding: 24px;
background: #e8f0ff;
border-radius: 16px;
margin: 20px 0;
}
.result mat-icon {
font-size: 36px;
width: 38px;
height: 38px;
}
.result div {
display: flex;
flex-direction: column;
}
.result strong {
font-size: 2rem;
margin: 4px 0;
}
`,
],
})
export class CalculatorComponent {
input = "";
total(): number {
return this.input
.split(/[\s,;]+/)
.map(Number)
.filter(Number.isFinite)
.reduce((a, b) => a + b, 0);
}
hours(): number { return Math.floor(this.total() / 60); }
minutes(): number { return this.total() % 60; }
}

238
frontend/src/app/pages/calendar.component.ts

@ -0,0 +1,238 @@
import { Component, OnInit, signal } from "@angular/core";
import { RouterLink } from "@angular/router";
import { FormsModule } from "@angular/forms";
import { MATERIAL } from "../shared/material";
import { ApiService } from "../core/api.service";
import { Entry } from "../core/models";
@Component({
standalone: true,
imports: [RouterLink, FormsModule, ...MATERIAL],
template: `<section class="page">
<div class="page-head">
<div>
<h1>Kalender</h1>
<p class="muted">{{ periodLabel() }}</p>
</div>
<div class="actions">
<button mat-stroked-button [class.selected]="mode()==='month'" (click)="setMode('month')">Monat</button>
<button mat-stroked-button [class.selected]="mode()==='week'" (click)="setMode('week')">Woche</button>
<button mat-icon-button (click)="move(-1)">
<mat-icon>chevron_left</mat-icon></button
><button mat-stroked-button (click)="today()">Heute</button
><button mat-icon-button (click)="move(1)">
<mat-icon>chevron_right</mat-icon>
</button>
</div>
</div>
<mat-card class="calendar-filters"><mat-card-content>
<mat-form-field appearance="outline"><mat-label>Ausbildung</mat-label><mat-select [(ngModel)]="typeFilter">
<mat-option value="">Alle</mat-option>
@for (type of types; track type) { <mat-option [value]="type">{{ type }}</mat-option> }
</mat-select></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Art</mat-label><mat-select [(ngModel)]="artFilter">
<mat-option value="">Alle</mat-option>
@for (art of arts; track art) { <mat-option [value]="art">{{ art }}</mat-option> }
</mat-select></mat-form-field>
<button mat-button (click)="clearFilters()">Zurücksetzen</button>
</mat-card-content></mat-card>
@if (loading()) {
<mat-progress-bar mode="indeterminate" />
}
<mat-card class="calendar"
><div class="weekdays">
@for (d of weekdays; track d) {
<strong>{{ d }}</strong>
}
</div>
<div class="days">
@for (day of days(); track day.key) {
<div
class="day"
[class.outside]="!day.current"
[class.today]="day.key === todayKey"
>
<div class="day-head">
<span>{{ day.date.getDate() }}</span
><a
mat-icon-button
[routerLink]="['/entries/new']"
[queryParams]="{ date: day.key }"
><mat-icon>add</mat-icon></a
>
</div>
@for (e of forDay(day.key); track e.id) {
<a class="event" [class]="'event ' + typeClass(e.praktikums_typ)" [routerLink]="['/entries', e.id, 'edit']"
><strong>{{ e.entry_art }}</strong
><span>{{ e.hours }}h {{ e.minutes }}min</span></a
>
}
</div>
}
</div></mat-card
>
</section>`,
styles: [
`
.calendar {
overflow: hidden;
}
.calendar-filters { margin-bottom:18px; }
.calendar-filters mat-card-content { display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding-bottom:0; }
.calendar-filters mat-form-field { min-width:220px; }
.selected { background:#dce9ff!important; }
.weekdays,
.days {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.weekdays {
padding: 14px;
background: #edf2fb;
text-align: center;
color: #59657a;
}
.day {
min-height: 145px;
padding: 8px;
border-top: 1px solid #e1e6ef;
border-right: 1px solid #e1e6ef;
}
.day-head {
display: flex;
justify-content: space-between;
align-items: center;
}
.day-head a {
transform: scale(0.8);
}
.outside {
opacity: 0.42;
}
.today {
background: #eef5ff;
}
.today .day-head > span {
background: #155bd7;
color: white;
border-radius: 50%;
width: 28px;
height: 28px;
display: grid;
place-items: center;
}
.event {
display: flex;
flex-direction: column;
text-decoration: none;
color: inherit;
background: #dce9ff;
border-left: 3px solid #2868d8;
border-radius: 6px;
padding: 5px 7px;
margin: 4px 0;
font-size: 12px;
}
.event span {
opacity: 0.75;
margin-top: 2px;
}
.event.type-propaedeutikum { background:rgba(13,110,253,.14);border-left-color:#0d6efd; }
.event.type-fachspezifikum { background:rgba(25,135,84,.14);border-left-color:#198754; }
.event.type-mediation { background:rgba(253,126,20,.16);border-left-color:#fd7e14; }
@media (max-width: 700px) {
.weekdays strong {
font-size: 0;
}
.weekdays strong:first-letter {
font-size: 13px;
}
.day {
min-height: 90px;
padding: 4px;
}
.event strong {
overflow: hidden;
text-overflow: ellipsis;
}
.event span {
display: none;
}
}
`,
],
})
export class CalendarComponent implements OnInit {
readonly month = signal(
new Date(new Date().getFullYear(), new Date().getMonth(), 1),
);
readonly weekAnchor = signal(new Date());
readonly mode = signal<"month"|"week">("month");
readonly entries = signal<Entry[]>([]);
readonly loading = signal(false);
readonly weekdays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
readonly types = ["propädeutikum", "fachspezifikum", "mediation"];
readonly arts = ["Praktikum", "Selbsterfahrung", "Supervision", "Fortbildung", "Semesterkosten", "Gruppenselbsterfahrung", "Theorie/Methodikseminare", "Peergruppensupervision", "Einzel-/Kleingruppensupervision", "Eigenständige Tätigkeit", "Präsenzmodul", "Peergruppenarbeit", "Fallarbeit", "Praxisseminare", "Literatur- und Selbststudium"];
typeFilter = "";
artFilter = "";
readonly todayKey = this.key(new Date());
constructor(private api: ApiService) {}
ngOnInit() {
this.load();
}
days() {
if(this.mode()==="week"){
const start=new Date(this.weekAnchor());
start.setDate(start.getDate()-((start.getDay()+6)%7));
return Array.from({length:7},(_,i)=>{const date=new Date(start);date.setDate(start.getDate()+i);return{date,key:this.key(date),current:true};});
}
const first = this.month(),
start = new Date(first);
const offset = (start.getDay() + 6) % 7;
start.setDate(start.getDate() - offset);
return Array.from({ length: 42 }, (_, i) => {
const date = new Date(start);
date.setDate(start.getDate() + i);
return {
date,
key: this.key(date),
current: date.getMonth() === first.getMonth(),
};
});
}
forDay(key: string) {
return this.entries().filter((e) => e.date === key && (!this.typeFilter || e.praktikums_typ === this.typeFilter) && (!this.artFilter || e.entry_art === this.artFilter));
}
clearFilters(){this.typeFilter="";this.artFilter="";}
typeClass(value:string){
const normalized=value?.toLocaleLowerCase("de-AT").normalize("NFD").replace(/[\u0300-\u036f]/g,"").replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");
return `type-${normalized === "propadeutikum" ? "propaedeutikum" : normalized || "unknown"}`;
}
move(n: number) {
if(this.mode()==="week"){const d=new Date(this.weekAnchor());d.setDate(d.getDate()+n*7);this.weekAnchor.set(d);this.load();return;}
const d = this.month();
this.month.set(new Date(d.getFullYear(), d.getMonth() + n, 1));
this.load();
}
today() {
const d = new Date();
this.month.set(new Date(d.getFullYear(), d.getMonth(), 1));
this.weekAnchor.set(d);
this.load();
}
setMode(mode:"month"|"week"){this.mode.set(mode);this.load();}
periodLabel(){if(this.mode()==="month")return new Intl.DateTimeFormat("de-AT",{month:"long",year:"numeric"}).format(this.month());const ds=this.days();return `${new Intl.DateTimeFormat("de-AT",{day:"2-digit",month:"2-digit"}).format(ds[0].date)} – ${new Intl.DateTimeFormat("de-AT",{day:"2-digit",month:"2-digit",year:"numeric"}).format(ds[6].date)}`;}
load() {
const ds = this.days();
this.loading.set(true);
this.api.calendar(ds[0].key, ds[ds.length-1].key).subscribe({
next: (e) => {
this.entries.set(e);
this.loading.set(false);
},
error: () => this.loading.set(false),
});
}
private key(d: Date) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
}

3
frontend/src/app/pages/confirm-email.component.ts

@ -0,0 +1,3 @@
import { Component, inject, signal } from '@angular/core'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { ApiService } from '../core/api.service'; import { MATERIAL } from '../shared/material'; import { authStyles } from './register.component';
@Component({standalone:true,imports:[RouterLink,...MATERIAL],template:`<main class="auth-page"><mat-card><mat-card-content><mat-icon class="hero-icon">mark_email_read</mat-icon><h1>E-Mail bestätigen</h1>@if(loading()){<mat-progress-bar mode="indeterminate"/>} @else if(error()){<p class="error">{{error()}}</p>} @else {<div class="success">{{message()}}</div>}<a mat-button routerLink="/login">Zur Anmeldung</a></mat-card-content></mat-card></main>`,styles:[authStyles()]})
export class ConfirmEmailComponent{loading=signal(true);error=signal('');message=signal('');private token=inject(ActivatedRoute).snapshot.queryParamMap.get('token')||'';constructor(api:ApiService){if(!this.token){this.loading.set(false);this.error.set('Der Bestätigungslink ist ungültig.');}else api.confirmEmail(this.token).subscribe({next:r=>{this.loading.set(false);this.message.set(r.message);},error:e=>{this.loading.set(false);this.error.set(e.error?.errors?.join(', ')||'Bestätigung fehlgeschlagen');}});}}

232
frontend/src/app/pages/dashboard.component.ts

@ -0,0 +1,232 @@
import { Component, OnInit, signal } from "@angular/core";
import { RouterLink } from "@angular/router";
import { DatePipe, DecimalPipe } from "@angular/common";
import { FormsModule } from "@angular/forms";
import { MATERIAL } from "../shared/material";
import { ApiService } from "../core/api.service";
import { Dashboard } from "../core/models";
@Component({
standalone: true,
imports: [RouterLink, FormsModule, DatePipe, DecimalPipe, ...MATERIAL],
template: `<section class="page">
<div class="page-head">
<div>
<h1>Dashboard</h1>
<p class="muted">Dein Ausbildungsfortschritt auf einen Blick</p>
</div>
<a mat-flat-button routerLink="/entries/new"
><mat-icon>add</mat-icon> Neuer Eintrag</a
>
</div>
@if (loading()) {
<mat-progress-bar mode="indeterminate" />
} @else if (data(); as d) {
<div class="grid cards">
<mat-card
><mat-card-content
><mat-icon>schedule</mat-icon><span>Geleistete Zeit</span
><strong>{{ duration(d.total_minutes) }}</strong></mat-card-content
></mat-card
><mat-card
><mat-card-content
><mat-icon>flag</mat-icon><span>Gesamtfortschritt</span
><strong>{{ d.completed_percent | number: "1.0-1" }} %</strong
><mat-progress-bar
[value]="d.completed_percent" /></mat-card-content></mat-card
><mat-card
><mat-card-content
><mat-icon>route</mat-icon><span>Gefahrene Kilometer</span
><strong
>{{ d.total_distance_km | number: "1.0-0" }} km</strong
></mat-card-content
></mat-card
><mat-card
><mat-card-content
><mat-icon>hourglass_bottom</mat-icon><span>Noch offen</span
><strong>{{
duration(d.remaining_minutes)
}}</strong></mat-card-content
></mat-card
>
</div>
<mat-card class="progress-card"
><mat-card-header
><mat-card-title
>Fortschritt nach Ausbildung</mat-card-title
></mat-card-header
><mat-card-content
><div class="progress-options"><mat-checkbox [(ngModel)]="showZero">0 % anzeigen</mat-checkbox><mat-checkbox [(ngModel)]="showCompleted">Erledigte anzeigen</mat-checkbox></div><div class="progress-grid">
@for (row of active(d); track row.typ + row.art) {
<div class="progress-row" [class]="'progress-row ' + typeClass(row.typ)">
<div>
<strong>{{ row.art }}</strong
><small
>{{ label(row.typ) }} · {{ duration(row.spent_minutes) }} /
{{ duration(row.target_minutes) }}</small
>
<small
>Ist: {{ row.actual_weekly ?? 0 }} h/Woche · Ziel:
{{ row.weekly_target }} h/Woche</small
>
@if (row.estimated_end) {
<small
>Voraussichtlich fertig:
{{ row.estimated_end | date: "dd.MM.yyyy" }}</small
>
}
</div>
<span>{{ row.percent }} %</span
><mat-progress-bar [value]="row.percent" />
</div>
}</div></mat-card-content
></mat-card>
<mat-card class="progress-card"
><mat-card-header
><mat-card-title>Kosten nach Jahr</mat-card-title></mat-card-header
><mat-card-content
><div class="cost-grid">
@for (cost of d.costs_by_year; track cost.year) {
<div class="cost-year">
<strong>{{ cost.year }}</strong
><span
>Fahrtkosten
<b>{{ cost.kilometer | number: "1.2-2" }} €</b></span
><span
>Fortbildung
<b>{{ cost.fortbildung | number: "1.2-2" }} €</b></span
><span
>Selbsterfahrung
<b>{{ cost.selbsterfahrung | number: "1.2-2" }} €</b></span
><span
>Supervision
<b>{{ cost.supervision | number: "1.2-2" }} €</b></span
><span
>Semesterkosten
<b>{{ cost.semester | number: "1.2-2" }} €</b></span
><span class="total"
>Gesamt <b>{{ cost.total | number: "1.2-2" }} €</b></span
>
</div>
}
</div></mat-card-content
></mat-card
>
<mat-card class="progress-card"
><mat-card-content
><strong>Mediation – Präsenztage</strong
><span
>{{ d.mediation_presence_days }} von
{{ d.mediation_presence_days_required }} Tagen erledigt</span
><mat-progress-bar
[value]="
(d.mediation_presence_days / d.mediation_presence_days_required) *
100
" /></mat-card-content
></mat-card>
}
</section>`,
styles: [
`
mat-card-content {
display: flex;
flex-direction: column;
gap: 9px;
}
mat-card-content > mat-icon {
color: #155bd7;
background: #e7efff;
border-radius: 12px;
padding: 9px;
width: 42px;
height: 42px;
}
mat-card-content > strong {
font-size: 1.65rem;
}
.progress-card {
margin-top: 22px;
padding: 10px;
}
.progress-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 22px;
margin-top: 20px;
}
.progress-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
padding:14px;
border-radius:12px;
border-left:4px solid transparent;
}
.progress-options { display:flex;gap:18px;flex-wrap:wrap; }
.progress-row.type-propaedeutikum { background:rgba(13,110,253,.08);border-left-color:#0d6efd; }
.progress-row.type-fachspezifikum { background:rgba(25,135,84,.08);border-left-color:#198754; }
.progress-row.type-mediation { background:rgba(253,126,20,.09);border-left-color:#fd7e14; }
.progress-row div {
display: flex;
flex-direction: column;
}
.progress-row small {
color: #687287;
margin-top: 4px;
}
.progress-row mat-progress-bar {
grid-column: 1/-1;
}
.cost-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 18px;
margin-top: 16px;
}
.cost-year {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
background: #f0f4fa;
border-radius: 14px;
}
.cost-year span {
display: flex;
justify-content: space-between;
}
.cost-year > strong {
font-size: 1.3rem;
}
.cost-year .total {
border-top: 1px solid #ccd4e2;
padding-top: 9px;
}
`,
],
})
export class DashboardComponent implements OnInit {
readonly data = signal<Dashboard | null>(null);
readonly loading = signal(true);
showZero = false;
showCompleted = false;
constructor(private api: ApiService) {}
ngOnInit() {
this.api.dashboard().subscribe({
next: (d) => {
this.data.set(d);
this.loading.set(false);
},
error: () => this.loading.set(false),
});
}
duration(m: number) {
return `${Math.floor(m / 60)} h ${m % 60} min`;
}
label(v: string) {
return v.charAt(0).toUpperCase() + v.slice(1);
}
active(d: Dashboard) {
return d.progress.filter((r) => r.target_minutes > 0 && (this.showZero || r.percent > 0) && (this.showCompleted || r.percent < 100));
}
typeClass(value:string){return value === "propädeutikum" ? "type-propaedeutikum" : `type-${value}`;}
}

331
frontend/src/app/pages/entries.component.ts

@ -0,0 +1,331 @@
import { Component, OnInit, signal } from "@angular/core";
import { DatePipe, DecimalPipe } from "@angular/common";
import { RouterLink } from "@angular/router";
import { FormsModule } from "@angular/forms";
import { MATERIAL } from "../shared/material";
import { ApiService } from "../core/api.service";
import { Dashboard, Entry } from "../core/models";
@Component({
standalone: true,
imports: [RouterLink, FormsModule, DatePipe, DecimalPipe, ...MATERIAL],
template: `<section class="page entries-page">
<div class="page-head">
<div>
<h1>Einträge</h1>
<p class="muted">Stunden, Kosten und Fahrten verwalten</p>
</div>
<div class="actions">
<button mat-stroked-button (click)="download()">
<mat-icon>download</mat-icon> CSV</button
><a mat-flat-button routerLink="/entries/new"
><mat-icon>add</mat-icon> Neuer Eintrag</a
>
</div>
</div>
<mat-card class="timer-card">
<mat-card-content>
@if (running(); as timer) {
<div><mat-icon>timer</mat-icon><span>Timer läuft</span><strong>{{ elapsed(timer) }}</strong><small>{{ timer.praktikums_typ }} · {{ timer.entry_art }}</small></div>
<mat-checkbox [(ngModel)]="lunchBreak">30 Minuten Pause abziehen</mat-checkbox>
<button class="timer-stop" mat-flat-button color="warn" (click)="stopTimer(timer)"><mat-icon>stop</mat-icon> Timer stoppen</button>
} @else {
<div><mat-icon>timer</mat-icon><span>Zeiterfassung</span><strong>Timer starten</strong></div>
<mat-form-field appearance="outline"><mat-label>Ausbildung</mat-label><mat-select [(ngModel)]="timerType">@for(t of types;track t){<mat-option [value]="t">{{t}}</mat-option>}</mat-select></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Art</mat-label><mat-select [(ngModel)]="timerArt">@for(a of timerArts();track a){<mat-option [value]="a">{{a}}</mat-option>}</mat-select></mat-form-field>
<button class="timer-start" mat-flat-button (click)="startTimer()"><mat-icon>play_arrow</mat-icon> Timer starten</button>
}
</mat-card-content>
</mat-card>
<mat-card class="filters"
><mat-card-content
><mat-form-field appearance="outline"
><mat-label>Suche</mat-label
><input
matInput
[(ngModel)]="search"
(keyup.enter)="load()"
/><mat-icon matSuffix>search</mat-icon></mat-form-field
><mat-form-field appearance="outline"
><mat-label>Ausbildung</mat-label
><mat-select [(ngModel)]="typ" (selectionChange)="load()"
><mat-option value="">Alle</mat-option>
@for (t of types; track t) {
<mat-option [value]="t">{{ t }}</mat-option>
}
</mat-select></mat-form-field
><mat-form-field appearance="outline"><mat-label>Von Jahr</mat-label><mat-select [(ngModel)]="minYear"><mat-option value="">Alle</mat-option>@for(y of years();track y){<mat-option [value]="y">{{y}}</mat-option>}</mat-select></mat-form-field
><mat-form-field appearance="outline"><mat-label>Bis Jahr</mat-label><mat-select [(ngModel)]="maxYear"><mat-option value="">Alle</mat-option>@for(y of years();track y){<mat-option [value]="y">{{y}}</mat-option>}</mat-select></mat-form-field
><button mat-button (click)="reset()">
Zurücksetzen
</button></mat-card-content
></mat-card
>
@if (loading()) {
<mat-progress-bar mode="indeterminate" />
}
<mat-card class="table-card"
><div class="table-wrap">
<table mat-table [dataSource]="visibleEntries()">
<ng-container matColumnDef="date"
><th mat-header-cell *matHeaderCellDef>Datum</th>
<td mat-cell *matCellDef="let e">
{{ e.date | date: "dd.MM.yyyy" }}
</td></ng-container
><ng-container matColumnDef="type"
><th mat-header-cell *matHeaderCellDef>Ausbildung</th>
<td mat-cell *matCellDef="let e">
<strong>{{ e.praktikums_typ }}</strong>
</td></ng-container
><ng-container matColumnDef="art"><th mat-header-cell *matHeaderCellDef>Art</th><td mat-cell *matCellDef="let e">{{e.entry_art}}</td></ng-container
><ng-container matColumnDef="time"
><th mat-header-cell *matHeaderCellDef>Zeit</th>
<td mat-cell *matCellDef="let e">
{{ e.hours }} h {{ e.minutes }} min
</td></ng-container
><ng-container matColumnDef="details"
><th mat-header-cell *matHeaderCellDef>Details</th>
<td mat-cell *matCellDef="let e">
<span>{{ e.beschreibung || "–" }}</span>
</td></ng-container
><ng-container matColumnDef="distance"><th mat-header-cell *matHeaderCellDef>Kilometer</th><td mat-cell *matCellDef="let e">{{e.distance_km || 0 | number:"1.0-2"}} km</td></ng-container
><ng-container matColumnDef="allowance"><th mat-header-cell *matHeaderCellDef>Pauschale</th><td mat-cell *matCellDef="let e">{{e.kilometer_pauschale || 0 | number:"1.2-2"}} €</td></ng-container
><ng-container matColumnDef="cost"><th mat-header-cell *matHeaderCellDef>Kosten</th><td mat-cell *matCellDef="let e">{{e.kosten || 0 | number:"1.2-2"}} €</td></ng-container
><ng-container matColumnDef="training"><th mat-header-cell *matHeaderCellDef>Fortbildung</th><td mat-cell *matCellDef="let e"><mat-icon class="training-state" [class.is-active]="e.zaehlt_als_fortbildung">{{e.zaehlt_als_fortbildung ? "check_circle" : "remove"}}</mat-icon></td></ng-container
><ng-container matColumnDef="actions"
><th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let e">
<div class="entry-actions">
<a class="edit-action" mat-icon-button matTooltip="Bearbeiten" aria-label="Eintrag bearbeiten" [routerLink]="['/entries', e.id, 'edit']"
><mat-icon>edit</mat-icon></a
><button class="delete-action" mat-icon-button matTooltip="Löschen" aria-label="Eintrag löschen" (click)="remove(e)">
<mat-icon>delete</mat-icon>
</button>
</div>
</td></ng-container
>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr
mat-row
*matRowDef="let row; columns: columns"
[class]="entryRowClass(row)"
></tr>
</table>
@if (!loading() && !entries().length) {
<div class="empty">
<mat-icon>event_busy</mat-icon>
<p>Noch keine passenden Einträge vorhanden.</p>
</div>
}
</div><div class="entry-count">Angezeigt: {{visibleEntries().length}} · Gesamt: {{entries().length}}</div></mat-card
>
</section>`,
styles: [
`
.filters {
margin-bottom: 20px;
}
.entries-page {
max-width: 1680px;
}
.timer-card { margin-bottom: 20px; }
.timer-card mat-card-content { display:flex;align-items:flex-start;gap:16px;flex-wrap:wrap; }
.timer-card mat-card-content > div { display:grid;grid-template-columns:auto 1fr;gap:3px 10px;margin-right:auto; }
.timer-card mat-card-content > div > mat-icon {
grid-row:1/4;
align-self:center;
display:grid;
place-items:center;
width:40px;
height:40px;
border-radius:12px;
color:var(--app-primary);
background:color-mix(in srgb,var(--app-primary) 13%,transparent);
}
.timer-card button mat-icon { color:inherit; }
.timer-card mat-card-content > button,
.filters mat-card-content > button {
align-self:flex-start;
min-height:48px;
margin-top:4px;
}
.timer-start mat-icon, .timer-stop mat-icon { opacity:1; }
.timer-card strong { font-size:1.25rem; }
.filters mat-card-content {
display: flex;
gap: 12px;
align-items: flex-start;
flex-wrap: wrap;
}
.filters mat-form-field {
min-width: 220px;
}
.table-card {
padding: 0;
overflow: hidden;
}
.entry-count { padding:12px 18px;color:#687287;border-top:1px solid rgba(120,130,145,.2);font-size:.85rem; }
.training-state { color:var(--app-muted);vertical-align:middle; }
.training-state.is-active { color:#198754; }
.mat-column-actions { width:108px;min-width:108px;white-space:nowrap; }
.mat-column-time { width:128px;min-width:128px;white-space:nowrap; }
.entry-actions { display:flex;align-items:center;justify-content:flex-end;gap:8px;white-space:nowrap; }
.entry-actions .mat-mdc-icon-button { flex:0 0 40px;width:40px;height:40px;padding:8px;border-radius:10px; }
.edit-action { color:#0d6efd;background:rgba(13,110,253,.11); }
.delete-action { color:#c62828;background:rgba(198,40,40,.11); }
.edit-action:hover { background:rgba(13,110,253,.19); }
.delete-action:hover { background:rgba(198,40,40,.19); }
td strong,
td small {
display: block;
}
td small {
color: #687287;
margin-top: 4px;
}
th {
font-weight: 700;
}
tr.mat-mdc-row > td:first-child {
border-left: 4px solid transparent;
}
tr.mat-mdc-row.type-propaedeutikum:nth-of-type(odd) > td {
background-color: rgba(13, 110, 253, 0.055);
}
tr.mat-mdc-row.type-propaedeutikum:nth-of-type(even) > td {
background-color: rgba(13, 110, 253, 0.115);
}
tr.mat-mdc-row.type-fachspezifikum:nth-of-type(odd) > td {
background-color: rgba(25, 135, 84, 0.055);
}
tr.mat-mdc-row.type-fachspezifikum:nth-of-type(even) > td {
background-color: rgba(25, 135, 84, 0.115);
}
tr.mat-mdc-row.type-mediation:nth-of-type(odd) > td {
background-color: rgba(253, 126, 20, 0.065);
}
tr.mat-mdc-row.type-mediation:nth-of-type(even) > td {
background-color: rgba(253, 126, 20, 0.135);
}
tr.mat-mdc-row.type-propaedeutikum:hover > td {
background-color: rgba(13, 110, 253, 0.17);
}
tr.mat-mdc-row.type-fachspezifikum:hover > td {
background-color: rgba(25, 135, 84, 0.17);
}
tr.mat-mdc-row.type-mediation:hover > td {
background-color: rgba(253, 126, 20, 0.19);
}
tr.mat-mdc-row.type-propaedeutikum > td:first-child { border-left-color: #0d6efd; }
tr.mat-mdc-row.type-fachspezifikum > td:first-child { border-left-color: #198754; }
tr.mat-mdc-row.type-mediation > td:first-child { border-left-color: #fd7e14; }
tr.mat-mdc-row.entry-today {
font-weight: 700;
}
tr.mat-mdc-row.entry-today > td {
box-shadow: inset 0 3px 0 #7c3aed, inset 0 -3px 0 #7c3aed;
}
tr.mat-mdc-row.entry-today > td:first-child {
border-left:6px solid #7c3aed;
}
tr.mat-mdc-row.entry-today > td:last-child {
border-right:3px solid #7c3aed;
}
:host-context(body.dark) tr.mat-mdc-row.type-propaedeutikum:nth-of-type(odd) > td { background-color: rgba(62, 139, 255, 0.12); }
:host-context(body.dark) tr.mat-mdc-row.type-propaedeutikum:nth-of-type(even) > td { background-color: rgba(62, 139, 255, 0.18); }
:host-context(body.dark) tr.mat-mdc-row.type-fachspezifikum:nth-of-type(odd) > td { background-color: rgba(53, 184, 116, 0.12); }
:host-context(body.dark) tr.mat-mdc-row.type-fachspezifikum:nth-of-type(even) > td { background-color: rgba(53, 184, 116, 0.18); }
:host-context(body.dark) tr.mat-mdc-row.type-mediation:nth-of-type(odd) > td { background-color: rgba(255, 143, 51, 0.13); }
:host-context(body.dark) tr.mat-mdc-row.type-mediation:nth-of-type(even) > td { background-color: rgba(255, 143, 51, 0.2); }
:host-context(body.dark) tr.mat-mdc-row.type-propaedeutikum:hover > td { background-color: rgba(62, 139, 255, 0.25); }
:host-context(body.dark) tr.mat-mdc-row.type-fachspezifikum:hover > td { background-color: rgba(53, 184, 116, 0.25); }
:host-context(body.dark) tr.mat-mdc-row.type-mediation:hover > td { background-color: rgba(255, 143, 51, 0.27); }
:host-context(body.dark) tr.mat-mdc-row.entry-today > td {
box-shadow: inset 0 3px 0 #b794f4, inset 0 -3px 0 #b794f4;
}
:host-context(body.dark) tr.mat-mdc-row.entry-today > td:first-child { border-left-color:#b794f4; }
:host-context(body.dark) tr.mat-mdc-row.entry-today > td:last-child { border-right-color:#b794f4; }
:host-context(body.dark) .edit-action { color:#8bb7ff;background:rgba(62,139,255,.2); }
:host-context(body.dark) .delete-action { color:#ff9b9b;background:rgba(255,90,90,.18); }
@media (max-width: 700px) {
.filters mat-form-field {
width: 100%;
}
.mat-column-details {
display: none;
}
}
`,
],
})
export class EntriesComponent implements OnInit {
readonly entries = signal<Entry[]>([]);
readonly loading = signal(false);
readonly columns = ["date", "time", "type", "art", "details", "distance", "allowance", "cost", "training", "actions"];
readonly types = ["propädeutikum", "fachspezifikum", "mediation"];
readonly arts: Record<string,string[]> = {propädeutikum:["Praktikum","Selbsterfahrung","Supervision","Fortbildung","Semesterkosten"],fachspezifikum:["Praktikum","Selbsterfahrung","Supervision","Fortbildung","Semesterkosten","Gruppenselbsterfahrung","Theorie/Methodikseminare","Peergruppensupervision","Einzel-/Kleingruppensupervision","Eigenständige Tätigkeit"],mediation:["Präsenzmodul","Selbsterfahrung","Supervision","Peergruppenarbeit","Fallarbeit","Praxisseminare","Literatur- und Selbststudium","Fortbildung","Semesterkosten"]};
readonly running=signal<Entry|null>(null);
readonly clock=signal(Date.now());
timerType="propädeutikum"; timerArt="Praktikum"; lunchBreak=false;
search = "";
typ = "";
minYear: number | "" = "";
maxYear: number | "" = "";
constructor(private api: ApiService) {}
ngOnInit() {
this.load();
setInterval(()=>this.clock.set(Date.now()),30_000);
}
load() {
this.loading.set(true);
this.api.entries({ search: this.search, typ: this.typ }).subscribe({
next: (v) => {
this.entries.set(v);
this.loading.set(false);
},
error: () => this.loading.set(false),
});
this.api.dashboard().subscribe((d:Dashboard)=>this.running.set(d.running_entry));
}
timerArts(){const available=this.arts[this.timerType]||[];if(!available.includes(this.timerArt))this.timerArt=available[0];return available;}
startTimer(){this.api.startTimer(this.timerType,this.timerArt).subscribe(e=>{this.running.set(e);this.load();});}
stopTimer(entry:Entry){this.api.stopTimer(entry.id,this.lunchBreak).subscribe(()=>{this.running.set(null);this.lunchBreak=false;this.load();});}
elapsed(entry:Entry){this.clock();if(!entry.start_time)return "0 h 0 min";const minutes=Math.max(0,Math.floor((Date.now()-new Date(entry.start_time).getTime())/60000));return `${Math.floor(minutes/60)} h ${minutes%60} min`;}
reset() {
this.search = "";
this.typ = "";
this.minYear = "";
this.maxYear = "";
this.load();
}
years(){return [...new Set(this.entries().map(e=>Number(e.date.slice(0,4))))].sort((a,b)=>b-a);}
visibleEntries(){return this.entries().filter(e=>{const year=Number(e.date.slice(0,4));return(!this.minYear||year>=this.minYear)&&(!this.maxYear||year<=this.maxYear);});}
entryRowClass(entry: Entry): string {
const normalized = entry.praktikums_typ
?.toLocaleLowerCase("de-AT")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
const type = normalized === "propadeutikum"
? "propaedeutikum"
: normalized?.replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
const classes = [`type-${type || "unknown"}`];
const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
if (entry.date === today) {
classes.push("entry-today");
}
return classes.join(" ");
}
remove(e: Entry) {
if (confirm(`Eintrag vom ${e.date} wirklich löschen?`))
this.api.deleteEntry(e.id).subscribe(() => this.load());
}
download() {
this.api.exportCsv().subscribe((blob) => {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `eintraege-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(a.href);
});
}
}

346
frontend/src/app/pages/entry-form.component.ts

@ -0,0 +1,346 @@
import { Component, inject, OnInit, signal } from "@angular/core";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import { ActivatedRoute, Router, RouterLink } from "@angular/router";
import { MatSnackBar } from "@angular/material/snack-bar";
import {
DateAdapter,
MAT_DATE_FORMATS,
MAT_DATE_LOCALE,
} from "@angular/material/core";
import { MatTimepickerModule } from "@angular/material/timepicker";
import { MATERIAL } from "../shared/material";
import { ApiService } from "../core/api.service";
import {
AUSTRIAN_DATE_FORMATS,
AustrianDateAdapter,
} from "../core/austrian-date-adapter";
@Component({
standalone: true,
imports: [ReactiveFormsModule, RouterLink, MatTimepickerModule, ...MATERIAL],
providers: [
{ provide: MAT_DATE_LOCALE, useValue: "de-AT" },
{ provide: MAT_DATE_FORMATS, useValue: AUSTRIAN_DATE_FORMATS },
{ provide: DateAdapter, useClass: AustrianDateAdapter },
],
template: `<section class="page narrow">
<div class="page-head">
<div>
<h1>{{ id ? "Eintrag bearbeiten" : "Neuer Eintrag" }}</h1>
<p class="muted">Zeit, Ausbildungsbereich und Kosten erfassen</p>
</div>
</div>
<form [formGroup]="form" (ngSubmit)="save()">
<mat-card
><mat-card-header><mat-card-title>Zeit</mat-card-title></mat-card-header
><mat-card-content class="form-grid"
><mat-form-field appearance="outline"
><mat-label>Datum</mat-label
><input
matInput
formControlName="date"
[matDatepicker]="datePicker"
/><mat-datepicker-toggle
matIconSuffix
[for]="datePicker"
aria-label="Datum auswählen"
></mat-datepicker-toggle></mat-form-field
><mat-datepicker #datePicker></mat-datepicker
><mat-form-field appearance="outline"
><mat-label>Beginn</mat-label
><input
matInput
[matTimepicker]="startTimePicker"
formControlName="start_time"
(valueChange)="calculateTime()"
/><mat-timepicker-toggle
matIconSuffix
[for]="startTimePicker"
aria-label="Beginn auswählen"
></mat-timepicker-toggle></mat-form-field
><mat-timepicker #startTimePicker interval="15m"></mat-timepicker
><mat-form-field appearance="outline"
><mat-label>Ende</mat-label
><input
matInput
[matTimepicker]="endTimePicker"
formControlName="end_time"
(valueChange)="calculateTime()"
/><mat-timepicker-toggle
matIconSuffix
[for]="endTimePicker"
aria-label="Ende auswählen"
></mat-timepicker-toggle></mat-form-field
><mat-timepicker #endTimePicker interval="15m"></mat-timepicker
><mat-form-field appearance="outline"
><mat-label>Stunden</mat-label
><input
matInput
type="number"
min="0"
formControlName="hours" /></mat-form-field
><mat-form-field appearance="outline"
><mat-label>Minuten</mat-label
><input
matInput
type="number"
min="0"
max="59"
formControlName="minutes" /></mat-form-field
><mat-checkbox formControlName="break" (change)="calculateTime()"
>30 Minuten Mittagspause abziehen</mat-checkbox
></mat-card-content
></mat-card
><mat-card
><mat-card-header
><mat-card-title>Zuordnung</mat-card-title></mat-card-header
><mat-card-content class="form-grid"
><mat-form-field appearance="outline"
><mat-label>Ausbildung</mat-label
><mat-select
formControlName="praktikums_typ"
(selectionChange)="typeChanged()"
>
@for (t of types; track t) {
<mat-option [value]="t">{{ t }}</mat-option>
}
</mat-select></mat-form-field
><mat-form-field appearance="outline"
><mat-label>Art</mat-label
><mat-select formControlName="entry_art">
@for (a of availableArts(); track a) {
<mat-option [value]="a">{{ a }}</mat-option>
}
</mat-select></mat-form-field
><mat-form-field appearance="outline" class="wide"
><mat-label>Beschreibung</mat-label
><textarea
matInput
rows="3"
formControlName="beschreibung"
></textarea></mat-form-field
><mat-form-field appearance="outline"
><mat-label>Entfernung</mat-label
><input
matInput
type="number"
min="0"
formControlName="distance_km"
/><span matTextSuffix>km</span></mat-form-field
><mat-form-field appearance="outline"
><mat-label>Kosten</mat-label
><input
matInput
type="number"
min="0"
step="0.01"
formControlName="kosten"
/><span matTextSuffix>€</span></mat-form-field
><mat-checkbox formControlName="zaehlt_als_fortbildung"
>Zählt als Fortbildung</mat-checkbox
></mat-card-content
></mat-card
>
<div class="actions footer">
<a mat-button routerLink="/entries">Abbrechen</a
><button mat-flat-button [disabled]="form.invalid || saving()">
Speichern
</button>
</div>
</form>
</section>`,
styles: [
`
.narrow {
max-width: 980px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px 18px;
padding-top: 20px !important;
}
.wide {
grid-column: 1/-1;
}
mat-card {
margin-bottom: 20px;
}
.footer {
justify-content: flex-end;
}
@media (max-width: 650px) {
.form-grid {
grid-template-columns: 1fr;
}
.wide {
grid-column: auto;
}
}
`,
],
})
export class EntryFormComponent implements OnInit {
private readonly fb = inject(FormBuilder);
id?: number;
readonly saving = signal(false);
readonly types = ["propädeutikum", "fachspezifikum", "mediation"];
readonly arts: Record<string, string[]> = {
propädeutikum: [
"Praktikum",
"Selbsterfahrung",
"Supervision",
"Fortbildung",
"Semesterkosten",
],
fachspezifikum: [
"Praktikum",
"Selbsterfahrung",
"Supervision",
"Fortbildung",
"Semesterkosten",
"Gruppenselbsterfahrung",
"Theorie/Methodikseminare",
"Peergruppensupervision",
"Einzel-/Kleingruppensupervision",
"Eigenständige Tätigkeit",
],
mediation: [
"Präsenzmodul",
"Selbsterfahrung",
"Supervision",
"Peergruppenarbeit",
"Fallarbeit",
"Praxisseminare",
"Literatur- und Selbststudium",
"Fortbildung",
"Semesterkosten",
],
};
readonly form = this.fb.nonNullable.group({
date: [this.today(), Validators.required],
start_time: [this.currentQuarter() as Date | null],
end_time: [null as Date | null],
hours: [0, [Validators.required, Validators.min(0)]],
minutes: [0, [Validators.required, Validators.min(0), Validators.max(59)]],
break: [false],
praktikums_typ: ["propädeutikum", Validators.required],
entry_art: ["Praktikum", Validators.required],
distance_km: [0, Validators.min(0)],
beschreibung: [""],
kosten: [null as number | null],
zaehlt_als_fortbildung: [false],
});
constructor(
private api: ApiService,
private route: ActivatedRoute,
private router: Router,
private snack: MatSnackBar,
) {}
ngOnInit() {
const raw = this.route.snapshot.paramMap.get("id");
const requestedDate = this.route.snapshot.queryParamMap.get("date");
if (requestedDate) {
const date = this.parseDate(requestedDate);
if (date) this.form.controls.date.setValue(date);
}
if (raw) {
this.id = Number(raw);
this.api
.entry(this.id)
.subscribe((e) =>
this.form.patchValue({
...e,
date: this.parseDate(e.date) ?? this.today(),
beschreibung: e.beschreibung ?? "",
start_time: this.parseTime(e.start_time),
end_time: this.parseTime(e.end_time),
break: e.lunch_break_minutes === 30,
}),
);
}
}
availableArts() {
return this.arts[this.form.controls.praktikums_typ.value] || [];
}
typeChanged() {
const arts = this.availableArts();
if (!arts.includes(this.form.controls.entry_art.value))
this.form.controls.entry_art.setValue(arts[0]);
}
calculateTime() {
const { start_time: s, end_time: e, break: b } = this.form.getRawValue();
if (!s || !e) return;
let mins = e.getHours() * 60 + e.getMinutes() -
(s.getHours() * 60 + s.getMinutes());
if (mins < 0) mins += 1440;
if (b) mins = Math.max(0, mins - 30);
this.form.patchValue({ hours: Math.floor(mins / 60), minutes: mins % 60 });
}
save() {
if (this.form.invalid) return;
this.saving.set(true);
const {
break: hasBreak,
date,
start_time,
end_time,
...rest
} = this.form.getRawValue();
const value = {
...rest,
date: this.formatDate(date),
start_time: this.formatTime(start_time),
end_time: this.formatTime(end_time),
};
this.api
.saveEntry({ ...value, lunch_break_minutes: hasBreak ? 30 : 0 }, this.id)
.subscribe({
next: () => {
this.snack.open("Eintrag gespeichert", "OK", { duration: 2500 });
this.router.navigate(["/entries"]);
},
error: (e) => {
this.saving.set(false);
this.snack.open(
e.error?.error ||
e.error?.errors?.join(", ") ||
"Speichern fehlgeschlagen",
"OK",
);
},
});
}
private today() {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
}
private currentQuarter() {
const now = new Date();
now.setMinutes(Math.floor(now.getMinutes() / 15) * 15, 0, 0);
return now;
}
private parseDate(value: string | null) {
if (!value) return null;
const [year, month, day] = value.slice(0, 10).split("-").map(Number);
const date = new Date(year, month - 1, day);
return Number.isNaN(date.getTime()) ? null : date;
}
private parseTime(value: string | null) {
if (!value) return null;
const parsed = new Date(value);
if (!Number.isNaN(parsed.getTime())) return parsed;
const match = value.match(/^(\d{1,2}):(\d{2})/);
if (!match) return null;
const date = this.today();
date.setHours(Number(match[1]), Number(match[2]), 0, 0);
return date;
}
private formatDate(value: Date) {
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
}
private formatTime(value: Date | null) {
return value
? `${String(value.getHours()).padStart(2, "0")}:${String(value.getMinutes()).padStart(2, "0")}`
: null;
}
}

3
frontend/src/app/pages/forgot-password.component.ts

@ -0,0 +1,3 @@
import { Component, inject, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { RouterLink } from '@angular/router'; import { ApiService } from '../core/api.service'; import { MATERIAL } from '../shared/material'; import { authStyles } from './register.component';
@Component({standalone:true,imports:[ReactiveFormsModule,RouterLink,...MATERIAL],template:`<main class="auth-page"><mat-card><mat-card-content><mat-icon class="hero-icon">lock_reset</mat-icon><h1>Passwort zurücksetzen</h1><p class="muted">Wir senden dir einen Link zum Festlegen eines neuen Passworts.</p>@if(message()){<div class="success">{{message()}}</div>} @else {<form [formGroup]="form" (ngSubmit)="submit()"><mat-form-field appearance="outline" class="full"><mat-label>E-Mail</mat-label><input matInput type="email" formControlName="email"></mat-form-field><button mat-flat-button class="full" [disabled]="form.invalid||loading()">Link anfordern</button></form>}<a mat-button routerLink="/login">Zur Anmeldung</a></mat-card-content></mat-card></main>`,styles:[authStyles()]})
export class ForgotPasswordComponent{private fb=inject(FormBuilder);loading=signal(false);message=signal('');form=this.fb.nonNullable.group({email:['',[Validators.required,Validators.email]]});constructor(private api:ApiService){}submit(){this.loading.set(true);this.api.forgotPassword(this.form.getRawValue().email).subscribe(r=>this.message.set(r.message));}}

2
frontend/src/app/pages/impressum.component.ts

@ -0,0 +1,2 @@
import { Component } from '@angular/core'; import { RouterLink } from '@angular/router'; import { MATERIAL } from '../shared/material';
@Component({standalone:true,imports:[RouterLink,...MATERIAL],template:`<section class="page narrow"><mat-card><mat-card-content><h1>Impressum</h1><p>Informationen gemäß § 5 ECG und § 25 MedienG.</p><p><strong>Christoph Marzell</strong><br>Furth an der Triesting, Österreich</p><p>Kontakt: <a href="mailto:christoph@marzell.net">christoph@marzell.net</a></p><a mat-button routerLink="/login">Zurück</a></mat-card-content></mat-card></section>`})export class ImpressumComponent{}

144
frontend/src/app/pages/login.component.ts

@ -0,0 +1,144 @@
import { Component, inject, signal } from "@angular/core";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import { Router, RouterLink } from "@angular/router";
import { MATERIAL } from "../shared/material";
import { ApiService } from "../core/api.service";
@Component({
standalone: true,
imports: [ReactiveFormsModule, RouterLink, ...MATERIAL],
template: `<main class="login">
<mat-card
><mat-card-content
><div class="logo">A</div>
<h1>Willkommen zurück</h1>
<p class="muted">Ausbildungsstunden übersichtlich dokumentieren.</p>
<form [formGroup]="form" (ngSubmit)="submit()">
<mat-form-field appearance="outline" class="full"
><mat-label>E-Mail</mat-label
><input
matInput
type="email"
formControlName="email"
autocomplete="email"
/><mat-icon matSuffix>mail</mat-icon></mat-form-field
><mat-form-field appearance="outline" class="full"
><mat-label>Passwort</mat-label
><input
matInput
type="password"
formControlName="password"
autocomplete="current-password"
/><mat-icon matSuffix>lock</mat-icon></mat-form-field
>
@if (error()) {
<p class="error">{{ error() }}</p>
}
<button
mat-flat-button
class="full"
[disabled]="form.invalid || loading()"
>
@if (loading()) {
<mat-spinner diameter="20" />
} @else {
Anmelden
}
</button>
<div class="auth-links">
<a routerLink="/forgot-password">Passwort vergessen?</a>
<a routerLink="/register">Konto registrieren</a>
</div>
<div class="legal"><a routerLink="/resend-confirmation">Bestätigungslink erneut senden</a></div>
<div class="legal"><a routerLink="/impressum">Impressum</a></div>
</form></mat-card-content
></mat-card
>
</main>`,
styles: [
`
.login {
min-height: 100%;
display: grid;
place-items: center;
padding: 20px;
background:
radial-gradient(circle at top left, #d9e9ff, transparent 45%), #f5f7fb;
}
.login mat-card {
width: min(430px, 100%);
padding: 24px;
border-radius: 24px !important;
}
.logo {
width: 54px;
height: 54px;
border-radius: 16px;
display: grid;
place-items: center;
background: #155bd7;
color: #fff;
font-size: 25px;
font-weight: 800;
}
h1 {
margin: 22px 0 6px;
}
.full {
margin-top: 10px;
}
.error {
color: #b3261e;
}
button.full {
height: 48px;
}
.auth-links {
display: flex;
justify-content: space-between;
gap: 16px;
margin-top: 20px;
}
.auth-links a {
color: #155bd7;
text-decoration: none;
font-weight: 500;
}
.legal {
text-align: center;
margin-top: 18px;
}
.legal a {
color: #687287;
text-decoration: none;
font-size: 13px;
}
`,
],
})
export class LoginComponent {
private readonly fb = inject(FormBuilder);
readonly loading = signal(false);
readonly error = signal("");
readonly form = this.fb.nonNullable.group({
email: ["", [Validators.required, Validators.email]],
password: ["", Validators.required],
});
constructor(
private api: ApiService,
private router: Router,
) {}
submit() {
if (this.form.invalid) return;
this.loading.set(true);
this.error.set("");
this.api
.login(this.form.value.email!, this.form.value.password!)
.subscribe({
next: () => this.router.navigate(["/dashboard"]),
error: (e) => {
this.loading.set(false);
this.error.set(e.error?.error || "Anmeldung fehlgeschlagen");
},
});
}
}

95
frontend/src/app/pages/mileage-rates.component.ts

@ -0,0 +1,95 @@
import { Component, OnInit, signal } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { MATERIAL } from "../shared/material";
import { ApiService } from "../core/api.service";
import { MileageRate } from "../core/models";
@Component({
standalone: true,
imports: [FormsModule, ...MATERIAL],
template: `<section class="page narrow">
<div class="page-head">
<div>
<h1>Kilometersätze</h1>
<p class="muted">Kilometergeld je Kalenderjahr verwalten</p>
</div>
</div>
<mat-card
><mat-card-content class="form"
><mat-form-field appearance="outline"
><mat-label>Jahr</mat-label
><input
matInput
type="number"
[(ngModel)]="draft.year" /></mat-form-field
><mat-form-field appearance="outline"
><mat-label>Satz pro km</mat-label
><input
matInput
type="number"
step="0.01"
[(ngModel)]="draft.rate_per_km"
/><span matTextSuffix>€</span></mat-form-field
><button mat-flat-button (click)="save()">
Speichern
</button></mat-card-content
></mat-card
>
<div class="grid cards">
@for (rate of rates(); track rate.id) {
<mat-card
><mat-card-content
><span>{{ rate.year }}</span
><strong>{{ rate.rate_per_km }} €/km</strong
><button mat-button (click)="draft = { ...rate }">
Bearbeiten
</button></mat-card-content
></mat-card
>
}
</div>
</section>`,
styles: [
`
.narrow {
max-width: 900px;
}
.form {
display: flex;
gap: 14px;
align-items: center;
flex-wrap: wrap;
}
.cards {
margin-top: 20px;
}
.cards mat-card-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.cards strong {
font-size: 1.5rem;
}
`,
],
})
export class MileageRatesComponent implements OnInit {
rates = signal<MileageRate[]>([]);
draft: Partial<MileageRate> = {
year: new Date().getFullYear(),
rate_per_km: 0.5,
};
constructor(private api: ApiService) {}
ngOnInit() {
this.load();
}
load() {
this.api.mileageRates().subscribe((v) => this.rates.set(v));
}
save() {
this.api.saveMileageRate(this.draft).subscribe(() => {
this.draft = { year: new Date().getFullYear(), rate_per_km: 0.5 };
this.load();
});
}
}

9
frontend/src/app/pages/register.component.ts

@ -0,0 +1,9 @@
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { ApiService } from '../core/api.service';
import { MATERIAL } from '../shared/material';
@Component({standalone:true,imports:[ReactiveFormsModule,RouterLink,...MATERIAL],template:`<main class="auth-page"><mat-card><mat-card-content><mat-icon class="hero-icon">person_add</mat-icon><h1>Konto registrieren</h1><p class="muted">Nach der Registrierung erhältst du einen Bestätigungslink per E-Mail.</p>@if(message()){<div class="success">{{message()}}</div>} @else {<form [formGroup]="form" (ngSubmit)="submit()"><mat-form-field appearance="outline" class="full"><mat-label>E-Mail</mat-label><input matInput type="email" formControlName="email"></mat-form-field><mat-form-field appearance="outline" class="full"><mat-label>Passwort</mat-label><input matInput type="password" formControlName="password"></mat-form-field><mat-form-field appearance="outline" class="full"><mat-label>Passwort wiederholen</mat-label><input matInput type="password" formControlName="password_confirmation"></mat-form-field>@if(error()){<p class="error">{{error()}}</p>}<button mat-flat-button class="full" [disabled]="form.invalid||loading()">Registrieren</button></form>}<a mat-button routerLink="/login">Zur Anmeldung</a></mat-card-content></mat-card></main>`,styles:[authStyles()]})
export class RegisterComponent{private fb=inject(FormBuilder);loading=signal(false);error=signal('');message=signal('');form=this.fb.nonNullable.group({email:['',[Validators.required,Validators.email]],password:['',[Validators.required,Validators.minLength(6)]],password_confirmation:['',Validators.required]});constructor(private api:ApiService){}submit(){const v=this.form.getRawValue();if(v.password!==v.password_confirmation){this.error.set('Die Passwörter stimmen nicht überein.');return;}this.loading.set(true);this.api.register(v.email,v.password,v.password_confirmation).subscribe({next:r=>this.message.set(r.message),error:e=>{this.loading.set(false);this.error.set(e.error?.errors?.join(', ')||'Registrierung fehlgeschlagen');}});}}
export function authStyles(){return `.auth-page{min-height:100%;display:grid;place-items:center;padding:20px;background:#f4f7fb}.auth-page mat-card{width:min(460px,100%);padding:24px;border-radius:24px!important}.hero-icon{font-size:38px;width:42px;height:42px;color:#155bd7}.full{width:100%;margin-top:10px}button.full{height:48px}.error{color:#b3261e}.success{padding:16px;border-radius:12px;background:#dff6e7;color:#125c2d;margin:18px 0}h1{margin-bottom:6px}`;}

109
frontend/src/app/pages/reports.component.ts

@ -0,0 +1,109 @@
import { Component, OnInit, signal } from "@angular/core";
import { DatePipe } from "@angular/common";
import { MATERIAL } from "../shared/material";
import { ApiService } from "../core/api.service";
interface Row {
month: string;
typ: string;
art: string;
total_minutes: number;
}
@Component({
standalone: true,
imports: [DatePipe, ...MATERIAL],
template: `<section class="page">
<div class="page-head">
<div>
<h1>Monatsbericht</h1>
<p class="muted">Geleistete Zeiten nach Monat und Kategorie</p>
</div>
</div>
@if (loading()) { <mat-progress-bar mode="indeterminate" /> }
<div class="grid report">
@for (group of groups(); track group.month) {
<mat-card [class.current-month]="isCurrentMonth(group.month)">
<mat-card-header>
<div class="month-heading">
<div>
<mat-card-title>{{ group.month | date: "MMMM yyyy" }}</mat-card-title>
<mat-card-subtitle>{{ duration(group.total) }}</mat-card-subtitle>
</div>
@if (isCurrentMonth(group.month)) {
<span class="current-badge"><mat-icon>star</mat-icon> Aktueller Monat</span>
}
</div>
</mat-card-header>
<mat-card-content>
@for (row of group.rows; track row.typ + row.art) {
<div class="row" [class]="'row ' + typeClass(row.typ)">
<div><strong>{{ row.art }}</strong><small>{{ row.typ }}</small></div>
<span>{{ duration(row.total_minutes) }}</span>
</div>
}
</mat-card-content>
</mat-card>
}
</div>
</section>`,
styles: [`
.report{grid-template-columns:repeat(auto-fit,minmax(340px,1fr))}
.month-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;width:100%}
mat-card.current-month{border:2px solid var(--app-primary);background:color-mix(in srgb,var(--app-primary) 5%,var(--app-surface));box-shadow:0 12px 32px color-mix(in srgb,var(--app-primary) 16%,transparent)}
.current-month mat-card-header{background:color-mix(in srgb,var(--app-primary) 10%,transparent);border-radius:14px 14px 0 0;padding-top:16px;padding-bottom:14px}
.current-badge{display:inline-flex;align-items:center;gap:5px;white-space:nowrap;border-radius:999px;padding:5px 10px;background:var(--app-primary);color:white;font-size:.78rem;font-weight:700}
.current-badge mat-icon{width:16px;height:16px;font-size:16px}
.row{display:flex;justify-content:space-between;align-items:center;margin:4px 0;padding:11px 12px;border-bottom:1px solid var(--app-border);border-left:4px solid transparent;border-radius:8px}
.row.type-propaedeutikum{background:rgba(13,110,253,.08);border-left-color:#0d6efd}
.row.type-fachspezifikum{background:rgba(25,135,84,.08);border-left-color:#198754}
.row.type-mediation{background:rgba(253,126,20,.10);border-left-color:#fd7e14}
:host-context(body.dark) .row.type-propaedeutikum{background:rgba(62,139,255,.15)}
:host-context(body.dark) .row.type-fachspezifikum{background:rgba(53,184,116,.15)}
:host-context(body.dark) .row.type-mediation{background:rgba(255,143,51,.17)}
.row:last-child{border-bottom:0}.row div{display:flex;flex-direction:column}.row small{margin-top:3px}mat-card-content{padding-top:12px!important}
@media(max-width:500px){.report{grid-template-columns:1fr}.month-heading{flex-direction:column}.current-badge{align-self:flex-start}}
`],
})
export class ReportsComponent implements OnInit {
readonly rows = signal<Row[]>([]);
readonly loading = signal(true);
constructor(private api: ApiService) {}
ngOnInit() {
this.api.monthlyReport().subscribe({
next: (rows) => { this.rows.set(rows); this.loading.set(false); },
error: () => this.loading.set(false),
});
}
groups() {
const map = new Map<string, Row[]>();
for (const row of this.rows()) map.set(row.month, [...(map.get(row.month) || []), row]);
return [...map].map(([month, rows]) => ({
month,
rows,
total: rows.reduce((sum, row) => sum + row.total_minutes, 0),
}));
}
isCurrentMonth(month: string) {
const now = new Date();
return month.slice(0, 7) === `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
typeClass(type: string) {
const normalized = type
?.toLocaleLowerCase("de-AT")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
return `type-${normalized === "propadeutikum" ? "propaedeutikum" : normalized || "unknown"}`;
}
duration(minutes: number) {
return `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
}
}

3
frontend/src/app/pages/resend-confirmation.component.ts

@ -0,0 +1,3 @@
import { Component, inject, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { RouterLink } from '@angular/router'; import { ApiService } from '../core/api.service'; import { MATERIAL } from '../shared/material'; import { authStyles } from './register.component';
@Component({standalone:true,imports:[ReactiveFormsModule,RouterLink,...MATERIAL],template:`<main class="auth-page"><mat-card><mat-card-content><mat-icon class="hero-icon">forward_to_inbox</mat-icon><h1>Bestätigungslink erneut senden</h1>@if(message()){<div class="success">{{message()}}</div>} @else {<form [formGroup]="form" (ngSubmit)="submit()"><mat-form-field appearance="outline" class="full"><mat-label>E-Mail</mat-label><input matInput type="email" formControlName="email"></mat-form-field><button mat-flat-button class="full" [disabled]="form.invalid||loading()">Link senden</button></form>}<a mat-button routerLink="/login">Zur Anmeldung</a></mat-card-content></mat-card></main>`,styles:[authStyles()]})
export class ResendConfirmationComponent{private fb=inject(FormBuilder);loading=signal(false);message=signal('');form=this.fb.nonNullable.group({email:['',[Validators.required,Validators.email]]});constructor(private api:ApiService){}submit(){this.loading.set(true);this.api.resendConfirmation(this.form.getRawValue().email).subscribe(r=>this.message.set(r.message));}}

3
frontend/src/app/pages/reset-password.component.ts

@ -0,0 +1,3 @@
import { Component, inject, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { ApiService } from '../core/api.service'; import { MATERIAL } from '../shared/material'; import { authStyles } from './register.component';
@Component({standalone:true,imports:[ReactiveFormsModule,RouterLink,...MATERIAL],template:`<main class="auth-page"><mat-card><mat-card-content><mat-icon class="hero-icon">password</mat-icon><h1>Neues Passwort</h1>@if(message()){<div class="success">{{message()}}</div>} @else {<form [formGroup]="form" (ngSubmit)="submit()"><mat-form-field appearance="outline" class="full"><mat-label>Neues Passwort</mat-label><input matInput type="password" formControlName="password"></mat-form-field><mat-form-field appearance="outline" class="full"><mat-label>Passwort wiederholen</mat-label><input matInput type="password" formControlName="password_confirmation"></mat-form-field>@if(error()){<p class="error">{{error()}}</p>}<button mat-flat-button class="full" [disabled]="form.invalid||loading()">Passwort speichern</button></form>}<a mat-button routerLink="/login">Zur Anmeldung</a></mat-card-content></mat-card></main>`,styles:[authStyles()]})
export class ResetPasswordComponent{private fb=inject(FormBuilder);token=inject(ActivatedRoute).snapshot.queryParamMap.get('token')||'';loading=signal(false);error=signal('');message=signal('');form=this.fb.nonNullable.group({password:['',[Validators.required,Validators.minLength(6)]],password_confirmation:['',Validators.required]});constructor(private api:ApiService){}submit(){const v=this.form.getRawValue();if(!this.token){this.error.set('Der Reset-Link ist ungültig.');return;}if(v.password!==v.password_confirmation){this.error.set('Die Passwörter stimmen nicht überein.');return;}this.loading.set(true);this.api.resetPassword(this.token,v.password,v.password_confirmation).subscribe({next:r=>this.message.set(r.message),error:e=>{this.loading.set(false);this.error.set(e.error?.errors?.join(', ')||'Passwort konnte nicht geändert werden');}});}}

222
frontend/src/app/pages/settings.component.ts

@ -0,0 +1,222 @@
import { Component, OnInit, signal } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { MatSnackBar } from "@angular/material/snack-bar";
import { MATERIAL } from "../shared/material";
import { ApiService } from "../core/api.service";
import { Settings } from "../core/models";
@Component({
standalone: true,
imports: [FormsModule, ...MATERIAL],
template: `<section class="page narrow">
<div class="page-head">
<div>
<h1>Einstellungen</h1>
<p class="muted">Persönliche Ausbildungsziele anpassen</p>
</div>
</div>
@if (loading()) {
<mat-progress-bar mode="indeterminate" />
} @else if (settings(); as s) {
<mat-card
><mat-card-header
><mat-card-title>Ausbildungsstatus</mat-card-title></mat-card-header
><mat-card-content
><mat-checkbox [(ngModel)]="s.praepedeutikum_done"
>Propädeutikum abgeschlossen</mat-checkbox
>
<p class="muted">
Nach dem Abschluss können keine neuen Propädeutikums-Einträge
angelegt werden.
</p></mat-card-content
></mat-card
>
<mat-card
><mat-card-header
><mat-card-title>Profil</mat-card-title></mat-card-header
><mat-card-content
><mat-form-field appearance="outline" class="full"
><mat-label>E-Mail-Adresse</mat-label
><input matInput type="email" [(ngModel)]="s.email"
/></mat-form-field>
<p class="muted">
Bei einer Änderung muss die neue Adresse erneut bestätigt werden.
</p></mat-card-content
></mat-card
>
@for (typ of s.praktikums_typen; track typ) {
<mat-card
><mat-card-header
><mat-card-title>{{ label(typ) }}</mat-card-title
><mat-card-subtitle
>Sollstunden und durchschnittliches Wochenziel</mat-card-subtitle
></mat-card-header
><mat-card-content
><div class="targets">
@for (art of s.entry_arten_by_typ[typ]; track art) {
<div>
<span>{{ art }}</span
><mat-form-field appearance="outline"
><mat-label>Soll</mat-label
><input
matInput
type="number"
min="0"
[(ngModel)]="s.required_hours_matrix[typ][art]"
/><span matTextSuffix>h</span></mat-form-field
><mat-form-field appearance="outline"
><mat-label>pro Woche</mat-label
><input
matInput
type="number"
min="0"
step="0.25"
[(ngModel)]="s.weekly_target_matrix[typ][art]"
/><span matTextSuffix>h</span></mat-form-field
>
</div>
}
</div></mat-card-content
></mat-card
>
}
<mat-card
><mat-card-header
><mat-card-title>Passwort ändern</mat-card-title></mat-card-header
><mat-card-content class="password-grid"
><mat-form-field appearance="outline"
><mat-label>Aktuelles Passwort</mat-label
><input
matInput
type="password"
[(ngModel)]="currentPassword" /></mat-form-field
><mat-form-field appearance="outline"
><mat-label>Neues Passwort</mat-label
><input
matInput
type="password"
[(ngModel)]="newPassword" /></mat-form-field
><mat-form-field appearance="outline"
><mat-label>Neues Passwort wiederholen</mat-label
><input
matInput
type="password"
[(ngModel)]="confirmation" /></mat-form-field
><button mat-stroked-button (click)="changePassword()">
Passwort ändern
</button></mat-card-content
></mat-card
>
<div class="actions footer">
<button mat-flat-button (click)="save()" [disabled]="saving()">
Änderungen speichern
</button>
</div>
}
</section>`,
styles: [
`
.narrow {
max-width: 1000px;
}
mat-card {
margin-bottom: 20px;
}
mat-card-content {
padding-top: 18px !important;
}
.targets > div {
display: grid;
grid-template-columns: minmax(220px, 1fr) 170px 170px;
gap: 14px;
align-items: center;
}
.targets mat-form-field {
margin-top: 8px;
}
.footer {
justify-content: flex-end;
position: sticky;
bottom: 12px;
}
.password-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
align-items: center;
}
@media (max-width: 700px) {
.targets > div {
grid-template-columns: 1fr 1fr;
}
.targets > div > span {
grid-column: 1/-1;
font-weight: 500;
}
.password-grid {
grid-template-columns: 1fr;
}
}
`,
],
})
export class SettingsComponent implements OnInit {
readonly settings = signal<Settings | null>(null);
readonly loading = signal(true);
readonly saving = signal(false);
currentPassword = "";
newPassword = "";
confirmation = "";
constructor(
private api: ApiService,
private snack: MatSnackBar,
) {}
ngOnInit() {
this.api.settings().subscribe({
next: (s) => {
this.settings.set(s);
this.loading.set(false);
},
error: () => this.loading.set(false),
});
}
label(v: string) {
return v.charAt(0).toUpperCase() + v.slice(1);
}
save() {
const s = this.settings();
if (!s) return;
this.saving.set(true);
this.api.saveSettings(s).subscribe({
next: (r) => {
this.settings.set(r);
this.saving.set(false);
this.snack.open("Einstellungen gespeichert", "OK", { duration: 2500 });
},
error: () => {
this.saving.set(false);
this.snack.open("Speichern fehlgeschlagen", "OK");
},
});
}
changePassword() {
if (this.newPassword !== this.confirmation) {
this.snack.open("Die Passwörter stimmen nicht überein", "OK");
return;
}
this.api
.changePassword(this.currentPassword, this.newPassword, this.confirmation)
.subscribe({
next: (r) => {
localStorage.removeItem("praktikum_token");
location.href = "/login";
},
error: (e) =>
this.snack.open(
e.error?.error ||
e.error?.errors?.join(", ") ||
"Passwortänderung fehlgeschlagen",
"OK",
),
});
}
}

2
frontend/src/app/shared/material.ts

@ -0,0 +1,2 @@
import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatIconModule } from '@angular/material/icon'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatSidenavModule } from '@angular/material/sidenav'; import { MatListModule } from '@angular/material/list'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatNativeDateModule } from '@angular/material/core'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatTableModule } from '@angular/material/table'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatDialogModule } from '@angular/material/dialog'; import { MatMenuModule } from '@angular/material/menu'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatDividerModule } from '@angular/material/divider'; import { MatChipsModule } from '@angular/material/chips';
export const MATERIAL=[MatButtonModule,MatCardModule,MatIconModule,MatToolbarModule,MatSidenavModule,MatListModule,MatFormFieldModule,MatInputModule,MatSelectModule,MatDatepickerModule,MatNativeDateModule,MatCheckboxModule,MatTableModule,MatProgressBarModule,MatProgressSpinnerModule,MatSnackBarModule,MatDialogModule,MatMenuModule,MatTooltipModule,MatDividerModule,MatChipsModule] as const;

1
frontend/src/index.html

@ -0,0 +1 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><title>Ausbildungsnachweis</title><base href="/"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="data:,"></head><body><app-root></app-root></body></html>

10
frontend/src/main.ts

@ -0,0 +1,10 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
import { authInterceptor } from './app/core/auth.interceptor';
bootstrapApplication(AppComponent, { providers: [provideRouter(routes), provideHttpClient(withInterceptors([authInterceptor])), provideAnimationsAsync()] })
.catch(console.error);

80
frontend/src/styles.scss

@ -0,0 +1,80 @@
@use '@angular/material' as mat;
@import 'material-icons/iconfont/material-icons.css';
$font: 'Inter Variable', 'Segoe UI', system-ui, sans-serif;
html {
@include mat.theme((color: (primary: mat.$azure-palette, tertiary: mat.$cyan-palette), typography: 'Inter Variable', density: 0));
height: 100%; color-scheme: light;
--app-bg:#f4f7fb; --app-surface:#fff; --app-surface-soft:#f7f9fc; --app-border:#dfe5ef;
--app-text:#172033; --app-muted:#657087; --app-primary:#155bd7;
--app-shadow:0 10px 30px rgba(31,50,81,.08);
}
html, body {
width:100%;
height:100%;
min-height:0;
overflow:hidden;
}
app-root {
display:block;
width:100%;
height:100%;
min-height:0;
overflow:hidden;
}
body {
margin:0; font-family:$font; font-size:15px; line-height:1.5; font-optical-sizing:auto;
font-synthesis:none; text-rendering:optimizeLegibility; -webkit-font-smoothing:antialiased;
background:var(--app-bg); color:var(--app-text);
}
body.dark {
@include mat.theme((color: (primary: mat.$cyan-palette, tertiary: mat.$azure-palette, theme-type: dark), typography: 'Inter Variable'));
color-scheme:dark; --app-bg:#0f1318; --app-surface:#171c22; --app-surface-soft:#1c232b;
--app-border:#2d3742; --app-text:#e8edf4; --app-muted:#9aa8ba; --app-primary:#75a7ff;
--app-shadow:0 12px 34px rgba(0,0,0,.26); background:var(--app-bg); color:var(--app-text);
}
*, *::before, *::after { box-sizing:border-box; }
button, input, textarea, select { font:inherit; }
h1,h2,h3,h4,h5,h6,p { font-family:$font; }
h1,h2,h3,h4,h5,h6 { color:var(--app-text); letter-spacing:-.025em; }
.mat-mdc-button,.mat-mdc-raised-button,.mat-mdc-unelevated-button,.mat-mdc-outlined-button,
.mat-mdc-icon-button,.mat-mdc-list-item,.mat-mdc-form-field,.mat-mdc-input-element,.mat-mdc-select,
.mat-mdc-option,.mat-mdc-table,.mat-mdc-dialog-container,.mat-mdc-card,.mat-mdc-menu-panel,
.mat-toolbar,.mat-mdc-checkbox,.mat-mdc-snack-bar-container,.cdk-overlay-container { font-family:$font !important; }
.page {
max-width:1480px;
min-height:0 !important;
height:auto !important;
margin:0 auto;
padding:30px 32px 48px;
}
.page-head { display:flex; align-items:center; justify-content:space-between; gap:18px; margin-bottom:24px; }
.page-head h1 { margin:0; font-size:clamp(1.7rem,2.5vw,2.25rem); font-weight:720; line-height:1.15; }
.page-head p { margin:7px 0 0; }
.grid { display:grid; gap:20px; }
.cards { grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); }
.full { width:100%; } .muted { color:var(--app-muted) !important; }
.actions { display:flex; flex-wrap:wrap; gap:10px; align-items:center; }
.table-wrap { overflow:auto; border-radius:16px; } table { width:100%; }
mat-card.mat-mdc-card { border:1px solid var(--app-border); border-radius:16px !important; background:var(--app-surface); color:var(--app-text); box-shadow:var(--app-shadow); }
.mat-mdc-card-title { font-weight:680 !important; letter-spacing:-.015em !important; }
.mat-mdc-card-subtitle,td small { color:var(--app-muted) !important; }
.mat-mdc-header-row { background:var(--app-surface-soft) !important; }
.mat-mdc-header-cell { color:var(--app-text) !important; font-weight:680 !important; white-space:nowrap; }
.mat-mdc-cell { color:var(--app-text) !important; border-color:var(--app-border) !important; }
.mat-mdc-row { transition:background-color .15s ease; }
.mat-mdc-form-field { --mdc-outlined-text-field-container-shape:10px; }
.mat-mdc-text-field-wrapper { background:var(--app-surface-soft); }
.mat-mdc-input-element,.mat-mdc-select-value,.mat-mdc-floating-label { color:var(--app-text) !important; }
.mat-mdc-select-arrow { color:var(--app-muted) !important; }
.mat-mdc-button,.mat-mdc-raised-button,.mat-mdc-unelevated-button,.mat-mdc-outlined-button { border-radius:10px !important; letter-spacing:0 !important; font-weight:620 !important; }
.mat-mdc-progress-bar { border-radius:999px; overflow:hidden; }
.empty { padding:48px 20px; text-align:center; color:var(--app-muted); }
body.dark .mat-drawer,body.dark .mat-drawer-content,body.dark .mat-mdc-table { background:var(--app-surface); color:var(--app-text); }
body.dark .mat-mdc-text-field-wrapper { background:var(--app-surface-soft); }
body.dark .cost-year { background:var(--app-surface-soft) !important; }
.shell.mat-drawer-container { height:100dvh; min-height:0; overflow:hidden; }
.shell .mat-drawer-inner-container { overflow:hidden; }
.shell .mat-drawer-content { height:100%; min-height:0; overflow-y:auto; overflow-x:hidden; }
@media(max-width:700px){.page{padding:20px 12px 36px}.page-head{align-items:flex-start;flex-direction:column}.hide-mobile{display:none!important}}

1
frontend/tsconfig.app.json

@ -0,0 +1 @@
{"extends":"./tsconfig.json","compilerOptions":{"outDir":"./out-tsc/app","types":[]},"files":["src/main.ts"],"include":["src/**/*.d.ts"]}

24
frontend/tsconfig.json

@ -0,0 +1,24 @@
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022", "dom"]
},
"angularCompilerOptions": {
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

0
log/.keep

16
node_modules/.package-lock.json

@ -1,16 +0,0 @@
{
"name": "praktikum",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/controllers": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/controllers/-/controllers-0.0.2.tgz",
"integrity": "sha512-Xhe4m8rr1reyM9jHIxaJQMR/P7ItoXvYhqFATht/5XMWcuONaFbYUNnV7/6WxR58uc+d4OHU9rLuKYK+5KUopw==",
"engines": {
"node": "*"
}
}
}
}

1
node_modules/controllers/.npmignore

@ -1 +0,0 @@
node_modules/*

21
node_modules/controllers/Cakefile

@ -1,21 +0,0 @@
fs = require 'fs'
{print} = require 'util'
{spawn, exec} = require 'child_process'
build = (watch, callback) ->
if typeof watch is 'function'
callback = watch
watch = false
options = ['-c', '-o', 'lib', 'src']
options.unshift '-w' if watch
coffee = spawn 'coffee', options
coffee.stdout.on 'data', (data) -> print data.toString()
coffee.stderr.on 'data', (data) -> print data.toString()
coffee.on 'exit', (status) -> callback?() if status is 0
task 'build', 'Compile CoffeeScript source files', ->
build()
task 'watch', 'Recompile CoffeeScript source files when modified', ->
build true

127
node_modules/controllers/README.md

@ -1,127 +0,0 @@
# Controllers
A simple mvc framework and route extender for Express.
### Installation
```bash
$ npm install controllers
```
### Usage
After setting all your middleware in Express, call the controllers method to initialise.
```
express = require 'express'
controllers = require 'controllers'
app = express.createServer()
app.use(express.static(__dirname + '/public'));
# Make sure all your app.use statements have been called
controllers app, options
```
Your folder system should now look like the following:
```
site
|-> controllers
| |-> home.js (or coffee, if you have overwritten the require calls)
| |-> blog.js
|-> views
| -> home
| |-> index.jade (these are your views, use whatever renderer you want)
| |-> welcome.jade
| -> blog
| | -> index.jade
| -> shared
| -> layout.jade
```
Controllers are called depending on your routing, and the render call is overwritten to access the folder with the same name as the controller, falling back to the shared folder if needed.
### Routing
When routing a controller and action must be defined, controllers extends the routing in Express to allow for default values
```
# app.get 'route', defaults, middleware...
app.get '/blogPage', { controller: 'blog', action: 'index' }, middleware
app.get '/:controller?/:action?/:id?', { controller: 'home', action: 'index' }, middleware
```
The above routing will route the following paths:
```
'/' -> Routes to the controller 'home' and runs the method 'index'
'/blogPage' -> Routes to the controller 'blog' and runs the method 'index'
'/home/welcome/1' -> Routes to the controller 'home' and runs the method 'welcome', with the 'id' param set to 1
```
### What does a controller look like?
The controller actions follow the normal convention of Express, taking the request, response and next arguments:
```
module.exports.index = (req, res, next) ->
res.render()
module.exports.welcome = (req, res, next) ->
id = req.param.id ?? 0
res.partial { id: id }
```
The render does not take an argument as the view for this action is automatically searched for in at 'views/home/index' and if that fails falls back to 'views/shared/index'.
### Helpers
There are a number of useful calls available in the controllers and views.
Controllers:
```
req.controller # stores current controller
req.action # stores current action
req.executeController 'controller', 'action', cb # Executes another controller and overwrites the next function with the cb
```
Views:
```
controller # stores current controller
action # stores current action
getUrl 'controller', 'action', defaultParams, queryParams # returns a url corresponding to the controller/action specified
getUrl 'action', defaultParams, queryParams # same as above but using the current controller
```
### Options
The default options are:
```
# If the controller/action is not defined do we throw an exception?
strict: true
# Overwrite the render/partial calls to use the 'controller/action' breakdown of the views
overwriteRender: true
# Log when the controllers are loaded and called
log: false
# Set the root folder for the controllers
root: app.set('controllers') || process.cwd() + '/controllers'
# Set the share folder in the views, all render/partial calls will fall back to this folder
sharedFolder: 'shared'
```
### License
©2012 Felix Jorkowski and available under the [MIT license](http://www.opensource.org/licenses/mit-license.php):
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

343
node_modules/controllers/lib/controllers.js

@ -1,343 +0,0 @@
(function() {
var Controllers, fs, path;
var __slice = Array.prototype.slice;
fs = require('fs');
path = require('path');
module.exports = function(app, options) {
var _ref, _ref2, _ref3, _ref4, _ref5;
if (options == null) options = {};
if ((_ref = options.strict) == null) options.strict = true;
if ((_ref2 = options.overwriteRender) == null) options.overwriteRender = true;
if ((_ref3 = options.log) == null) options.log = false;
if ((_ref4 = options.root) == null) {
options.root = app.set('controllers') || process.cwd() + '/controllers';
}
if ((_ref5 = options.sharedFolder) == null) options.sharedFolder = 'shared';
return new Controllers(app, options);
};
Controllers = (function() {
function Controllers(app, options) {
var originalRoute, self;
this.options = options;
self = this;
this._controllers = {};
this.executeOnDirectory(this.options.root, function(file) {
var controller, ext, reduced;
ext = path.extname(file);
if (ext === '.js' || ext === '.coffee') {
reduced = file.replace(ext, '');
controller = path.basename(reduced);
self._controllers[controller] = require(reduced);
if (self.options.log) {
return console.log("Controller '" + controller + "' has been loaded");
}
}
});
originalRoute = app.routes._route;
app.routes._route = function() {
var c, callbacks, defaults, defkey, defvalue, holder, key, method, newCallbacks, newRoute, path, result, _i, _j, _len, _len2, _ref;
method = arguments[0], path = arguments[1], defaults = arguments[2], callbacks = 4 <= arguments.length ? __slice.call(arguments, 3) : [];
if ('function' === typeof defaults) {
callbacks.push(defaults);
defaults = null;
}
if (callbacks.length === 0) callbacks.push(function(req, res) {});
if (defaults == null) defaults = {};
holder = {};
for (_i = 0, _len = callbacks.length; _i < _len; _i++) {
c = callbacks[_i];
newCallbacks = self.overwriteCallback(c, holder);
}
result = originalRoute.call(app.routes, method, path, newCallbacks);
holder.route = newRoute = result.routes[method][result.routes[method].length - 1];
for (defkey in defaults) {
defvalue = defaults[defkey];
key = self.getKeyInRoute(defkey, newRoute);
if (key != null) {
key["default"] = defvalue;
} else {
if (defkey === 'controller' || defkey === 'action') {
newRoute[defkey] = defvalue;
}
}
}
_ref = newRoute.keys;
for (_j = 0, _len2 = _ref.length; _j < _len2; _j++) {
key = _ref[_j];
if (key.name === 'controller' || key.name === 'action') {
newRoute[key.name] = '*';
}
}
return result;
};
this.addHelpers(app);
}
Controllers.prototype.addReqHelpers = function(req, res) {
var self;
self = this;
return req.executeController = function(controller, action, next) {
var currentA, currentC, nextFunc;
if (!(controller != null) || !(action != null)) {
throw new Error("executeController needs the controller and action specified");
}
if (next != null) {
currentC = req.controller;
currentA = req.action;
nextFunc = next;
next = function() {
req.controller = currentC;
req.action = currentA;
return nextFunc.apply(this, arguments);
};
}
req.controller = controller;
req.action = action;
return self._controllers[controller][action](req, res, next);
};
};
Controllers.prototype.addHelpers = function(app) {
var self;
self = this;
return app.dynamicHelpers({
controller: function(req, res) {
return req.controller;
},
action: function(req, res) {
return req.action;
},
getUrl: function(req, res) {
return function(controller, action, other, query) {
var def, first, hasReplaced, i, key, regExp, replacement, result, route, value, _i, _len, _ref, _ref2, _ref3, _ref4;
if (!(action != null) || 'object' === typeof action) {
query = other;
other = action;
action = controller;
controller = null;
}
if (controller == null) controller = req.controller;
if (other == null) other = {};
other.controller = controller;
other.action = action;
if (query == null) query = {};
if (!(action != null) || !(controller != null)) {
throw new Error("getUrl needs at minimum an action defined, but also takes a controller");
}
_ref = app.routes.routes.get;
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
route = _ref[_i];
if (self.isMatchingPath(other, route)) {
hasReplaced = false;
result = route.path;
for (i = _ref2 = route.keys.length - 1; _ref2 <= 0 ? i <= 0 : i >= 0; _ref2 <= 0 ? i++ : i--) {
key = route.keys[i];
def = (_ref3 = key["default"]) != null ? _ref3 : '';
replacement = (_ref4 = other[key.name]) != null ? _ref4 : def;
if (hasReplaced && replacement === '') {
throw new Error("The optional parameter '" + key.name + "' is required for this getUrl call as an parameter further down the path has been specified");
} else {
if (!hasReplaced) {
if ((!key.optional || replacement !== def) && (hasReplaced = true)) {} else {
replacement = '';
}
}
}
regExp = new RegExp(":" + key.name + "(\\?)?");
result = result.replace(regExp, replacement);
}
result = result.replace(/\/+/g, '/');
if (result !== '/') result = result.replace(/\/+$/, '');
first = true;
for (key in query) {
value = query[key];
if (first) {
first = false;
result = result + '?' + key;
if ((value != null) && value !== '') {
result = result + '=' + value;
}
} else {
result = result + '&' + key;
if ((value != null) && value !== '') {
result = result + '=' + value;
}
}
}
return result;
}
}
throw new Error("Route could not be found that matches getUrl parameters, make sure to specify a valid controller, action and required parameters");
};
}
});
};
Controllers.prototype.getKeyInRoute = function(name, route) {
var key, _i, _len, _ref;
_ref = route.keys;
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
key = _ref[_i];
if (key.name === name) return key;
}
return null;
};
Controllers.prototype.isMatchingPath = function(object, route) {
var key, value, _i, _len, _ref;
if (route.controller !== '*' && route.controller !== object.controller) {
return false;
}
if (route.action !== '*' && route.action !== object.action) return false;
for (key in object) {
value = object[key];
if (key !== 'controller' && key !== 'action') {
if (!((this.getKeyInRoute(key, route)) != null)) return false;
}
}
_ref = route.keys;
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
key = _ref[_i];
if (key.name !== 'controller' && key.name !== 'action') {
if (!key.optional && !(object[key] != null)) return false;
}
}
return true;
};
Controllers.prototype.overwriteCallback = function(callback, routeHolder) {
var options, self;
self = this;
options = this.options;
return function(req, resp, next) {
var action, controller, key, route, _i, _len, _ref, _ref2, _ref3;
callback(req, resp, next);
self.addReqHelpers(req, resp);
route = routeHolder.route;
_ref = route.keys;
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
key = _ref[_i];
if (!(req.params[key.name] != null) && (key["default"] != null)) {
req.params[key.name] = key["default"];
}
}
req.controller = (_ref2 = req.params.controller) != null ? _ref2 : route.controller;
req.action = (_ref3 = req.params.action) != null ? _ref3 : route.action;
if (options.log) {
console.log('Controller: ' + req.controller);
console.log('Action: ' + req.action);
}
if (options.strict) {
if (!(req.controller != null)) {
throw new Error("Is in strict mode and no controller specified");
}
if (!(req.action != null)) {
throw new Error("Is in strict mode and no action specified");
}
}
if ((req.controller != null) && (req.action != null)) {
if (options.overwriteRender) self.overwriteRender(req, resp);
controller = self._controllers[req.controller];
if (!(controller != null)) {
if (options.log) {
console.log("Controller '" + req.controller + "' could not be found");
}
next('route');
return;
}
action = controller[req.action];
if (!(action != null)) {
if (options.log) {
console.log("Action '" + req.action + "' could not be found on controller '" + req.controller + "' ");
}
next('route');
return;
}
return action(req, resp, next);
} else {
if (options.log) {
return console.log('Controller or action was not specified, no action was called');
}
}
};
};
Controllers.prototype.overwriteRender = function(req, resp) {
var original, root, self;
self = this;
original = resp.render;
root = resp.app.set('views') || process.cwd() + '/views';
return resp.render = function(view, opts, fn, parent, sub) {
var finalPass, hasHints, reset, result, secondRender, secondResult;
if ('object' === typeof view || 'function' === typeof view) {
sub = parent;
parent = fn;
fn = opts;
opts = view;
view = null;
}
if (view == null) view = req.action;
hasHints = resp.app.enabled('hints');
resp.app.disable('hints');
result = null;
secondResult = null;
reset = function() {
if (hasHints) return resp.app.enable('hints');
};
finalPass = function(err, err2, str) {
reset();
if (err != null) err = err + '\r\n\r\n' + err2;
if (fn != null) {
return fn(err, str);
} else {
if (err != null) {
return req.next(err);
} else {
return resp.send(str);
}
}
};
secondRender = function(err, str) {
if (err != null) {
return secondResult = original.call(resp, self.options.sharedFolder + '/' + view, opts, (function(err2, str2) {
return finalPass(err2, err, str2);
}), parent, sub);
} else {
reset();
if (fn != null) {
return fn(err, str);
} else {
return resp.send(str);
}
}
};
result = original.call(resp, req.controller + '/' + view, opts, secondRender, parent, sub);
if (secondResult != null) result = secondResult;
reset();
return result;
};
};
Controllers.prototype.executeOnDirectory = function(dir, action) {
return fs.readdirSync(dir).forEach(function(file) {
var localpath, stat;
localpath = dir + '/' + file;
stat = fs.statSync(localpath);
if (stat && stat.isDirectory()) {
return self.executeOnDirectory(localpath, action);
} else {
return action(localpath);
}
});
};
return Controllers;
})();
}).call(this);

16
node_modules/controllers/package.json

@ -1,16 +0,0 @@
{
"author": "Felix Jorkowski (http://jorkowski.com)",
"name": "controllers",
"description": "A simple mvc framework and route extender for Express",
"version": "0.0.2",
"homepage": "https://github.com/ajorkowski/controllers",
"repository": {
"type": "git",
"url": "git://github.com/ajorkowski/controllers.git"
},
"main": "lib/controllers.js",
"dependencies": {
},
"devDependencies": {
}
}

324
node_modules/controllers/src/controllers.coffee

@ -1,324 +0,0 @@
fs = require('fs')
path = require('path')
module.exports = (app, options = {}) ->
options.strict ?= true
options.overwriteRender ?= true
options.log ?= false
options.root ?= app.set('controllers') || process.cwd() + '/controllers'
options.sharedFolder ?= 'shared'
new Controllers app, options
class Controllers
constructor: (app, @options) ->
self = this
@_controllers = {}
# Pre-load all the controllers... one time hit so done sync
this.executeOnDirectory @options.root, (file) ->
ext = path.extname file
if ext == '.js' || ext == '.coffee'
reduced = file.replace ext, ''
controller = path.basename reduced
self._controllers[controller] = require reduced
if self.options.log
console.log "Controller '#{controller}' has been loaded"
# We are off to hijack the req.app.routes._route
# which is the point of contact of all our get/post/pull/etc methods.
# We will let the usual chain occur till the very last
# callback, and then we will make sure the controller and action
# are both defined, and then load up that controller/action.
# We have already cached the controllers to reduce require calls
originalRoute = app.routes._route
app.routes._route = (method, path, defaults, callbacks...) ->
# We might not have defaults
if 'function' == typeof defaults
callbacks.push defaults
defaults = null
if callbacks.length == 0
callbacks.push (req, res) ->
defaults ?= { }
holder = { }
# overwrite the callbacks to use this info
newCallbacks = (self.overwriteCallback c, holder) for c in callbacks
result = originalRoute.call app.routes, method, path, newCallbacks
# Extend the routing by adding defaults
holder.route = newRoute = result.routes[method][result.routes[method].length - 1]
for defkey, defvalue of defaults
key = self.getKeyInRoute defkey, newRoute
if key?
key.default = defvalue
else
# controller/action is a special case and we need to save it
if defkey == 'controller' or defkey == 'action'
newRoute[defkey] = defvalue
# If we have a key for controller/action that means they could be anything
for key in newRoute.keys when key.name == 'controller' or key.name == 'action'
newRoute[key.name] = '*'
return result
# Add all the corresponding helpers
this.addHelpers app
addReqHelpers: (req, res) ->
self = this
req.executeController = (controller, action, next) ->
if not controller? or not action?
throw new Error("executeController needs the controller and action specified")
# If we pass a next switch the controller/action back to our current one
if next?
currentC = req.controller
currentA = req.action
nextFunc = next
next = ->
req.controller = currentC
req.action = currentA
nextFunc.apply this, arguments
req.controller = controller
req.action = action
self._controllers[controller][action] req, res, next
addHelpers: (app) ->
self = this
app.dynamicHelpers {
controller: (req, res) ->
req.controller
action: (req, res) ->
req.action
getUrl: (req, res) ->
(controller, action, other, query) ->
if not action? or 'object' == typeof action
query = other
other = action
action = controller
controller = null
controller ?= req.controller
other ?= {}
other.controller = controller
other.action = action
query ?= {}
if not action? or not controller?
throw new Error("getUrl needs at minimum an action defined, but also takes a controller")
for route in app.routes.routes.get
if self.isMatchingPath other, route
# We have found a route that matches
# We are stepping through the keys backwards so that if
# any keys are found the rest MUST be displayed
# (that is... optional keys cannot be blank)
hasReplaced = false
result = route.path
for i in [route.keys.length-1..0]
key = route.keys[i]
def = key.default ? ''
replacement = other[key.name] ? def
if hasReplaced and replacement == ''
throw new Error("The optional parameter '#{key.name}' is required for this getUrl call as an parameter further down the path has been specified")
else
if not hasReplaced
if (not key.optional or replacement != def) and
hasReplaced = true
else
replacement = ''
# Do the replacement
regExp = new RegExp ":#{key.name}(\\?)?"
result = result.replace regExp, replacement
# Remove multiple slashes
result = result.replace /\/+/g, '/'
# Remove trailing slash... unless we are at root
if result != '/'
result = result.replace /\/+$/, ''
# Add in query strings
first = true
for key, value of query
if first
first = false
result = result + '?' + key
if value? and value != ''
result = result + '=' + value
else
result = result + '&' + key
if value? and value != ''
result = result + '=' + value
return result
throw new Error("Route could not be found that matches getUrl parameters, make sure to specify a valid controller, action and required parameters")
}
getKeyInRoute: (name, route) ->
for key in route.keys when key.name == name
return key
return null
isMatchingPath: (object, route) ->
# First check the controller and action
if route.controller != '*' and route.controller != object.controller
return false
if route.action != '*' and route.action != object.action
return false
# This is checking that all items in the object match with a key
for key, value of object when key != 'controller' and key != 'action'
if not (@getKeyInRoute key, route)?
return false
# This is checking all (required) keys have an object value
for key in route.keys when key.name != 'controller' and key.name != 'action'
if not key.optional and not object[key]?
return false
return true
overwriteCallback: (callback, routeHolder) ->
self = this
options = @options
(req, resp, next) ->
# Call the normal callback
callback req, resp, next
# Add helpers
self.addReqHelpers req, resp
# set the current route
route = routeHolder.route
# Go through our keys and if they have a default and the param value
# is not set make sure it is
for key in route.keys when not req.params[key.name]? and key.default?
req.params[key.name] = key.default
# Grab our current controller/action either from the route or use defaults
req.controller = req.params.controller ? route.controller
req.action = req.params.action ? route.action
if options.log
console.log 'Controller: ' + req.controller
console.log 'Action: ' + req.action
if options.strict
if not req.controller?
throw new Error("Is in strict mode and no controller specified")
if not req.action?
throw new Error("Is in strict mode and no action specified")
if req.controller? and req.action?
# We have a controller and an action - lets overwrite the res.render
# command so that we do not have to specify view names
if options.overwriteRender
self.overwriteRender req, resp
# Find the controller
controller = self._controllers[req.controller]
if not controller?
if options.log
console.log "Controller '#{req.controller}' could not be found"
next 'route'
return
# Execute the action
action = controller[req.action]
if not action?
if options.log
console.log "Action '#{req.action}' could not be found on controller '#{req.controller}' "
next 'route'
return
# Execute the controller with a nothing followup action
action req, resp, next
else
if options.log
console.log 'Controller or action was not specified, no action was called'
overwriteRender: (req, resp) ->
self = this
original = resp.render
# This is the root dir the render method uses
root = resp.app.set('views') || process.cwd() + '/views'
resp.render = (view, opts, fn, parent, sub) ->
# Allow for view to be empty
if 'object' == typeof view || 'function' == typeof view
sub = parent
parent = fn
fn = opts
opts = view
view = null
# The view defaults to the action
view ?= req.action
# Set the root directory as the controller directory
# if that doesnt work, try the shared directory
# disable hints because it comes up funny like
hasHints = resp.app.enabled 'hints'
resp.app.disable 'hints'
result = null
secondResult = null
reset = ->
if hasHints
resp.app.enable 'hints'
finalPass = (err, err2, str) ->
reset()
if err?
err = err + '\r\n\r\n' + err2
if fn?
fn err, str
else
if err?
req.next err
else
resp.send str
secondRender = (err, str) ->
if err?
# If the first render failed failed try getting view from 'shared'
secondResult = original.call resp, self.options.sharedFolder + '/' + view, opts, ((err2, str2) -> finalPass(err2, err, str2)), parent, sub
else
reset()
if fn?
fn err, str
else
resp.send str
result = original.call resp, req.controller + '/' + view, opts, secondRender, parent, sub
if secondResult?
result = secondResult
reset()
return result
executeOnDirectory: (dir, action) ->
fs.readdirSync(dir).forEach (file) ->
localpath = dir + '/' + file
stat = fs.statSync localpath
if stat and stat.isDirectory()
self.executeOnDirectory localpath, action
else
action localpath

56
setup-env.ps1

@ -0,0 +1,56 @@
$ErrorActionPreference = "Stop"
$ProjectDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path
$EnvironmentFile = Join-Path $ProjectDirectory ".env"
if (Test-Path $EnvironmentFile) {
Write-Host ".env besteht bereits; es wurde nichts ueberschrieben."
exit 0
}
function New-RandomHex([int]$ByteCount) {
$Bytes = New-Object byte[] $ByteCount
$Generator = [System.Security.Cryptography.RandomNumberGenerator]::Create()
try {
$Generator.GetBytes($Bytes)
}
finally {
$Generator.Dispose()
}
return (($Bytes | ForEach-Object { $_.ToString("x2") }) -join "")
}
$DatabasePassword = New-RandomHex 24
$SecretKeyBase = New-RandomHex 64
$PgHeroPassword = New-RandomHex 16
$Content = @"
DATABASE_PASSWORD=$DatabasePassword
SECRET_KEY_BASE=$SecretKeyBase
API_PORT=13000
FRONTEND_PORT=13131
FRONTEND_ORIGINS=http://localhost:13131
FRONTEND_URL=http://localhost:13131
ADMIN_EMAIL=christoph@marzell.net
MAILER_FROM=praktikum@marzell.net
SMTP_ENABLED=true
SMTP_ADDRESS=smtp.ionos.de
SMTP_PORT=587
SMTP_USERNAME=praktikum@marzell.net
SMTP_PASSWORD=CHANGE_ME
SMTP_DOMAIN=marzell.net
BACKUP_NOTIFY_EMAIL=christoph@marzell.net
TRAINING_WATCH_NOTIFY_EMAIL=christoph@marzell.net
PGHERO_USERNAME=admin
PGHERO_PASSWORD=$PgHeroPassword
"@
[System.IO.File]::WriteAllText(
$EnvironmentFile,
$Content,
[System.Text.UTF8Encoding]::new($false)
)
Write-Host ".env wurde mit zufaelligen Zugangsdaten erstellt."

0
storage/.keep

0
tmp/.keep

0
tmp/pids/.keep

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

Loading…
Cancel
Save