diff --git a/app/controllers/api/v1/dashboard_controller.rb b/app/controllers/api/v1/dashboard_controller.rb index 2feaeaf..c0af570 100644 --- a/app/controllers/api/v1/dashboard_controller.rb +++ b/app/controllers/api/v1/dashboard_controller.rb @@ -4,7 +4,8 @@ module Api def show current_user.update_required_matrices! 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| 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 @@ -28,6 +29,7 @@ module Api 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"), + enabled_praktikums_typen: enabled_types, 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: } diff --git a/app/controllers/api/v1/entries_controller.rb b/app/controllers/api/v1/entries_controller.rb index a811c94..84f6fb1 100644 --- a/app/controllers/api/v1/entries_controller.rb +++ b/app/controllers/api/v1/entries_controller.rb @@ -20,11 +20,16 @@ module Api 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) + 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) 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? + 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) 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") 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_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) end @@ -69,6 +75,11 @@ module Api current_user.praepedeutikum_abgeschlossen? && entry.praktikums_typ == "propädeutikum" 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) 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 diff --git a/app/controllers/api/v1/settings_controller.rb b/app/controllers/api/v1/settings_controller.rb index c9594ba..b599e78 100644 --- a/app/controllers/api/v1/settings_controller.rb +++ b/app/controllers/api/v1/settings_controller.rb @@ -6,13 +6,41 @@ module Api 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) + 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 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) + 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 diff --git a/app/models/entry.rb b/app/models/entry.rb index 15d6275..f3dc16c 100644 --- a/app/models/entry.rb +++ b/app/models/entry.rb @@ -7,34 +7,8 @@ class Entry < ApplicationRecord # 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 = { "Peergroup" => "Peergruppenarbeit", @@ -428,4 +402,4 @@ class Entry < ApplicationRecord total % 60 end end -end \ No newline at end of file +end diff --git a/app/models/user.rb b/app/models/user.rb index 00a63f8..bd51e20 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -13,34 +13,30 @@ class User < ApplicationRecord PRAKTIKUMSTYPEN = %w[ propädeutikum fachspezifikum + psychotherapie + lsb mediation + coaching + supervision + sonstige ].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 @@ -69,6 +65,29 @@ class User < ApplicationRecord "Einzel-/Kleingruppensupervision", "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" => [ "Präsenzmodul", @@ -80,8 +99,44 @@ class User < ApplicationRecord "Literatur- und Selbststudium", "Fortbildung", "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 + + ENTRY_ARTEN = ENTRY_ARTEN_BY_TYP.values.flatten.uniq.freeze # ------------------------------------------------------------ # Mediation – zusätzliche Anforderungen @@ -96,6 +151,8 @@ class User < ApplicationRecord MEDIATION_EINZELSUPERVISION_MINDESTENS = 3 after_initialize :set_default, if: :new_record? + before_validation :normalize_enabled_praktikums_typen + validate :at_least_one_praktikums_typ_enabled # ------------------------------------------------------------ # Allgemein @@ -112,6 +169,15 @@ class User < ApplicationRecord def self.entry_arten_for(typ) ENTRY_ARTEN_BY_TYP[typ.to_s] || ENTRY_ARTEN 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 @@ -135,7 +201,7 @@ class User < ApplicationRecord required[typ] ||= {} weekly[typ] ||= {} - ENTRY_ARTEN.each do |art| + self.class.entry_arten_for(typ).each do |art| unless required[typ].key?(art) required[typ][art] = default_hours_for(typ, art) end @@ -346,10 +412,11 @@ class User < ApplicationRecord # ------------------------------------------------------------ def set_default + self.enabled_praktikums_typen ||= DEFAULT_ENABLED_PRAKTIKUMSTYPEN.dup self.required_hours_matrix ||= PRAKTIKUMSTYPEN.to_h do |typ| [ typ, - ENTRY_ARTEN.to_h do |art| + self.class.entry_arten_for(typ).to_h do |art| [art, default_hours_for(typ, art)] end ] @@ -358,7 +425,7 @@ class User < ApplicationRecord self.weekly_target_matrix ||= PRAKTIKUMSTYPEN.to_h do |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)] end ] @@ -368,6 +435,18 @@ class User < ApplicationRecord # ------------------------------------------------------------ # 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) case art.to_s diff --git a/config/initializers/build_version.rb b/config/initializers/build_version.rb index 0b1e326..a93c30d 100644 --- a/config/initializers/build_version.rb +++ b/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") diff --git a/db/migrate/20260820000000_add_enabled_praktikums_typen_to_users.rb b/db/migrate/20260820000000_add_enabled_praktikums_typen_to_users.rb new file mode 100644 index 0000000..52e39d3 --- /dev/null +++ b/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 diff --git a/db/schema.rb b/db/schema.rb index db3dc2b..186f7f6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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 enable_extension "pg_stat_statements" enable_extension "plpgsql" @@ -188,6 +188,9 @@ ActiveRecord::Schema[7.1].define(version: 2025_12_09_043625) do t.datetime "confirmation_sent_at" t.string "unconfirmed_email" 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 ["email"], name: "index_users_on_email", unique: true t.index ["praepedeutikum_done"], name: "index_users_on_praepedeutikum_done" diff --git a/frontend/src/app/core/models.ts b/frontend/src/app/core/models.ts index 12f4bc3..d2e5234 100644 --- a/frontend/src/app/core/models.ts +++ b/frontend/src/app/core/models.ts @@ -49,6 +49,7 @@ export interface Dashboard { }[]; last_entry: Entry | null; running_entry: Entry | null; + enabled_praktikums_typen: string[]; mediation_presence_days: number; mediation_presence_days_required: number; progress: Progress[]; @@ -61,6 +62,8 @@ export interface Settings { required_hours_matrix: Record>; weekly_target_matrix: Record>; praktikums_typen: string[]; + enabled_praktikums_typen: string[]; + praktikums_typ_labels: Record; entry_arten: string[]; entry_arten_by_typ: Record; } diff --git a/frontend/src/app/pages/calendar.component.ts b/frontend/src/app/pages/calendar.component.ts index ab751e9..a6be45b 100644 --- a/frontend/src/app/pages/calendar.component.ts +++ b/frontend/src/app/pages/calendar.component.ts @@ -3,7 +3,7 @@ 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"; +import { Entry, Settings } from "../core/models"; @Component({ standalone: true, imports: [RouterLink, FormsModule, ...MATERIAL], @@ -27,11 +27,11 @@ import { Entry } from "../core/models"; Ausbildung Alle - @for (type of types; track type) { {{ type }} } + @for (type of types(); track type) { {{ typeLabel(type) }} } Art Alle - @for (art of arts; track art) { {{ art }} } + @for (art of arts(); track art) { {{ art }} } @@ -124,6 +124,24 @@ import { Entry } from "../core/models"; display: grid; 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 { display: flex; 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-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-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) { .calendar-filters mat-card-content { gap: 8px; @@ -243,13 +266,19 @@ export class CalendarComponent implements OnInit { readonly entries = signal([]); 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"]; + readonly types = signal([]); + readonly arts = signal([]); + readonly typeLabels = signal>({}); typeFilter = ""; artFilter = ""; readonly todayKey = this.key(new Date()); constructor(private api: ApiService) {} 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(); } 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)); } clearFilters(){this.typeFilter="";this.artFilter="";} + typeLabel(type:string){return this.typeLabels()[type]||type.charAt(0).toUpperCase()+type.slice(1);} 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"}`; diff --git a/frontend/src/app/pages/dashboard.component.ts b/frontend/src/app/pages/dashboard.component.ts index 5bb6cc7..9937d41 100644 --- a/frontend/src/app/pages/dashboard.component.ts +++ b/frontend/src/app/pages/dashboard.component.ts @@ -111,18 +111,20 @@ import { Dashboard } from "../core/models"; - Mediation – Präsenztage{{ d.mediation_presence_days }} von - {{ d.mediation_presence_days_required }} Tagen erledigt + @if (d.enabled_praktikums_typen.includes("mediation")) { + Mediation – Präsenztage{{ d.mediation_presence_days }} von + {{ d.mediation_presence_days_required }} Tagen erledigt + } } `, 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-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-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 { display: flex; flex-direction: column; @@ -223,10 +230,14 @@ export class DashboardComponent implements OnInit { return `${Math.floor(m / 60)} h ${m % 60} min`; } label(v: string) { - return v.charAt(0).toUpperCase() + v.slice(1); + const labels:Record={lsb:"Lebens- und Sozialberatung (LSB)",sonstige:"Sonstige Leistungen"}; + return labels[v] || 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}`;} + 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"}`; + } } diff --git a/frontend/src/app/pages/entries.component.ts b/frontend/src/app/pages/entries.component.ts index 8a366fd..840329a 100644 --- a/frontend/src/app/pages/entries.component.ts +++ b/frontend/src/app/pages/entries.component.ts @@ -4,7 +4,7 @@ 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"; +import { Dashboard, Entry, Settings } from "../core/models"; @Component({ standalone: true, imports: [RouterLink, FormsModule, DatePipe, DecimalPipe, ...MATERIAL], @@ -30,9 +30,9 @@ import { Dashboard, Entry } from "../core/models"; } @else {
timerZeiterfassungTimer starten
- Ausbildung@for(t of types;track t){{{t}}} + Ausbildung@for(t of types();track t){{{typeLabel(t)}}} Art@for(a of timerArts();track a){{{a}}} - + } @@ -49,8 +49,8 @@ import { Dashboard, Entry } from "../core/models"; >AusbildungAlle - @for (t of types; track t) { - {{ t }} + @for (t of filterTypes(); track t) { + {{ typeLabel(t) }} } Von JahrAlle@for(y of years();track y){{{y}}}Ausbildung - {{ e.praktikums_typ }} + {{ typeLabel(e.praktikums_typ) }} Art{{e.entry_art}} td { 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 { 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 { 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-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-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 { 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-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-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-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-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 { box-shadow: inset 0 3px 0 #b794f4, inset 0 -3px 0 #b794f4; } @@ -261,17 +296,20 @@ export class EntriesComponent implements OnInit { readonly entries = signal([]); 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 = {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([]); + readonly filterTypes = signal([]); + readonly arts = signal>({}); + readonly typeLabels = signal>({}); readonly running=signal(null); readonly clock=signal(Date.now()); - timerType="propädeutikum"; timerArt="Praktikum"; lunchBreak=false; + timerType=""; timerArt=""; lunchBreak=false; search = ""; typ = ""; minYear: number | "" = ""; maxYear: number | "" = ""; constructor(private api: ApiService) {} ngOnInit() { + this.loadSettings(); this.load(); 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)); } - 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();});} 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`;} @@ -328,4 +367,14 @@ export class EntriesComponent implements OnInit { 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] || ""; + }); + } } diff --git a/frontend/src/app/pages/entry-form.component.ts b/frontend/src/app/pages/entry-form.component.ts index cbd4005..ff42c0d 100644 --- a/frontend/src/app/pages/entry-form.component.ts +++ b/frontend/src/app/pages/entry-form.component.ts @@ -10,6 +10,7 @@ import { import { MatTimepickerModule } from "@angular/material/timepicker"; import { MATERIAL } from "../shared/material"; import { ApiService } from "../core/api.service"; +import { Settings } from "../core/models"; import { AUSTRIAN_DATE_FORMATS, AustrianDateAdapter, @@ -100,8 +101,8 @@ import { formControlName="praktikums_typ" (selectionChange)="typeChanged()" > - @for (t of types; track t) { - {{ t }} + @for (t of types(); track t) { + {{ typeLabel(t) }} } @@ -183,39 +184,11 @@ 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 = { - 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([]); + readonly arts = signal>({}); + readonly typeLabels = signal>({}); + private currentEntryType?: string; readonly form = this.fb.nonNullable.group({ date: [this.today(), Validators.required], start_time: [this.currentQuarter() as Date | null], @@ -223,8 +196,8 @@ export class EntryFormComponent implements OnInit { 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], + praktikums_typ: ["", Validators.required], + entry_art: ["", Validators.required], distance_km: [0, Validators.min(0)], beschreibung: [""], kosten: [null as number | null], @@ -247,7 +220,9 @@ export class EntryFormComponent implements OnInit { this.id = Number(raw); this.api .entry(this.id) - .subscribe((e) => + .subscribe((e) => { + this.currentEntryType = e.praktikums_typ; + this.ensureTypeAvailable(e.praktikums_typ); this.form.patchValue({ ...e, date: this.parseDate(e.date) ?? this.today(), @@ -255,17 +230,22 @@ export class EntryFormComponent implements OnInit { start_time: this.parseTime(e.start_time), end_time: this.parseTime(e.end_time), break: e.lunch_break_minutes === 30, - }), - ); + }); + }); } + this.loadSettings(); } availableArts() { - return this.arts[this.form.controls.praktikums_typ.value] || []; + 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]); + this.form.controls.entry_art.setValue(arts[0] || ""); + } + typeLabel(type: string) { + return this.typeLabels()[type] || + type.charAt(0).toUpperCase() + type.slice(1); } calculateTime() { 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")}` : 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]); + } } diff --git a/frontend/src/app/pages/reports.component.ts b/frontend/src/app/pages/reports.component.ts index 82e06e0..0788dd8 100644 --- a/frontend/src/app/pages/reports.component.ts +++ b/frontend/src/app/pages/reports.component.ts @@ -38,7 +38,7 @@ interface Row { @for (row of group.rows; track row.typ + row.art) {
-
{{ row.art }}{{ row.typ }}
+
{{ row.art }}{{ typeLabel(row.typ) }}
{{ duration(row.total_minutes) }}
} @@ -58,9 +58,19 @@ interface Row { .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} + .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-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-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} @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 { readonly rows = signal([]); readonly loading = signal(true); + private readonly typeOrder: Record = { + propadeutikum: 0, + fachspezifikum: 1, + psychotherapie: 2, + lsb: 3, + mediation: 4, + coaching: 5, + supervision: 6, + sonstige: 7, + }; + private readonly typeLabels: Record = { + 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) {} @@ -83,7 +113,7 @@ export class ReportsComponent implements OnInit { for (const row of this.rows()) map.set(row.month, [...(map.get(row.month) || []), row]); return [...map].map(([month, rows]) => ({ month, - rows, + rows: this.sortRows(rows), total: rows.reduce((sum, row) => sum + row.total_minutes, 0), })); } @@ -94,13 +124,40 @@ export class ReportsComponent implements OnInit { } 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") .replace(/[\u0300-\u036f]/g, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); - return `type-${normalized === "propadeutikum" ? "propaedeutikum" : normalized || "unknown"}`; } duration(minutes: number) { diff --git a/frontend/src/app/pages/settings.component.ts b/frontend/src/app/pages/settings.component.ts index 1ebc829..0cf6c7b 100644 --- a/frontend/src/app/pages/settings.component.ts +++ b/frontend/src/app/pages/settings.component.ts @@ -30,6 +30,30 @@ import { Settings } from "../core/models";

+ Verwendete BereicheNur aktivierte Bereiche stehen bei neuen Einträgen und beim Timer zur Auswahl. +
+ @for (typ of s.praktikums_typen; track typ) { + {{ label(s, typ) }} + } +
+ @if (!s.enabled_praktikums_typen.length) { +

Mindestens ein Bereich muss aktiviert sein.

+ } +

+ Bereits vorhandene Einträge deaktivierter Bereiche bleiben in Tabellen, + Kalender und Berichten erhalten. +

+
Profil - @for (typ of s.praktikums_typen; track typ) { + @for (typ of enabledTypes(s); track typ) { {{ label(typ) }}{{ label(s, typ) }}Sollstunden und durchschnittliches Wochenziel @@ -133,6 +157,16 @@ import { Settings } from "../core/models"; .targets mat-form-field { 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 { justify-content: flex-end; position: sticky; @@ -179,8 +213,24 @@ export class SettingsComponent implements OnInit { 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() { const s = this.settings();