Browse Source

add filters

main
Christoph Marzell 1 month ago
parent
commit
deb4e693ed
  1. 4
      app/controllers/api/v1/dashboard_controller.rb
  2. 11
      app/controllers/api/v1/entries_controller.rb
  3. 36
      app/controllers/api/v1/settings_controller.rb
  4. 30
      app/models/entry.rb
  5. 135
      app/models/user.rb
  6. 4
      config/initializers/build_version.rb
  7. 15
      db/migrate/20260820000000_add_enabled_praktikums_typen_to_users.rb
  8. 5
      db/schema.rb
  9. 3
      frontend/src/app/core/models.ts
  10. 40
      frontend/src/app/pages/calendar.component.ts
  11. 39
      frontend/src/app/pages/dashboard.component.ts
  12. 69
      frontend/src/app/pages/entries.component.ts
  13. 94
      frontend/src/app/pages/entry-form.component.ts
  14. 67
      frontend/src/app/pages/reports.component.ts
  15. 60
      frontend/src/app/pages/settings.component.ts

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

@ -4,7 +4,8 @@ module Api
def show def show
current_user.update_required_matrices! current_user.update_required_matrices!
entries = current_user.entries.where("date <= ?", Date.current) entries = current_user.entries.where("date <= ?", Date.current)
progress = User::PRAKTIKUMSTYPEN.flat_map do |typ|
enabled_types = current_user.enabled_praktikums_typen_list
progress = enabled_types.flat_map do |typ|
User.entry_arten_for(typ).map do |art| 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 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 target = current_user.required_hours_for(typ, art).to_f * 60
@ -28,6 +29,7 @@ module Api
completed_percent: total_target.positive? ? (total_spent.to_f / total_target * 100).round(1) : 0, 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, 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"), running_entry: current_user.entries.find_by(end_time: nil, beschreibung: "Timer"),
enabled_praktikums_typen: enabled_types,
mediation_presence_days: current_user.mediation_praesenzmodule_completed, mediation_presence_days: current_user.mediation_praesenzmodule_completed,
mediation_presence_days_required: current_user.mediation_praesenzmodule_required, mediation_presence_days_required: current_user.mediation_praesenzmodule_required,
last_entry: current_user.entries.where("date <= ?", Date.current).order(date: :desc).first, progress: } last_entry: current_user.entries.where("date <= ?", Date.current).order(date: :desc).first, progress: }

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

@ -20,11 +20,16 @@ module Api
def create def create
entry = current_user.entries.new(entry_params) entry = current_user.entries.new(entry_params)
return render json: { error: "Das Propädeutikum ist bereits abgeschlossen" }, status: :unprocessable_entity if blocked?(entry) return render json: { error: "Das Propädeutikum ist bereits abgeschlossen" }, status: :unprocessable_entity if blocked?(entry)
return render_disabled_type(entry.praktikums_typ) unless current_user.praktikums_typ_enabled?(entry.praktikums_typ)
entry.save ? render(json: payload(entry), status: :created) : render_validation(entry) entry.save ? render(json: payload(entry), status: :created) : render_validation(entry)
end end
def update 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? 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?
requested_type = params.dig(:entry, :praktikums_typ).presence || @entry.praktikums_typ
if requested_type != @entry.praktikums_typ && !current_user.praktikums_typ_enabled?(requested_type)
return render_disabled_type(requested_type)
end
@entry.update(entry_params) ? render(json: payload(@entry)) : render_validation(@entry) @entry.update(entry_params) ? render(json: payload(@entry)) : render_validation(@entry)
end end
@ -37,6 +42,7 @@ module Api
return render json: { error: "Es läuft bereits ein Timer" }, status: :unprocessable_entity if current_user.entries.exists?(end_time: nil, beschreibung: "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]) 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) return render json: { error: "Das Propädeutikum ist bereits abgeschlossen" }, status: :unprocessable_entity if blocked?(entry)
return render_disabled_type(entry.praktikums_typ) unless current_user.praktikums_typ_enabled?(entry.praktikums_typ)
entry.save ? render(json: payload(entry), status: :created) : render_validation(entry) entry.save ? render(json: payload(entry), status: :created) : render_validation(entry)
end end
@ -69,6 +75,11 @@ module Api
current_user.praepedeutikum_abgeschlossen? && entry.praktikums_typ == "propädeutikum" current_user.praepedeutikum_abgeschlossen? && entry.praktikums_typ == "propädeutikum"
end end
def render_disabled_type(type)
label = User::PRAKTIKUMSTYP_LABELS[type.to_s] || type.to_s
render json: { error: "#{label} ist in den Einstellungen nicht aktiviert" }, status: :unprocessable_entity
end
def payload(entry) 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) 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

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

@ -6,13 +6,41 @@ module Api
render json: payload render json: payload
end end
def update 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)
attributes = params.require(:settings).permit(
:email,
:total_required_hours,
:weekly_target_hours,
:praepedeutikum_done,
enabled_praktikums_typen: [],
required_hours_matrix: {},
weekly_target_matrix: {}
)
if current_user.update(attributes)
current_user.update_required_matrices!
render json: payload
else
render_validation(current_user)
end
end end
private private
def payload 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)
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,
enabled_praktikums_typen: current_user.enabled_praktikums_typen_list,
praktikums_typ_labels: User::PRAKTIKUMSTYP_LABELS,
entry_arten: User::ENTRY_ARTEN,
entry_arten_by_typ: User::ENTRY_ARTEN_BY_TYP
)
end end
end end
end end

30
app/models/entry.rb

@ -7,34 +7,8 @@ class Entry < ApplicationRecord
# Konstanten # Konstanten
# ------------------------------------------------------------ # ------------------------------------------------------------
PRAKTIKUMSTYPEN = %w[
propädeutikum
fachspezifikum
mediation
].freeze
ENTRY_ARTEN = [
# Allgemein
"Praktikum",
"Selbsterfahrung",
"Supervision",
"Fortbildung",
"Semesterkosten",
# Mediation
"Präsenzmodul",
"Peergruppenarbeit",
"Fallarbeit",
"Praxisseminare",
"Literatur- und Selbststudium",
# Fachspezifikum
"Gruppenselbsterfahrung",
"Theorie/Methodikseminare",
"Peergruppensupervision",
"Einzel-/Kleingruppensupervision",
"Eigenständige Tätigkeit"
].freeze
PRAKTIKUMSTYPEN = User::PRAKTIKUMSTYPEN
ENTRY_ARTEN = User::ENTRY_ARTEN
LEGACY_ENTRY_ART_ALIASES = { LEGACY_ENTRY_ART_ALIASES = {
"Peergroup" => "Peergruppenarbeit", "Peergroup" => "Peergruppenarbeit",

135
app/models/user.rb

@ -13,34 +13,30 @@ class User < ApplicationRecord
PRAKTIKUMSTYPEN = %w[ PRAKTIKUMSTYPEN = %w[
propädeutikum propädeutikum
fachspezifikum fachspezifikum
psychotherapie
lsb
mediation mediation
coaching
supervision
sonstige
].freeze ].freeze
# ------------------------------------------------------------
# Alle möglichen Entry-Arten
# ------------------------------------------------------------
ENTRY_ARTEN = [
# Allgemein
"Praktikum",
"Selbsterfahrung",
"Supervision",
"Fortbildung",
"Semesterkosten",
# Mediation
"Präsenzmodul",
"Peergruppenarbeit",
"Fallarbeit",
"Praxisseminare",
"Literatur- und Selbststudium",
# Fachspezifikum
"Gruppenselbsterfahrung",
"Theorie/Methodikseminare",
"Peergruppensupervision",
"Einzel-/Kleingruppensupervision",
"Eigenständige Tätigkeit"
].freeze
DEFAULT_ENABLED_PRAKTIKUMSTYPEN = %w[
propädeutikum
fachspezifikum
mediation
].freeze
PRAKTIKUMSTYP_LABELS = {
"propädeutikum" => "Propädeutikum",
"fachspezifikum" => "Fachspezifikum",
"psychotherapie" => "Psychotherapie",
"lsb" => "Lebens- und Sozialberatung (LSB)",
"mediation" => "Mediation",
"coaching" => "Coaching",
"supervision" => "Supervision",
"sonstige" => "Sonstige Leistungen"
}.freeze
# ------------------------------------------------------------ # ------------------------------------------------------------
# Arten pro Ausbildung # Arten pro Ausbildung
@ -70,6 +66,29 @@ class User < ApplicationRecord
"Eigenständige Tätigkeit" "Eigenständige Tätigkeit"
], ],
"psychotherapie" => [
"Praxis/Fälle",
"Supervision",
"Intervision",
"Selbsterfahrung",
"Fortbildung",
"Literatur- und Selbststudium",
"Kosten",
"Sonstige Tätigkeit"
],
"lsb" => [
"Theorie/Methodikseminare",
"Praktikum",
"Praktikumssupervision",
"Gruppenselbsterfahrung",
"Einzelselbsterfahrung",
"Peergruppenarbeit",
"Fortbildung",
"Literatur- und Selbststudium",
"Kosten"
],
"mediation" => [ "mediation" => [
"Präsenzmodul", "Präsenzmodul",
"Selbsterfahrung", "Selbsterfahrung",
@ -80,9 +99,45 @@ class User < ApplicationRecord
"Literatur- und Selbststudium", "Literatur- und Selbststudium",
"Fortbildung", "Fortbildung",
"Semesterkosten" "Semesterkosten"
],
"coaching" => [
"Ausbildung/Lehrgang",
"Praxis/Fälle",
"Supervision",
"Intervision",
"Selbsterfahrung",
"Peergruppenarbeit",
"Fortbildung",
"Literatur- und Selbststudium",
"Kosten"
],
"supervision" => [
"Ausbildung/Lehrgang",
"Lehrsupervision",
"Praxis/Fälle",
"Intervision",
"Selbsterfahrung",
"Fortbildung",
"Literatur- und Selbststudium",
"Kosten"
],
"sonstige" => [
"Ausbildung/Lehrgang",
"Praxis/Fälle",
"Supervision",
"Selbsterfahrung",
"Fortbildung",
"Literatur- und Selbststudium",
"Kosten",
"Sonstige Tätigkeit"
] ]
}.freeze }.freeze
ENTRY_ARTEN = ENTRY_ARTEN_BY_TYP.values.flatten.uniq.freeze
# ------------------------------------------------------------ # ------------------------------------------------------------
# Mediation – zusätzliche Anforderungen # Mediation – zusätzliche Anforderungen
# ------------------------------------------------------------ # ------------------------------------------------------------
@ -96,6 +151,8 @@ class User < ApplicationRecord
MEDIATION_EINZELSUPERVISION_MINDESTENS = 3 MEDIATION_EINZELSUPERVISION_MINDESTENS = 3
after_initialize :set_default, if: :new_record? after_initialize :set_default, if: :new_record?
before_validation :normalize_enabled_praktikums_typen
validate :at_least_one_praktikums_typ_enabled
# ------------------------------------------------------------ # ------------------------------------------------------------
# Allgemein # Allgemein
@ -113,6 +170,15 @@ class User < ApplicationRecord
ENTRY_ARTEN_BY_TYP[typ.to_s] || ENTRY_ARTEN ENTRY_ARTEN_BY_TYP[typ.to_s] || ENTRY_ARTEN
end end
def enabled_praktikums_typen_list
values = Array(enabled_praktikums_typen).map(&:to_s)
PRAKTIKUMSTYPEN.select { |typ| values.include?(typ) }
end
def praktikums_typ_enabled?(typ)
enabled_praktikums_typen_list.include?(typ.to_s)
end
# ------------------------------------------------------------ # ------------------------------------------------------------
# Matrizen aktualisieren # Matrizen aktualisieren
# ------------------------------------------------------------ # ------------------------------------------------------------
@ -135,7 +201,7 @@ class User < ApplicationRecord
required[typ] ||= {} required[typ] ||= {}
weekly[typ] ||= {} weekly[typ] ||= {}
ENTRY_ARTEN.each do |art|
self.class.entry_arten_for(typ).each do |art|
unless required[typ].key?(art) unless required[typ].key?(art)
required[typ][art] = default_hours_for(typ, art) required[typ][art] = default_hours_for(typ, art)
end end
@ -346,10 +412,11 @@ class User < ApplicationRecord
# ------------------------------------------------------------ # ------------------------------------------------------------
def set_default def set_default
self.enabled_praktikums_typen ||= DEFAULT_ENABLED_PRAKTIKUMSTYPEN.dup
self.required_hours_matrix ||= PRAKTIKUMSTYPEN.to_h do |typ| self.required_hours_matrix ||= PRAKTIKUMSTYPEN.to_h do |typ|
[ [
typ, typ,
ENTRY_ARTEN.to_h do |art|
self.class.entry_arten_for(typ).to_h do |art|
[art, default_hours_for(typ, art)] [art, default_hours_for(typ, art)]
end end
] ]
@ -358,7 +425,7 @@ class User < ApplicationRecord
self.weekly_target_matrix ||= PRAKTIKUMSTYPEN.to_h do |typ| self.weekly_target_matrix ||= PRAKTIKUMSTYPEN.to_h do |typ|
[ [
typ, typ,
ENTRY_ARTEN.to_h do |art|
self.class.entry_arten_for(typ).to_h do |art|
[art, default_weekly_target_for(typ, art)] [art, default_weekly_target_for(typ, art)]
end end
] ]
@ -369,6 +436,18 @@ class User < ApplicationRecord
# Alte Bezeichnungen normalisieren # Alte Bezeichnungen normalisieren
# ------------------------------------------------------------ # ------------------------------------------------------------
def normalize_enabled_praktikums_typen
self.enabled_praktikums_typen = PRAKTIKUMSTYPEN.select do |typ|
Array(enabled_praktikums_typen).map(&:to_s).include?(typ)
end
end
def at_least_one_praktikums_typ_enabled
return if enabled_praktikums_typen_list.any?
errors.add(:enabled_praktikums_typen, "muss mindestens einen Bereich enthalten")
end
def canonical_entry_art(art) def canonical_entry_art(art)
case art.to_s case art.to_s
when "Peergroup", "Peer-Gruppe" when "Peergroup", "Peer-Gruppe"

4
config/initializers/build_version.rb

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

15
db/migrate/20260820000000_add_enabled_praktikums_typen_to_users.rb

@ -0,0 +1,15 @@
class AddEnabledPraktikumsTypenToUsers < ActiveRecord::Migration[7.1]
def up
return if column_exists?(:users, :enabled_praktikums_typen)
add_column :users,
:enabled_praktikums_typen,
:jsonb,
default: %w[propädeutikum fachspezifikum mediation],
null: false
end
def down
remove_column :users, :enabled_praktikums_typen if column_exists?(:users, :enabled_praktikums_typen)
end
end

5
db/schema.rb

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2025_12_09_043625) do
ActiveRecord::Schema[7.1].define(version: 2026_08_20_000000) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
enable_extension "plpgsql" enable_extension "plpgsql"
@ -188,6 +188,9 @@ ActiveRecord::Schema[7.1].define(version: 2025_12_09_043625) do
t.datetime "confirmation_sent_at" t.datetime "confirmation_sent_at"
t.string "unconfirmed_email" t.string "unconfirmed_email"
t.boolean "praepedeutikum_done", default: false, null: false t.boolean "praepedeutikum_done", default: false, null: false
t.string "api_token_digest"
t.jsonb "enabled_praktikums_typen", default: ["propädeutikum", "fachspezifikum", "mediation"], null: false
t.index ["api_token_digest"], name: "index_users_on_api_token_digest", unique: true
t.index ["confirmation_token"], name: "index_users_on_confirmation_token" t.index ["confirmation_token"], name: "index_users_on_confirmation_token"
t.index ["email"], name: "index_users_on_email", unique: true t.index ["email"], name: "index_users_on_email", unique: true
t.index ["praepedeutikum_done"], name: "index_users_on_praepedeutikum_done" t.index ["praepedeutikum_done"], name: "index_users_on_praepedeutikum_done"

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

@ -49,6 +49,7 @@ export interface Dashboard {
}[]; }[];
last_entry: Entry | null; last_entry: Entry | null;
running_entry: Entry | null; running_entry: Entry | null;
enabled_praktikums_typen: string[];
mediation_presence_days: number; mediation_presence_days: number;
mediation_presence_days_required: number; mediation_presence_days_required: number;
progress: Progress[]; progress: Progress[];
@ -61,6 +62,8 @@ export interface Settings {
required_hours_matrix: Record<string, Record<string, number>>; required_hours_matrix: Record<string, Record<string, number>>;
weekly_target_matrix: Record<string, Record<string, number>>; weekly_target_matrix: Record<string, Record<string, number>>;
praktikums_typen: string[]; praktikums_typen: string[];
enabled_praktikums_typen: string[];
praktikums_typ_labels: Record<string, string>;
entry_arten: string[]; entry_arten: string[];
entry_arten_by_typ: Record<string, string[]>; entry_arten_by_typ: Record<string, string[]>;
} }

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

@ -3,7 +3,7 @@ import { RouterLink } from "@angular/router";
import { FormsModule } from "@angular/forms"; import { FormsModule } from "@angular/forms";
import { MATERIAL } from "../shared/material"; import { MATERIAL } from "../shared/material";
import { ApiService } from "../core/api.service"; import { ApiService } from "../core/api.service";
import { Entry } from "../core/models";
import { Entry, Settings } from "../core/models";
@Component({ @Component({
standalone: true, standalone: true,
imports: [RouterLink, FormsModule, ...MATERIAL], imports: [RouterLink, FormsModule, ...MATERIAL],
@ -27,11 +27,11 @@ import { Entry } from "../core/models";
<mat-card class="calendar-filters"><mat-card-content> <mat-card class="calendar-filters"><mat-card-content>
<mat-form-field appearance="outline"><mat-label>Ausbildung</mat-label><mat-select [(ngModel)]="typeFilter"> <mat-form-field appearance="outline"><mat-label>Ausbildung</mat-label><mat-select [(ngModel)]="typeFilter">
<mat-option value="">Alle</mat-option> <mat-option value="">Alle</mat-option>
@for (type of types; track type) { <mat-option [value]="type">{{ type }}</mat-option> }
@for (type of types(); track type) { <mat-option [value]="type">{{ typeLabel(type) }}</mat-option> }
</mat-select></mat-form-field> </mat-select></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Art</mat-label><mat-select [(ngModel)]="artFilter"> <mat-form-field appearance="outline"><mat-label>Art</mat-label><mat-select [(ngModel)]="artFilter">
<mat-option value="">Alle</mat-option> <mat-option value="">Alle</mat-option>
@for (art of arts; track art) { <mat-option [value]="art">{{ art }}</mat-option> }
@for (art of arts(); track art) { <mat-option [value]="art">{{ art }}</mat-option> }
</mat-select></mat-form-field> </mat-select></mat-form-field>
<button mat-button (click)="clearFilters()">Zurücksetzen</button> <button mat-button (click)="clearFilters()">Zurücksetzen</button>
</mat-card-content></mat-card> </mat-card-content></mat-card>
@ -124,6 +124,24 @@ import { Entry } from "../core/models";
display: grid; display: grid;
place-items: center; place-items: center;
} }
:host-context(body.dark) .today {
background: #202b3a;
box-shadow: inset 0 0 0 2px #4f8ff7;
}
:host-context(body.dark) .today .day-head > span {
background: #4f8ff7;
color: #07111f;
font-weight: 800;
}
:host-context(body.dark) .today .day-head a {
color: #a9c9ff;
}
:host-context(body.dark) .today .event {
color: #f7f9fc;
}
:host-context(body.dark) .today .event span {
opacity: 0.82;
}
.event { .event {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -155,6 +173,11 @@ import { Entry } from "../core/models";
.event.type-propaedeutikum { background:rgba(13,110,253,.14);border-left-color:#0d6efd; } .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-fachspezifikum { background:rgba(25,135,84,.14);border-left-color:#198754; }
.event.type-mediation { background:rgba(253,126,20,.16);border-left-color:#fd7e14; } .event.type-mediation { background:rgba(253,126,20,.16);border-left-color:#fd7e14; }
.event.type-psychotherapie { background:rgba(124,58,237,.14);border-left-color:#7c3aed; }
.event.type-lsb { background:rgba(219,39,119,.14);border-left-color:#db2777; }
.event.type-coaching { background:rgba(8,145,178,.14);border-left-color:#0891b2; }
.event.type-supervision { background:rgba(202,138,4,.14);border-left-color:#ca8a04; }
.event.type-sonstige { background:rgba(100,116,139,.14);border-left-color:#64748b; }
@media (max-width: 700px) { @media (max-width: 700px) {
.calendar-filters mat-card-content { .calendar-filters mat-card-content {
gap: 8px; gap: 8px;
@ -243,13 +266,19 @@ export class CalendarComponent implements OnInit {
readonly entries = signal<Entry[]>([]); readonly entries = signal<Entry[]>([]);
readonly loading = signal(false); readonly loading = signal(false);
readonly weekdays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; 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"];
readonly types = signal<string[]>([]);
readonly arts = signal<string[]>([]);
readonly typeLabels = signal<Record<string,string>>({});
typeFilter = ""; typeFilter = "";
artFilter = ""; artFilter = "";
readonly todayKey = this.key(new Date()); readonly todayKey = this.key(new Date());
constructor(private api: ApiService) {} constructor(private api: ApiService) {}
ngOnInit() { ngOnInit() {
this.api.settings().subscribe((settings: Settings) => {
this.types.set(settings.praktikums_typen);
this.arts.set(settings.entry_arten);
this.typeLabels.set(settings.praktikums_typ_labels);
});
this.load(); this.load();
} }
days() { days() {
@ -276,6 +305,7 @@ export class CalendarComponent implements OnInit {
return this.entries().filter((e) => e.date === key && (!this.typeFilter || e.praktikums_typ === this.typeFilter) && (!this.artFilter || e.entry_art === this.artFilter)); 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="";} clearFilters(){this.typeFilter="";this.artFilter="";}
typeLabel(type:string){return this.typeLabels()[type]||type.charAt(0).toUpperCase()+type.slice(1);}
typeClass(value:string){ typeClass(value:string){
const normalized=value?.toLocaleLowerCase("de-AT").normalize("NFD").replace(/[\u0300-\u036f]/g,"").replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,""); 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"}`; return `type-${normalized === "propadeutikum" ? "propaedeutikum" : normalized || "unknown"}`;

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

@ -111,18 +111,20 @@ import { Dashboard } from "../core/models";
</div></mat-card-content </div></mat-card-content
></mat-card ></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>
@if (d.enabled_praktikums_typen.includes("mediation")) {
<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>`, </section>`,
styles: [ styles: [
@ -165,6 +167,11 @@ import { Dashboard } from "../core/models";
.progress-row.type-propaedeutikum { background:rgba(13,110,253,.08);border-left-color:#0d6efd; } .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-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.type-mediation { background:rgba(253,126,20,.09);border-left-color:#fd7e14; }
.progress-row.type-psychotherapie { background:rgba(124,58,237,.08);border-left-color:#7c3aed; }
.progress-row.type-lsb { background:rgba(219,39,119,.08);border-left-color:#db2777; }
.progress-row.type-coaching { background:rgba(8,145,178,.08);border-left-color:#0891b2; }
.progress-row.type-supervision { background:rgba(202,138,4,.08);border-left-color:#ca8a04; }
.progress-row.type-sonstige { background:rgba(100,116,139,.08);border-left-color:#64748b; }
.progress-row div { .progress-row div {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -223,10 +230,14 @@ export class DashboardComponent implements OnInit {
return `${Math.floor(m / 60)} h ${m % 60} min`; return `${Math.floor(m / 60)} h ${m % 60} min`;
} }
label(v: string) { label(v: string) {
return v.charAt(0).toUpperCase() + v.slice(1);
const labels:Record<string,string>={lsb:"Lebens- und Sozialberatung (LSB)",sonstige:"Sonstige Leistungen"};
return labels[v] || v.charAt(0).toUpperCase() + v.slice(1);
} }
active(d: Dashboard) { active(d: Dashboard) {
return d.progress.filter((r) => r.target_minutes > 0 && (this.showZero || r.percent > 0) && (this.showCompleted || r.percent < 100)); 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}`;}
typeClass(value:string){
const type=value.toLocaleLowerCase("de-AT").normalize("NFD").replace(/[\u0300-\u036f]/g,"").replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");
return `type-${type === "propadeutikum" ? "propaedeutikum" : type || "unknown"}`;
}
} }

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

@ -4,7 +4,7 @@ import { RouterLink } from "@angular/router";
import { FormsModule } from "@angular/forms"; import { FormsModule } from "@angular/forms";
import { MATERIAL } from "../shared/material"; import { MATERIAL } from "../shared/material";
import { ApiService } from "../core/api.service"; import { ApiService } from "../core/api.service";
import { Dashboard, Entry } from "../core/models";
import { Dashboard, Entry, Settings } from "../core/models";
@Component({ @Component({
standalone: true, standalone: true,
imports: [RouterLink, FormsModule, DatePipe, DecimalPipe, ...MATERIAL], imports: [RouterLink, FormsModule, DatePipe, DecimalPipe, ...MATERIAL],
@ -30,9 +30,9 @@ import { Dashboard, Entry } from "../core/models";
<button class="timer-stop" mat-flat-button color="warn" (click)="stopTimer(timer)"><mat-icon>stop</mat-icon> Timer stoppen</button> <button class="timer-stop" mat-flat-button color="warn" (click)="stopTimer(timer)"><mat-icon>stop</mat-icon> Timer stoppen</button>
} @else { } @else {
<div><mat-icon>timer</mat-icon><span>Zeiterfassung</span><strong>Timer starten</strong></div> <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>Ausbildung</mat-label><mat-select [(ngModel)]="timerType">@for(t of types();track t){<mat-option [value]="t">{{typeLabel(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> <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>
<button class="timer-start" mat-flat-button [disabled]="!timerType || !timerArt" (click)="startTimer()"><mat-icon>play_arrow</mat-icon> Timer starten</button>
} }
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
@ -49,8 +49,8 @@ import { Dashboard, Entry } from "../core/models";
><mat-label>Ausbildung</mat-label ><mat-label>Ausbildung</mat-label
><mat-select [(ngModel)]="typ" (selectionChange)="load()" ><mat-select [(ngModel)]="typ" (selectionChange)="load()"
><mat-option value="">Alle</mat-option> ><mat-option value="">Alle</mat-option>
@for (t of types; track t) {
<mat-option [value]="t">{{ t }}</mat-option>
@for (t of filterTypes(); track t) {
<mat-option [value]="t">{{ typeLabel(t) }}</mat-option>
} }
</mat-select></mat-form-field </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>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
@ -74,7 +74,7 @@ import { Dashboard, Entry } from "../core/models";
><ng-container matColumnDef="type" ><ng-container matColumnDef="type"
><th mat-header-cell *matHeaderCellDef>Ausbildung</th> ><th mat-header-cell *matHeaderCellDef>Ausbildung</th>
<td mat-cell *matCellDef="let e"> <td mat-cell *matCellDef="let e">
<strong>{{ e.praktikums_typ }}</strong>
<strong>{{ typeLabel(e.praktikums_typ) }}</strong>
</td></ng-container </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="art"><th mat-header-cell *matHeaderCellDef>Art</th><td mat-cell *matCellDef="let e">{{e.entry_art}}</td></ng-container
><ng-container matColumnDef="time" ><ng-container matColumnDef="time"
@ -206,6 +206,16 @@ import { Dashboard, Entry } from "../core/models";
tr.mat-mdc-row.type-mediation:nth-of-type(even) > td { tr.mat-mdc-row.type-mediation:nth-of-type(even) > td {
background-color: rgba(253, 126, 20, 0.135); background-color: rgba(253, 126, 20, 0.135);
} }
tr.mat-mdc-row.type-psychotherapie:nth-of-type(odd) > td { background-color:rgba(124,58,237,.055); }
tr.mat-mdc-row.type-psychotherapie:nth-of-type(even) > td { background-color:rgba(124,58,237,.115); }
tr.mat-mdc-row.type-lsb:nth-of-type(odd) > td { background-color:rgba(219,39,119,.055); }
tr.mat-mdc-row.type-lsb:nth-of-type(even) > td { background-color:rgba(219,39,119,.115); }
tr.mat-mdc-row.type-coaching:nth-of-type(odd) > td { background-color:rgba(8,145,178,.055); }
tr.mat-mdc-row.type-coaching:nth-of-type(even) > td { background-color:rgba(8,145,178,.115); }
tr.mat-mdc-row.type-supervision:nth-of-type(odd) > td { background-color:rgba(202,138,4,.055); }
tr.mat-mdc-row.type-supervision:nth-of-type(even) > td { background-color:rgba(202,138,4,.115); }
tr.mat-mdc-row.type-sonstige:nth-of-type(odd) > td { background-color:rgba(100,116,139,.055); }
tr.mat-mdc-row.type-sonstige:nth-of-type(even) > td { background-color:rgba(100,116,139,.115); }
tr.mat-mdc-row.type-propaedeutikum:hover > td { tr.mat-mdc-row.type-propaedeutikum:hover > td {
background-color: rgba(13, 110, 253, 0.17); background-color: rgba(13, 110, 253, 0.17);
} }
@ -215,9 +225,19 @@ import { Dashboard, Entry } from "../core/models";
tr.mat-mdc-row.type-mediation:hover > td { tr.mat-mdc-row.type-mediation:hover > td {
background-color: rgba(253, 126, 20, 0.19); background-color: rgba(253, 126, 20, 0.19);
} }
tr.mat-mdc-row.type-psychotherapie:hover > td { background-color:rgba(124,58,237,.17); }
tr.mat-mdc-row.type-lsb:hover > td { background-color:rgba(219,39,119,.17); }
tr.mat-mdc-row.type-coaching:hover > td { background-color:rgba(8,145,178,.17); }
tr.mat-mdc-row.type-supervision:hover > td { background-color:rgba(202,138,4,.17); }
tr.mat-mdc-row.type-sonstige:hover > td { background-color:rgba(100,116,139,.17); }
tr.mat-mdc-row.type-propaedeutikum > td:first-child { border-left-color: #0d6efd; } 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-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.type-mediation > td:first-child { border-left-color: #fd7e14; }
tr.mat-mdc-row.type-psychotherapie > td:first-child { border-left-color:#7c3aed; }
tr.mat-mdc-row.type-lsb > td:first-child { border-left-color:#db2777; }
tr.mat-mdc-row.type-coaching > td:first-child { border-left-color:#0891b2; }
tr.mat-mdc-row.type-supervision > td:first-child { border-left-color:#ca8a04; }
tr.mat-mdc-row.type-sonstige > td:first-child { border-left-color:#64748b; }
tr.mat-mdc-row.entry-today { tr.mat-mdc-row.entry-today {
font-weight: 700; font-weight: 700;
} }
@ -236,9 +256,24 @@ import { Dashboard, Entry } from "../core/models";
: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-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(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-mediation:nth-of-type(even) > td { background-color: rgba(255, 143, 51, 0.2); }
:host-context(body.dark) tr.mat-mdc-row.type-psychotherapie:nth-of-type(odd) > td { background-color:rgba(167,139,250,.12); }
:host-context(body.dark) tr.mat-mdc-row.type-psychotherapie:nth-of-type(even) > td { background-color:rgba(167,139,250,.18); }
:host-context(body.dark) tr.mat-mdc-row.type-lsb:nth-of-type(odd) > td { background-color:rgba(244,114,182,.12); }
:host-context(body.dark) tr.mat-mdc-row.type-lsb:nth-of-type(even) > td { background-color:rgba(244,114,182,.18); }
:host-context(body.dark) tr.mat-mdc-row.type-coaching:nth-of-type(odd) > td { background-color:rgba(34,211,238,.12); }
:host-context(body.dark) tr.mat-mdc-row.type-coaching:nth-of-type(even) > td { background-color:rgba(34,211,238,.18); }
:host-context(body.dark) tr.mat-mdc-row.type-supervision:nth-of-type(odd) > td { background-color:rgba(250,204,21,.11); }
:host-context(body.dark) tr.mat-mdc-row.type-supervision:nth-of-type(even) > td { background-color:rgba(250,204,21,.17); }
:host-context(body.dark) tr.mat-mdc-row.type-sonstige:nth-of-type(odd) > td { background-color:rgba(148,163,184,.11); }
:host-context(body.dark) tr.mat-mdc-row.type-sonstige:nth-of-type(even) > td { background-color:rgba(148,163,184,.17); }
: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-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-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.type-mediation:hover > td { background-color: rgba(255, 143, 51, 0.27); }
:host-context(body.dark) tr.mat-mdc-row.type-psychotherapie:hover > td { background-color:rgba(167,139,250,.25); }
:host-context(body.dark) tr.mat-mdc-row.type-lsb:hover > td { background-color:rgba(244,114,182,.25); }
:host-context(body.dark) tr.mat-mdc-row.type-coaching:hover > td { background-color:rgba(34,211,238,.25); }
:host-context(body.dark) tr.mat-mdc-row.type-supervision:hover > td { background-color:rgba(250,204,21,.24); }
:host-context(body.dark) tr.mat-mdc-row.type-sonstige:hover > td { background-color:rgba(148,163,184,.24); }
:host-context(body.dark) tr.mat-mdc-row.entry-today > td { :host-context(body.dark) tr.mat-mdc-row.entry-today > td {
box-shadow: inset 0 3px 0 #b794f4, inset 0 -3px 0 #b794f4; box-shadow: inset 0 3px 0 #b794f4, inset 0 -3px 0 #b794f4;
} }
@ -261,17 +296,20 @@ export class EntriesComponent implements OnInit {
readonly entries = signal<Entry[]>([]); readonly entries = signal<Entry[]>([]);
readonly loading = signal(false); readonly loading = signal(false);
readonly columns = ["date", "time", "type", "art", "details", "distance", "allowance", "cost", "training", "actions"]; 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 types = signal<string[]>([]);
readonly filterTypes = signal<string[]>([]);
readonly arts = signal<Record<string,string[]>>({});
readonly typeLabels = signal<Record<string,string>>({});
readonly running=signal<Entry|null>(null); readonly running=signal<Entry|null>(null);
readonly clock=signal(Date.now()); readonly clock=signal(Date.now());
timerType="propädeutikum"; timerArt="Praktikum"; lunchBreak=false;
timerType=""; timerArt=""; lunchBreak=false;
search = ""; search = "";
typ = ""; typ = "";
minYear: number | "" = ""; minYear: number | "" = "";
maxYear: number | "" = ""; maxYear: number | "" = "";
constructor(private api: ApiService) {} constructor(private api: ApiService) {}
ngOnInit() { ngOnInit() {
this.loadSettings();
this.load(); this.load();
setInterval(()=>this.clock.set(Date.now()),30_000); setInterval(()=>this.clock.set(Date.now()),30_000);
} }
@ -286,7 +324,8 @@ export class EntriesComponent implements OnInit {
}); });
this.api.dashboard().subscribe((d:Dashboard)=>this.running.set(d.running_entry)); 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;}
timerArts(){const available=this.arts()[this.timerType]||[];if(!available.includes(this.timerArt))this.timerArt=available[0]||"";return available;}
typeLabel(type:string){return this.typeLabels()[type]||type.charAt(0).toUpperCase()+type.slice(1);}
startTimer(){this.api.startTimer(this.timerType,this.timerArt).subscribe(e=>{this.running.set(e);this.load();});} 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();});} 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`;} 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`;}
@ -328,4 +367,14 @@ export class EntriesComponent implements OnInit {
URL.revokeObjectURL(a.href); URL.revokeObjectURL(a.href);
}); });
} }
private loadSettings() {
this.api.settings().subscribe((settings: Settings) => {
this.types.set(settings.enabled_praktikums_typen);
this.filterTypes.set(settings.praktikums_typen);
this.arts.set(settings.entry_arten_by_typ);
this.typeLabels.set(settings.praktikums_typ_labels);
this.timerType = settings.enabled_praktikums_typen[0] || "";
this.timerArt = settings.entry_arten_by_typ[this.timerType]?.[0] || "";
});
}
} }

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

@ -10,6 +10,7 @@ import {
import { MatTimepickerModule } from "@angular/material/timepicker"; import { MatTimepickerModule } from "@angular/material/timepicker";
import { MATERIAL } from "../shared/material"; import { MATERIAL } from "../shared/material";
import { ApiService } from "../core/api.service"; import { ApiService } from "../core/api.service";
import { Settings } from "../core/models";
import { import {
AUSTRIAN_DATE_FORMATS, AUSTRIAN_DATE_FORMATS,
AustrianDateAdapter, AustrianDateAdapter,
@ -100,8 +101,8 @@ import {
formControlName="praktikums_typ" formControlName="praktikums_typ"
(selectionChange)="typeChanged()" (selectionChange)="typeChanged()"
> >
@for (t of types; track t) {
<mat-option [value]="t">{{ t }}</mat-option>
@for (t of types(); track t) {
<mat-option [value]="t">{{ typeLabel(t) }}</mat-option>
} }
</mat-select></mat-form-field </mat-select></mat-form-field
><mat-form-field appearance="outline" ><mat-form-field appearance="outline"
@ -142,7 +143,7 @@ import {
> >
<div class="actions footer"> <div class="actions footer">
<a mat-button routerLink="/entries">Abbrechen</a <a mat-button routerLink="/entries">Abbrechen</a
><button mat-flat-button [disabled]="form.invalid || saving()">
><button mat-flat-button [disabled]="form.invalid || saving() || configLoading()">
Speichern Speichern
</button> </button>
</div> </div>
@ -183,39 +184,11 @@ export class EntryFormComponent implements OnInit {
private readonly fb = inject(FormBuilder); private readonly fb = inject(FormBuilder);
id?: number; id?: number;
readonly saving = signal(false); 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 configLoading = signal(true);
readonly types = signal<string[]>([]);
readonly arts = signal<Record<string, string[]>>({});
readonly typeLabels = signal<Record<string, string>>({});
private currentEntryType?: string;
readonly form = this.fb.nonNullable.group({ readonly form = this.fb.nonNullable.group({
date: [this.today(), Validators.required], date: [this.today(), Validators.required],
start_time: [this.currentQuarter() as Date | null], start_time: [this.currentQuarter() as Date | null],
@ -223,8 +196,8 @@ export class EntryFormComponent implements OnInit {
hours: [0, [Validators.required, Validators.min(0)]], hours: [0, [Validators.required, Validators.min(0)]],
minutes: [0, [Validators.required, Validators.min(0), Validators.max(59)]], minutes: [0, [Validators.required, Validators.min(0), Validators.max(59)]],
break: [false], break: [false],
praktikums_typ: ["propädeutikum", Validators.required],
entry_art: ["Praktikum", Validators.required],
praktikums_typ: ["", Validators.required],
entry_art: ["", Validators.required],
distance_km: [0, Validators.min(0)], distance_km: [0, Validators.min(0)],
beschreibung: [""], beschreibung: [""],
kosten: [null as number | null], kosten: [null as number | null],
@ -247,7 +220,9 @@ export class EntryFormComponent implements OnInit {
this.id = Number(raw); this.id = Number(raw);
this.api this.api
.entry(this.id) .entry(this.id)
.subscribe((e) =>
.subscribe((e) => {
this.currentEntryType = e.praktikums_typ;
this.ensureTypeAvailable(e.praktikums_typ);
this.form.patchValue({ this.form.patchValue({
...e, ...e,
date: this.parseDate(e.date) ?? this.today(), date: this.parseDate(e.date) ?? this.today(),
@ -255,17 +230,22 @@ export class EntryFormComponent implements OnInit {
start_time: this.parseTime(e.start_time), start_time: this.parseTime(e.start_time),
end_time: this.parseTime(e.end_time), end_time: this.parseTime(e.end_time),
break: e.lunch_break_minutes === 30, break: e.lunch_break_minutes === 30,
}),
);
});
});
} }
this.loadSettings();
} }
availableArts() { availableArts() {
return this.arts[this.form.controls.praktikums_typ.value] || [];
return this.arts()[this.form.controls.praktikums_typ.value] || [];
} }
typeChanged() { typeChanged() {
const arts = this.availableArts(); const arts = this.availableArts();
if (!arts.includes(this.form.controls.entry_art.value)) if (!arts.includes(this.form.controls.entry_art.value))
this.form.controls.entry_art.setValue(arts[0]);
this.form.controls.entry_art.setValue(arts[0] || "");
}
typeLabel(type: string) {
return this.typeLabels()[type] ||
type.charAt(0).toUpperCase() + type.slice(1);
} }
calculateTime() { calculateTime() {
const { start_time: s, end_time: e, break: b } = this.form.getRawValue(); const { start_time: s, end_time: e, break: b } = this.form.getRawValue();
@ -343,4 +323,32 @@ export class EntryFormComponent implements OnInit {
? `${String(value.getHours()).padStart(2, "0")}:${String(value.getMinutes()).padStart(2, "0")}` ? `${String(value.getHours()).padStart(2, "0")}:${String(value.getMinutes()).padStart(2, "0")}`
: null; : null;
} }
private loadSettings() {
this.api.settings().subscribe({
next: (settings: Settings) => {
this.arts.set(settings.entry_arten_by_typ);
this.typeLabels.set(settings.praktikums_typ_labels);
this.types.set([...settings.enabled_praktikums_typen]);
if (this.currentEntryType) {
this.ensureTypeAvailable(this.currentEntryType);
} else {
const type = this.types()[0] || "";
this.form.patchValue({
praktikums_typ: type,
entry_art: settings.entry_arten_by_typ[type]?.[0] || "",
});
}
this.configLoading.set(false);
},
error: () => {
this.configLoading.set(false);
this.snack.open("Ausbildungsbereiche konnten nicht geladen werden", "OK");
},
});
}
private ensureTypeAvailable(type: string) {
if (!type || this.types().includes(type)) return;
this.types.update((types) => [...types, type]);
}
} }

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

@ -38,7 +38,7 @@ interface Row {
<mat-card-content> <mat-card-content>
@for (row of group.rows; track row.typ + row.art) { @for (row of group.rows; track row.typ + row.art) {
<div class="row" [class]="'row ' + typeClass(row.typ)"> <div class="row" [class]="'row ' + typeClass(row.typ)">
<div><strong>{{ row.art }}</strong><small>{{ row.typ }}</small></div>
<div><strong>{{ row.art }}</strong><small>{{ typeLabel(row.typ) }}</small></div>
<span>{{ duration(row.total_minutes) }}</span> <span>{{ duration(row.total_minutes) }}</span>
</div> </div>
} }
@ -58,9 +58,19 @@ interface Row {
.row.type-propaedeutikum{background:rgba(13,110,253,.08);border-left-color:#0d6efd} .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-fachspezifikum{background:rgba(25,135,84,.08);border-left-color:#198754}
.row.type-mediation{background:rgba(253,126,20,.10);border-left-color:#fd7e14} .row.type-mediation{background:rgba(253,126,20,.10);border-left-color:#fd7e14}
.row.type-psychotherapie{background:rgba(124,58,237,.09);border-left-color:#7c3aed}
.row.type-lsb{background:rgba(219,39,119,.09);border-left-color:#db2777}
.row.type-coaching{background:rgba(8,145,178,.09);border-left-color:#0891b2}
.row.type-supervision{background:rgba(202,138,4,.09);border-left-color:#ca8a04}
.row.type-sonstige{background:rgba(100,116,139,.09);border-left-color:#64748b}
:host-context(body.dark) .row.type-propaedeutikum{background:rgba(62,139,255,.15)} :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-fachspezifikum{background:rgba(53,184,116,.15)}
:host-context(body.dark) .row.type-mediation{background:rgba(255,143,51,.17)} :host-context(body.dark) .row.type-mediation{background:rgba(255,143,51,.17)}
:host-context(body.dark) .row.type-psychotherapie{background:rgba(167,139,250,.16)}
:host-context(body.dark) .row.type-lsb{background:rgba(244,114,182,.16)}
:host-context(body.dark) .row.type-coaching{background:rgba(34,211,238,.16)}
:host-context(body.dark) .row.type-supervision{background:rgba(250,204,21,.15)}
:host-context(body.dark) .row.type-sonstige{background:rgba(148,163,184,.15)}
.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} .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}} @media(max-width:500px){.report{grid-template-columns:1fr}.month-heading{flex-direction:column}.current-badge{align-self:flex-start}}
`], `],
@ -68,6 +78,26 @@ interface Row {
export class ReportsComponent implements OnInit { export class ReportsComponent implements OnInit {
readonly rows = signal<Row[]>([]); readonly rows = signal<Row[]>([]);
readonly loading = signal(true); readonly loading = signal(true);
private readonly typeOrder: Record<string, number> = {
propadeutikum: 0,
fachspezifikum: 1,
psychotherapie: 2,
lsb: 3,
mediation: 4,
coaching: 5,
supervision: 6,
sonstige: 7,
};
private readonly typeLabels: Record<string, string> = {
propädeutikum: "Propädeutikum",
fachspezifikum: "Fachspezifikum",
psychotherapie: "Psychotherapie",
lsb: "Lebens- und Sozialberatung (LSB)",
mediation: "Mediation",
coaching: "Coaching",
supervision: "Supervision",
sonstige: "Sonstige Leistungen",
};
constructor(private api: ApiService) {} constructor(private api: ApiService) {}
@ -83,7 +113,7 @@ export class ReportsComponent implements OnInit {
for (const row of this.rows()) map.set(row.month, [...(map.get(row.month) || []), row]); for (const row of this.rows()) map.set(row.month, [...(map.get(row.month) || []), row]);
return [...map].map(([month, rows]) => ({ return [...map].map(([month, rows]) => ({
month, month,
rows,
rows: this.sortRows(rows),
total: rows.reduce((sum, row) => sum + row.total_minutes, 0), total: rows.reduce((sum, row) => sum + row.total_minutes, 0),
})); }));
} }
@ -94,13 +124,40 @@ export class ReportsComponent implements OnInit {
} }
typeClass(type: string) { typeClass(type: string) {
const normalized = type
?.toLocaleLowerCase("de-AT")
const normalized = this.normalizedType(type);
return `type-${normalized === "propadeutikum" ? "propaedeutikum" : normalized || "unknown"}`;
}
typeLabel(type: string) {
return this.typeLabels[type] || type.charAt(0).toUpperCase() + type.slice(1);
}
private sortRows(rows: Row[]) {
return [...rows].sort((a, b) => {
const typeA = this.normalizedType(a.typ);
const typeB = this.normalizedType(b.typ);
const rankDifference =
(this.typeOrder[typeA] ?? Number.MAX_SAFE_INTEGER) -
(this.typeOrder[typeB] ?? Number.MAX_SAFE_INTEGER);
if (rankDifference !== 0) return rankDifference;
const typeDifference = a.typ.localeCompare(b.typ, "de-AT", {
sensitivity: "base",
});
if (typeDifference !== 0) return typeDifference;
return a.art.localeCompare(b.art, "de-AT", { sensitivity: "base" });
});
}
private normalizedType(type: string): string {
return (type || "")
.toLocaleLowerCase("de-AT")
.normalize("NFD") .normalize("NFD")
.replace(/[\u0300-\u036f]/g, "") .replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-") .replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, ""); .replace(/^-|-$/g, "");
return `type-${normalized === "propadeutikum" ? "propaedeutikum" : normalized || "unknown"}`;
} }
duration(minutes: number) { duration(minutes: number) {

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

@ -30,6 +30,30 @@ import { Settings } from "../core/models";
</p></mat-card-content </p></mat-card-content
></mat-card ></mat-card
> >
<mat-card
><mat-card-header
><mat-card-title>Verwendete Bereiche</mat-card-title
><mat-card-subtitle
>Nur aktivierte Bereiche stehen bei neuen Einträgen und beim Timer zur Auswahl.</mat-card-subtitle
></mat-card-header
><mat-card-content>
<div class="type-options">
@for (typ of s.praktikums_typen; track typ) {
<mat-checkbox
[checked]="isTypeEnabled(s, typ)"
(change)="setTypeEnabled(s, typ, $event.checked)"
>{{ label(s, typ) }}</mat-checkbox>
}
</div>
@if (!s.enabled_praktikums_typen.length) {
<p class="selection-error">Mindestens ein Bereich muss aktiviert sein.</p>
}
<p class="muted">
Bereits vorhandene Einträge deaktivierter Bereiche bleiben in Tabellen,
Kalender und Berichten erhalten.
</p>
</mat-card-content></mat-card
>
<mat-card <mat-card
><mat-card-header ><mat-card-header
><mat-card-title>Profil</mat-card-title></mat-card-header ><mat-card-title>Profil</mat-card-title></mat-card-header
@ -43,10 +67,10 @@ import { Settings } from "../core/models";
</p></mat-card-content </p></mat-card-content
></mat-card ></mat-card
> >
@for (typ of s.praktikums_typen; track typ) {
@for (typ of enabledTypes(s); track typ) {
<mat-card <mat-card
><mat-card-header ><mat-card-header
><mat-card-title>{{ label(typ) }}</mat-card-title
><mat-card-title>{{ label(s, typ) }}</mat-card-title
><mat-card-subtitle ><mat-card-subtitle
>Sollstunden und durchschnittliches Wochenziel</mat-card-subtitle >Sollstunden und durchschnittliches Wochenziel</mat-card-subtitle
></mat-card-header ></mat-card-header
@ -107,7 +131,7 @@ import { Settings } from "../core/models";
></mat-card ></mat-card
> >
<div class="actions footer"> <div class="actions footer">
<button mat-flat-button (click)="save()" [disabled]="saving()">
<button mat-flat-button (click)="save()" [disabled]="saving() || !s.enabled_praktikums_typen.length">
Änderungen speichern Änderungen speichern
</button> </button>
</div> </div>
@ -133,6 +157,16 @@ import { Settings } from "../core/models";
.targets mat-form-field { .targets mat-form-field {
margin-top: 8px; margin-top: 8px;
} }
.type-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 10px 18px;
}
.selection-error {
color: var(--mat-sys-error);
font-weight: 600;
margin: 14px 0 0;
}
.footer { .footer {
justify-content: flex-end; justify-content: flex-end;
position: sticky; position: sticky;
@ -179,8 +213,24 @@ export class SettingsComponent implements OnInit {
error: () => this.loading.set(false), error: () => this.loading.set(false),
}); });
} }
label(v: string) {
return v.charAt(0).toUpperCase() + v.slice(1);
label(settings: Settings, value: string) {
return settings.praktikums_typ_labels[value] ||
value.charAt(0).toUpperCase() + value.slice(1);
}
isTypeEnabled(settings: Settings, type: string) {
return settings.enabled_praktikums_typen.includes(type);
}
setTypeEnabled(settings: Settings, type: string, enabled: boolean) {
settings.enabled_praktikums_typen = enabled
? settings.praktikums_typen.filter((value) =>
value === type || settings.enabled_praktikums_typen.includes(value),
)
: settings.enabled_praktikums_typen.filter((value) => value !== type);
}
enabledTypes(settings: Settings) {
return settings.praktikums_typen.filter((type) =>
settings.enabled_praktikums_typen.includes(type),
);
} }
save() { save() {
const s = this.settings(); const s = this.settings();

Loading…
Cancel
Save