class EntriesController < ApplicationController before_action :authenticate_user! before_action :set_entry, only: %i[edit update destroy stop_timer] 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) @running_entry = current_user.entries.find_by( end_time: nil, beschreibung: "Timer" ) 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] ) ) @total_kilometer_pauschale = @entries.sum(&:kilometer_pauschale) # 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 @remaining_minutes_matrix = {} @spent_minutes_matrix = {} @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 @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 = 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 @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) calculate_estimated_end_dates(weekly_target_matrix) calculate_actual_hours_per_week(required_hours_matrix) end def new @entry = current_user.entries.build end def start_timer typ = params[:typ] art = params[: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 def stop_timer @entry.end_time = Time.current if ActiveModel::Type::Boolean.new.cast(params[:lunch_break]) @entry.lunch_break_minutes = 30 end 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 == 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, status: :unprocessable_entity end end def edit end def update if @entry.update(entry_params) redirect_to entries_path, notice: "Eintrag aktualisiert" else render :edit, status: :unprocessable_entity end end def destroy @entry.destroy! redirect_to entries_path, notice: "Eintrag gelöscht" end def export_csv @entries = current_user.entries.order(date: :desc) respond_to do |format| format.csv do send_data( @entries.to_csv, filename: "eintraege-#{Date.current}.csv" ) end end end 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") ) @report = {} 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][typ_art] = { total_minutes: total_minutes, hours: total_minutes / 60, minutes: total_minutes % 60 } end end private def set_entry @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" ) next unless entries.exists? spent_minutes = entries.sum do |entry| entry.hours.to_i * 60 + entry.minutes.to_i end first_entry_date = entries.minimum(:date) last_entry_date = entries.maximum(:date) next unless first_entry_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_per_week = (spent_minutes / 60.0) / weeks @actual_hours_per_week[[typ, art]] = actual_hours_per_week.round(2) end end def entry_params params.require(:entry).permit( :date, :hours, :minutes, :praktikums_typ, :entry_art, :distance_km, :beschreibung, :kosten, :zaehlt_als_fortbildung, :start_time, :end_time ) end end