diff --git a/app/controllers/entries_controller.rb b/app/controllers/entries_controller.rb index de31e06..26ba2e0 100644 --- a/app/controllers/entries_controller.rb +++ b/app/controllers/entries_controller.rb @@ -3,155 +3,128 @@ class EntriesController < ApplicationController before_action :set_entry, only: %i[edit update destroy stop_timer] def index - @entries = current_user.entries.order(date: :desc) - @running_entry = current_user.entries.find_by(end_time: nil, beschreibung: "Timer") - # Gesamtzeit in Minuten - @total_minutes = @entries.sum { |e| e.hours.to_i * 60 + e.minutes.to_i } + @entries = current_user.entries.order(date: :desc) + + # Nur Einträge bis einschließlich heute für Zeitberechnungen verwenden + @calculation_entries = current_user.entries + .where("date <= ?", Date.current) + .order(date: :desc) - @total_minutes_praktikum_prop = @entries.where(praktikums_typ: Entry::PRAKTIKUMSTYPEN[0], entry_art: Entry::ENTRY_ARTEN[0]).sum { |e| e.hours.to_i * 60 + e.minutes.to_i } + @running_entry = current_user.entries.find_by( + end_time: nil, + beschreibung: "Timer" + ) - @total_minutes_praktikum_fach = @entries.where(praktikums_typ: Entry::PRAKTIKUMSTYPEN[1], entry_art: Entry::ENTRY_ARTEN[0]).sum { |e| e.hours.to_i * 60 + e.minutes.to_i } + 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_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: Entry::PRAKTIKUMSTYPEN[1], + entry_art: Entry::ENTRY_ARTEN[0] + ) + ) - # Gesamtbetrag der Kilometerpauschale @total_kilometer_pauschale = @entries.sum(&:kilometer_pauschale) - # Zeitverbrauch je Kombination (typ + art) - @time_by_typ_art = @entries.group_by(&:praktikums_typ).transform_values do |group| - group.group_by(&:entry_art).transform_values do |entries| - entries.sum { |e| e.hours.to_i * 60 + e.minutes.to_i } - end + # 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 - # Verbleibende Minuten je Kombination @remaining_minutes_matrix = {} @spent_minutes_matrix = {} + @time_by_typ_art.each do |typ, arts| @remaining_minutes_matrix[typ] ||= {} - @spent_minutes_matrix[typ] ||= {} + @spent_minutes_matrix[typ] ||= {} + arts.each do |art, spent_minutes| - target = current_user.required_hours_matrix.dig(typ, art).to_i * 60 - remaining = [target - spent_minutes, 0].max - @remaining_minutes_matrix[typ][art] = remaining + 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 @completion_percent_by_typ_art = {} @progress_chart_data = {} + @progress_colors = {} - @progress_colors = {} - - User::PRAKTIKUMSTYPEN.product(User::ENTRY_ARTEN).each do |typ, art| - total_required = current_user.required_hours_matrix.to_h.dig(typ, art).to_f - remaining_minutes = @remaining_minutes_matrix.dig(typ, art).to_i - required_minutes = (total_required * 60).to_i - entries_exist = current_user.entries.where(praktikums_typ: typ, entry_art: art).exists? - - if total_required > 0 && entries_exist - done_minutes = required_minutes - remaining_minutes - percent = (done_minutes / required_minutes.to_f * 100).round - percent = 100 if percent > 100 - next if percent.zero? - @completion_percent_by_typ_art[[typ, art]] = percent - @progress_chart_data["#{typ.capitalize} – #{art}"] = percent - else - percent = 0 - next if percent.zero? - @completion_percent_by_typ_art[[typ, art]] = percent - @progress_chart_data["#{typ.capitalize} – #{art}"] = percent + @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 - - - @progress_colors["#{typ.capitalize} – #{art}"] = if percent < 50 - '#007bff' # Blau - elsif percent < 90 - '#28a745' # Grün - else - '#dc3545' # Rot - end end - @total_kilometer_costs_by_year = Entry.total_kilometer_cost_by_year(current_user) - @fortbildungskosten_by_year = Entry.total_fortbildungskosten_by_year(current_user) - @selbstsupervision_by_year = Entry.total_supervision_by_year(current_user) - @selbsterfahrungskosten_by_year = Entry.total_selbsterfahrungskosten_by_year(current_user) - @allekosten_by_year = Entry.total_semesterkosten_by_year(current_user) - @gesamtkosten_by_year = Entry.total_gesamtkosten_by_year(current_user) + @total_kilometer_costs_by_year = + Entry.total_kilometer_cost_by_year(current_user) - # Voraussichtliches Ende je Kombination basierend auf weekly_target_matrix - # - @estimated_end_by_typ_art = {} + @fortbildungskosten_by_year = + Entry.total_fortbildungskosten_by_year(current_user) + @selbstsupervision_by_year = + Entry.total_supervision_by_year(current_user) - @remaining_minutes_matrix.each do |typ, arts| - @estimated_end_by_typ_art[typ] ||= {} - - arts.each do |art, remaining_minutes| - hours_remaining = remaining_minutes / 60.0 - weekly_hours = current_user.weekly_target_matrix.dig(typ, art).to_f - - last_entry_date = current_user.entries - .where(praktikums_typ: typ, entry_art: art) - .maximum(:date) - - #start_date = [last_entry_date, Date.today].compact.max - start_date = Date.current - if weekly_hours > 0 && hours_remaining > 0 - weeks_remaining = (hours_remaining / weekly_hours).ceil - @estimated_end_by_typ_art[typ][art] = start_date + weeks_remaining.weeks - else - @estimated_end_by_typ_art[typ][art] = nil - end - end - end + @selbsterfahrungskosten_by_year = + Entry.total_selbsterfahrungskosten_by_year(current_user) + @allekosten_by_year = + Entry.total_semesterkosten_by_year(current_user) - @actual_hours_per_week = {} + @gesamtkosten_by_year = + Entry.total_gesamtkosten_by_year(current_user) - User::PRAKTIKUMSTYPEN.product(User::ENTRY_ARTEN).each do |typ, art| - required_hours = current_user.required_hours_matrix - .to_h - .dig(typ, art) - .to_f - - 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" - ) - .order(:date) - - next if entries.empty? - - spent_minutes = entries.sum do |entry| - entry.hours.to_i * 60 + entry.minutes.to_i - end - - first_entry_date = entries.first.date - - required_minutes = (required_hours * 60).to_i - completed = spent_minutes >= required_minutes - - period_end = - if completed - entries.last.date - else - Date.current - end - - total_days = (period_end - first_entry_date).to_i + 1 - total_days = 7 if total_days < 7 - - weeks = total_days / 7.0 - - @actual_hours_per_week[[typ, art]] = - ((spent_minutes / 60.0) / weeks).round(2) - end - - - + calculate_estimated_end_dates(weekly_target_matrix) + calculate_actual_hours_per_week(required_hours_matrix) end def new @@ -159,60 +132,70 @@ class EntriesController < ApplicationController end def start_timer - typ = params[:typ] art = params[:art] - @entry = current_user.entries.create!(start_time: Time.current, beschreibung: "Timer", praktikums_typ: typ, entry_art: art) + current_user.entries.create!( + start_time: Time.current, + beschreibung: "Timer", + praktikums_typ: typ, + entry_art: art + ) + redirect_to entries_path, notice: "Timer gestartet" end - # neue Aktion: Timer stoppen def stop_timer - #@entry = current_user.entries.where(end_time: nil).order(start_time: :desc).first - if @entry - @entry.end_time = Time.current - @entry.lunch_break_minutes = 30 if ActiveModel::Type::Boolean.new.cast(params[:lunch_break]) - - total_minutes = @entry.total_minutes_including_break - @entry.hours = total_minutes / 60 - @entry.minutes = total_minutes % 60 - - @entry.save! - notice = "Eintrag gestoppt – Gesamtzeit: #{@entry.hours} h #{@entry.minutes} min" - else - notice = "Kein laufender Eintrag gefunden" + @entry.end_time = Time.current + + if ActiveModel::Type::Boolean.new.cast(params[:lunch_break]) + @entry.lunch_break_minutes = 30 end - redirect_to entries_path, notice: notice + + total_minutes = @entry.total_minutes_including_break + + @entry.hours = total_minutes / 60 + @entry.minutes = total_minutes % 60 + + @entry.save! + + redirect_to entries_path, + notice: "Eintrag gestoppt – Gesamtzeit: #{@entry.hours} h #{@entry.minutes} min" end def create @entry = current_user.entries.new(entry_params) - if current_user.praepedeutikum_abgeschlossen? && @entry.praktikums_typ == 'propädeutikum' - redirect_to entries_path, alert: "Propädeutikum ist bereits abgeschlossen – Neuer Eintrag dieses Typs ist nicht erlaubt." + + if current_user.praepedeutikum_abgeschlossen? && + @entry.praktikums_typ == Entry::PRAKTIKUMSTYPEN[0] + + redirect_to entries_path, + alert: "Propädeutikum ist bereits abgeschlossen – Neuer Eintrag dieses Typs ist nicht erlaubt." + return end + if @entry.save redirect_to entries_path, notice: "Eintrag gespeichert" else - render :new + render :new, status: :unprocessable_entity end end def edit - @entry end def update if @entry.update(entry_params) redirect_to entries_path, notice: "Eintrag aktualisiert" else - render :edit + render :edit, status: :unprocessable_entity end end def destroy - @entry.destroy + @entry.destroy! + redirect_to entries_path, notice: "Eintrag gelöscht" end @@ -220,12 +203,16 @@ class EntriesController < ApplicationController @entries = current_user.entries.order(date: :desc) respond_to do |format| - format.csv { send_data @entries.to_csv, filename: "eintraege-#{Date.today}.csv" } + format.csv do + send_data( + @entries.to_csv, + filename: "eintraege-#{Date.current}.csv" + ) + end end end def monthly_report - # Nur die wirklich benötigten Spalten abfragen rows = current_user.entries .select( Arel.sql("DATE_TRUNC('month', date) AS month"), @@ -238,21 +225,29 @@ class EntriesController < ApplicationController :praktikums_typ, :entry_art ) - .order(Arel.sql("DATE_TRUNC('month', date) DESC")) + .order( + Arel.sql("DATE_TRUNC('month', date) DESC") + ) - # In Hash-Struktur für die View umwandeln @report = {} - rows.each do |r| - month = r.attributes["month"].to_date - total = r.attributes["total_minutes"].to_i - next if total.zero? - typ_art = [r.praktikums_typ, r.entry_art] + + rows.each do |row| + month = row.attributes["month"].to_date + total_minutes = row.attributes["total_minutes"].to_i + + next if total_minutes.zero? + + typ_art = [ + row.praktikums_typ, + row.entry_art + ] + + @report[month] ||= {} - @report[month] ||= {} @report[month][typ_art] = { - total_minutes: total, - hours: total / 60, - minutes: total % 60 + total_minutes: total_minutes, + hours: total_minutes / 60, + minutes: total_minutes % 60 } end end @@ -263,6 +258,88 @@ class EntriesController < ApplicationController @entry = current_user.entries.find(params[:id]) end + def sum_minutes(entries) + entries.sum do |entry| + entry.hours.to_i * 60 + entry.minutes.to_i + end + end + + def calculate_estimated_end_dates(weekly_target_matrix) + @estimated_end_by_typ_art = {} + + @remaining_minutes_matrix.each do |typ, arts| + @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 + + 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 + else + @estimated_end_by_typ_art[typ][art] = nil + end + end + end + end + + def calculate_actual_hours_per_week(required_hours_matrix) + @actual_hours_per_week = {} + + Entry::PRAKTIKUMSTYPEN + .product(Entry::ENTRY_ARTEN) + .each do |typ, art| + + required_hours = required_hours_matrix.dig(typ, art).to_f + + 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" + ) + .order(:date) + .to_a + + next if entries.empty? + + spent_minutes = sum_minutes(entries) + + first_entry_date = entries.first.date + last_entry_date = entries.last.date + + required_minutes = (required_hours * 60).round + completed = spent_minutes >= required_minutes + + period_end = + if completed + last_entry_date + else + Date.current + end + + total_days = (period_end - first_entry_date).to_i + 1 + total_days = [total_days, 7].max + + weeks = total_days / 7.0 + + actual_hours = spent_minutes / 60.0 + + @actual_hours_per_week[[typ, art]] = ( + actual_hours / weeks + ).round(2) + end + end + def entry_params params.require(:entry).permit( :date, @@ -278,5 +355,4 @@ class EntriesController < ApplicationController :end_time ) end - -end +end \ No newline at end of file