diff --git a/app/controllers/entries_controller.rb b/app/controllers/entries_controller.rb index d5d8837..fbef89a 100644 --- a/app/controllers/entries_controller.rb +++ b/app/controllers/entries_controller.rb @@ -1,163 +1,203 @@ class EntriesController < ApplicationController before_action :authenticate_user! - before_action :set_entry, only: %i[edit update destroy stop_timer] + before_action :set_entry, only: %i[ + edit + update + destroy + stop_timer + ] + + # ============================================================ + # INDEX + # ============================================================ def index @entries = current_user.entries.order(date: :desc) - # Für Berechnungen nur Einträge bis einschließlich heute verwenden. - # Keine Sortierung setzen, da diese Relation später unterschiedlich - # ausgewertet wird. - @calculation_entries = current_user.entries - .where("date <= ?", Date.current) + # Für sämtliche Fortschrittsberechnungen nur Einträge + # bis einschließlich heute berücksichtigen. + # + # Zukünftige geplante Einträge erscheinen weiterhin in + # @entries, verändern aber noch nicht den Fortschritt. + @calculation_entries = + current_user.entries.where("date <= ?", Date.current) + + @running_entry = + current_user.entries.find_by( + end_time: nil, + beschreibung: "Timer" + ) - @running_entry = current_user.entries.find_by( - end_time: nil, - beschreibung: "Timer" - ) - + # ---------------------------------------------------------- + # Gesamtzeiten + # ---------------------------------------------------------- - required_hours_matrix = current_user.required_hours_matrix.to_h - weekly_target_matrix = current_user.weekly_target_matrix.to_h + @total_minutes = + sum_minutes(@calculation_entries) - @total_minutes = sum_minutes(@calculation_entries) + @total_minutes_praktikum_prop = + sum_minutes( + @calculation_entries.where( + praktikums_typ: "propädeutikum", + entry_art: "Praktikum" + ) + ) - @total_minutes_praktikum_prop = sum_minutes( - @calculation_entries.where( - praktikums_typ: Entry::PRAKTIKUMSTYPEN[0], - entry_art: Entry::ENTRY_ARTEN[0] + @total_minutes_praktikum_fach = + sum_minutes( + @calculation_entries.where( + praktikums_typ: "fachspezifikum", + entry_art: "Praktikum" + ) ) - ) - @total_minutes_praktikum_fach = sum_minutes( - @calculation_entries.where( - praktikums_typ: Entry::PRAKTIKUMSTYPEN[1], - entry_art: Entry::ENTRY_ARTEN[0] + @total_minutes_praktikum_mediation = + sum_minutes( + @calculation_entries.where( + praktikums_typ: "mediation" + ) ) - ) - @total_kilometer_pauschale = @entries.sum(&:kilometer_pauschale) + # ---------------------------------------------------------- + # Kilometer + # ---------------------------------------------------------- - # Bereits geleistete Zeit je Kombination aus Typ und Art - @time_by_typ_art = @calculation_entries - .group_by(&:praktikums_typ) - .transform_values do |type_entries| - type_entries - .group_by(&:entry_art) - .transform_values { |entries| sum_minutes(entries) } - end + @total_kilometer_pauschale = + @entries.sum(&:kilometer_pauschale) - @remaining_minutes_matrix = {} - @spent_minutes_matrix = {} + # ---------------------------------------------------------- + # Tatsächlich geleistete Zeit nach Typ / Art + # ---------------------------------------------------------- - @time_by_typ_art.each do |typ, arts| - @remaining_minutes_matrix[typ] ||= {} - @spent_minutes_matrix[typ] ||= {} - - arts.each do |art, spent_minutes| - required_hours = required_hours_matrix.dig(typ, art).to_f - - # Erst nach der Multiplikation runden, damit auch Dezimalstunden - # wie 0.5 korrekt als 30 Minuten berechnet werden. - required_minutes = (required_hours * 60).round - - remaining_minutes = [ - required_minutes - spent_minutes, - 0 - ].max - - @remaining_minutes_matrix[typ][art] = remaining_minutes - @spent_minutes_matrix[typ][art] = spent_minutes - end - end + build_time_by_typ_art - @completion_percent_by_typ_art = {} - @progress_chart_data = {} - @progress_colors = {} + # ---------------------------------------------------------- + # Soll / Erledigt / Verbleibend + # ---------------------------------------------------------- - @spent_minutes_matrix.each do |typ, arts| - arts.each do |art, spent_minutes| - required_hours = required_hours_matrix.dig(typ, art).to_f - required_minutes = (required_hours * 60).round - - next unless required_minutes.positive? - next unless spent_minutes.positive? - - completed_minutes = [spent_minutes, required_minutes].min - - percent = ( - completed_minutes / required_minutes.to_f * 100 - ).round - - percent = [percent, 100].min - - key = "#{typ.capitalize} – #{art}" - - @completion_percent_by_typ_art[[typ, art]] = percent - @progress_chart_data[key] = percent - - @progress_colors[key] = - if percent < 50 - "#007bff" - elsif percent < 90 - "#28a745" - else - "#dc3545" - end - end - end + build_progress_matrices + + # ---------------------------------------------------------- + # Prozentwerte + Diagramm + # ---------------------------------------------------------- - @total_kilometer_costs_by_year = + build_completion_data + + # ---------------------------------------------------------- + # Kosten + # ---------------------------------------------------------- + + @total_kilometer_costs_by_year = Entry.total_kilometer_cost_by_year(current_user) - @fortbildungskosten_by_year = + @fortbildungskosten_by_year = Entry.total_fortbildungskosten_by_year(current_user) - @selbstsupervision_by_year = + @selbstsupervision_by_year = Entry.total_supervision_by_year(current_user) @selbsterfahrungskosten_by_year = Entry.total_selbsterfahrungskosten_by_year(current_user) - @allekosten_by_year = + @allekosten_by_year = Entry.total_semesterkosten_by_year(current_user) - @gesamtkosten_by_year = + @gesamtkosten_by_year = Entry.total_gesamtkosten_by_year(current_user) - calculate_estimated_end_dates(weekly_target_matrix) - calculate_actual_hours_per_week(required_hours_matrix) + # ---------------------------------------------------------- + # Hochrechnungen + # ---------------------------------------------------------- + + calculate_estimated_end_dates + calculate_actual_hours_per_week end + # ============================================================ + # NEW + # ============================================================ + def new @entry = current_user.entries.build end + # ============================================================ + # TIMER START + # ============================================================ + def start_timer typ = params[:typ] art = params[:art] + unless Entry::PRAKTIKUMSTYPEN.include?(typ) + redirect_to entries_path, + alert: "Ungültiger Ausbildungstyp." + return + end + + unless Entry::ENTRY_ARTEN.include?(art) + redirect_to entries_path, + alert: "Ungültige Eintragsart." + return + end + + if current_user.praepedeutikum_abgeschlossen? && + typ == "propädeutikum" + + redirect_to entries_path, + alert: "Propädeutikum ist bereits abgeschlossen – Neuer Eintrag dieses Typs ist nicht erlaubt." + + return + end + + if current_user.entries.exists?( + end_time: nil, + beschreibung: "Timer" + ) + redirect_to entries_path, + alert: "Es läuft bereits ein Timer." + return + end + current_user.entries.create!( + date: Date.current, start_time: Time.current, beschreibung: "Timer", praktikums_typ: typ, entry_art: art ) - redirect_to entries_path, notice: "Timer gestartet" + redirect_to entries_path, + notice: "Timer gestartet" end + # ============================================================ + # TIMER STOP + # ============================================================ + def stop_timer + unless @entry.start_time.present? + redirect_to entries_path, + alert: "Dieser Eintrag besitzt keine Startzeit." + return + end + @entry.end_time = Time.current if ActiveModel::Type::Boolean.new.cast(params[:lunch_break]) @entry.lunch_break_minutes = 30 + else + @entry.lunch_break_minutes ||= 0 end - total_minutes = @entry.total_minutes_including_break + total_minutes = + @entry.total_minutes_including_break.to_i + + @entry.hours = + total_minutes / 60 - @entry.hours = total_minutes / 60 - @entry.minutes = total_minutes % 60 + @entry.minutes = + total_minutes % 60 @entry.save! @@ -165,11 +205,16 @@ class EntriesController < ApplicationController notice: "Eintrag gestoppt – Gesamtzeit: #{@entry.hours} h #{@entry.minutes} min" end + # ============================================================ + # CREATE + # ============================================================ + def create - @entry = current_user.entries.new(entry_params) + @entry = + current_user.entries.new(entry_params) if current_user.praepedeutikum_abgeschlossen? && - @entry.praktikums_typ == Entry::PRAKTIKUMSTYPEN[0] + @entry.praktikums_typ == "propädeutikum" redirect_to entries_path, alert: "Propädeutikum ist bereits abgeschlossen – Neuer Eintrag dieses Typs ist nicht erlaubt." @@ -178,31 +223,53 @@ class EntriesController < ApplicationController end if @entry.save - redirect_to entries_path, notice: "Eintrag gespeichert" + redirect_to entries_path, + notice: "Eintrag gespeichert" else - render :new, status: :unprocessable_entity + render :new, + status: :unprocessable_entity end end + # ============================================================ + # EDIT + # ============================================================ + def edit end + # ============================================================ + # UPDATE + # ============================================================ + def update if @entry.update(entry_params) - redirect_to entries_path, notice: "Eintrag aktualisiert" + redirect_to entries_path, + notice: "Eintrag aktualisiert" else - render :edit, status: :unprocessable_entity + render :edit, + status: :unprocessable_entity end end + # ============================================================ + # DESTROY + # ============================================================ + def destroy @entry.destroy! - redirect_to entries_path, notice: "Eintrag gelöscht" + redirect_to entries_path, + notice: "Eintrag gelöscht" end + # ============================================================ + # CSV EXPORT + # ============================================================ + def export_csv - @entries = current_user.entries.order(date: :desc) + @entries = + current_user.entries.order(date: :desc) respond_to do |format| format.csv do @@ -214,28 +281,44 @@ class EntriesController < ApplicationController end end + # ============================================================ + # MONATSBERICHT + # ============================================================ + def monthly_report - rows = current_user.entries - .select( - Arel.sql("DATE_TRUNC('month', date) AS month"), - :praktikums_typ, - :entry_art, - Arel.sql("SUM(hours * 60 + minutes) AS total_minutes") - ) - .group( - Arel.sql("DATE_TRUNC('month', date)"), - :praktikums_typ, - :entry_art - ) - .order( - Arel.sql("DATE_TRUNC('month', date) DESC") - ) + rows = + current_user.entries + .select( + Arel.sql( + "DATE_TRUNC('month', date) AS month" + ), + :praktikums_typ, + :entry_art, + Arel.sql( + "SUM(COALESCE(hours, 0) * 60 + COALESCE(minutes, 0)) AS total_minutes" + ) + ) + .group( + Arel.sql( + "DATE_TRUNC('month', date)" + ), + :praktikums_typ, + :entry_art + ) + .order( + Arel.sql( + "DATE_TRUNC('month', date) DESC" + ) + ) @report = {} rows.each do |row| - month = row.attributes["month"].to_date - total_minutes = row.attributes["total_minutes"].to_i + month = + row.attributes["month"].to_date + + total_minutes = + row.attributes["total_minutes"].to_i next if total_minutes.zero? @@ -256,73 +339,263 @@ class EntriesController < ApplicationController private + # ============================================================ + # ENTRY LADEN + # ============================================================ + def set_entry - @entry = current_user.entries.find(params[:id]) + @entry = + current_user.entries.find(params[:id]) end + # ============================================================ + # MINUTEN SUMMIEREN + # ============================================================ + def sum_minutes(entries) entries.sum do |entry| - entry.hours.to_i * 60 + entry.minutes.to_i + entry.hours.to_i * 60 + + entry.minutes.to_i + end + end + + # ============================================================ + # GELEISTETE ZEIT NACH TYP / ART + # ============================================================ + + def build_time_by_typ_art + @time_by_typ_art = + @calculation_entries + .group_by(&:praktikums_typ) + .transform_values do |type_entries| + + type_entries + .group_by(&:entry_art) + .transform_values do |entries| + sum_minutes(entries) + end + end + end + + # ============================================================ + # FORTSCHRITTSMATRIZEN + # + # Wichtig: + # + # Nicht nur Kombinationen mit bestehenden Entries erzeugen, + # sondern ALLE bekannten Kombinationen. + # + # Dadurch existiert z. B. + # + # Mediation / Peergruppenarbeit + # 0 h erledigt / 24 h offen + # + # auch bevor der erste Entry angelegt wurde. + # ============================================================ + + def build_progress_matrices + @remaining_minutes_matrix = {} + @spent_minutes_matrix = {} + + Entry::PRAKTIKUMSTYPEN.each do |typ| + @remaining_minutes_matrix[typ] = {} + @spent_minutes_matrix[typ] = {} + + Entry::ENTRY_ARTEN.each do |art| + spent_minutes = + @time_by_typ_art + .dig(typ, art) + .to_i + + required_hours = + current_user + .required_hours_for(typ, art) + .to_f + + required_minutes = + (required_hours * 60).round + + remaining_minutes = [ + required_minutes - spent_minutes, + 0 + ].max + + @spent_minutes_matrix[typ][art] = + spent_minutes + + @remaining_minutes_matrix[typ][art] = + remaining_minutes + end end end - def calculate_estimated_end_dates(weekly_target_matrix) + # ============================================================ + # FORTSCHRITT IN PROZENT + # ============================================================ + + def build_completion_data + @completion_percent_by_typ_art = {} + @progress_chart_data = {} + @progress_colors = {} + + @spent_minutes_matrix.each do |typ, arts| + arts.each do |art, spent_minutes| + required_hours = + current_user + .required_hours_for(typ, art) + .to_f + + required_minutes = + (required_hours * 60).round + + # Kategorien ohne Sollwert wie Fortbildung oder + # Semesterkosten gehören nicht in die Fortschrittsrechnung. + next unless required_minutes.positive? + + completed_minutes = [ + spent_minutes, + required_minutes + ].min + + percent = + if required_minutes.positive? + ( + completed_minutes / + required_minutes.to_f * + 100 + ).round + else + 0 + end + + percent = [ + percent, + 100 + ].min + + @completion_percent_by_typ_art[ + [typ, art] + ] = percent + + # Im Chart nur Kategorien anzeigen, bei denen tatsächlich + # bereits etwas erledigt wurde. + # + # Die Tabelle kennt trotzdem auch 0-%-Anforderungen. + next unless spent_minutes.positive? + + key = + "#{typ.capitalize} – #{art}" + + @progress_chart_data[key] = + percent + + @progress_colors[key] = + progress_color_for(percent) + end + end + end + + # ============================================================ + # FARBE FÜR FORTSCHRITT + # ============================================================ + + def progress_color_for(percent) + if percent < 50 + "#007bff" + elsif percent < 90 + "#28a745" + else + "#dc3545" + end + end + + # ============================================================ + # VORAUSSICHTLICHES ENDDATUM + # ============================================================ + + def calculate_estimated_end_dates @estimated_end_by_typ_art = {} @remaining_minutes_matrix.each do |typ, arts| - @estimated_end_by_typ_art[typ] ||= {} + @estimated_end_by_typ_art[typ] = {} arts.each do |art, remaining_minutes| - hours_remaining = remaining_minutes / 60.0 - weekly_hours = weekly_target_matrix.dig(typ, art).to_f + hours_remaining = + remaining_minutes / 60.0 + + weekly_hours = + current_user + .weekly_target_for(typ, art) + .to_f - if weekly_hours.positive? && hours_remaining.positive? - weeks_remaining = ( - hours_remaining / weekly_hours - ).ceil + if weekly_hours.positive? && + hours_remaining.positive? + + weeks_remaining = + ( + hours_remaining / + weekly_hours + ).ceil @estimated_end_by_typ_art[typ][art] = - Date.current + weeks_remaining.weeks + Date.current + + weeks_remaining.weeks else - @estimated_end_by_typ_art[typ][art] = nil + @estimated_end_by_typ_art[typ][art] = + nil end end end end - def calculate_actual_hours_per_week(required_hours_matrix) + # ============================================================ + # TATSÄCHLICHE STUNDEN PRO WOCHE + # ============================================================ + + def calculate_actual_hours_per_week @actual_hours_per_week = {} Entry::PRAKTIKUMSTYPEN .product(Entry::ENTRY_ARTEN) .each do |typ, art| - required_hours = required_hours_matrix.dig(typ, art).to_f + required_hours = + current_user + .required_hours_for(typ, art) + .to_f + # Ohne Sollwert ergibt eine Fortschrittsgeschwindigkeit + # keinen sinnvollen Wert. next unless required_hours.positive? - entries = @calculation_entries - .where( - praktikums_typ: typ, - entry_art: art - ) - .where( - "COALESCE(hours, 0) > 0 OR COALESCE(minutes, 0) > 0" - ) + entries = + @calculation_entries + .where( + praktikums_typ: typ, + entry_art: art + ) + .where( + "COALESCE(hours, 0) > 0 OR COALESCE(minutes, 0) > 0" + ) next unless entries.exists? - spent_minutes = entries.sum do |entry| - entry.hours.to_i * 60 + entry.minutes.to_i - end + spent_minutes = + sum_minutes(entries) + + first_entry_date = + entries.minimum(:date) - first_entry_date = entries.minimum(:date) - last_entry_date = entries.maximum(:date) + last_entry_date = + entries.maximum(:date) next unless first_entry_date - required_minutes = (required_hours * 60).round - completed = spent_minutes >= required_minutes + required_minutes = + (required_hours * 60).round + + completed = + spent_minutes >= required_minutes period_end = if completed @@ -331,19 +604,34 @@ class EntriesController < ApplicationController Date.current end - total_days = (period_end - first_entry_date).to_i + 1 - total_days = [total_days, 7].max + total_days = + (period_end - first_entry_date).to_i + 1 - weeks = total_days / 7.0 + # Mindestens eine Woche als Berechnungsbasis, + # damit einzelne Tagesblöcke keine künstlich extrem + # hohen Wochenwerte erzeugen. + total_days = [ + total_days, + 7 + ].max + + weeks = + total_days / 7.0 actual_hours_per_week = - (spent_minutes / 60.0) / weeks + (spent_minutes / 60.0) / + weeks - @actual_hours_per_week[[typ, art]] = - actual_hours_per_week.round(2) + @actual_hours_per_week[ + [typ, art] + ] = actual_hours_per_week.round(2) end end + # ============================================================ + # STRONG PARAMS + # ============================================================ + def entry_params params.require(:entry).permit( :date,