class EntriesController < ApplicationController before_action :authenticate_user! before_action :set_entry, only: %i[ edit update destroy stop_timer ] # ============================================================ # INDEX # ============================================================ def index @entries = current_user.entries.order(date: :desc) # 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" ) # ---------------------------------------------------------- # Gesamtzeiten # ---------------------------------------------------------- @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_fach = sum_minutes( @calculation_entries.where( praktikums_typ: "fachspezifikum", entry_art: "Praktikum" ) ) @total_minutes_praktikum_mediation = sum_minutes( @calculation_entries.where( praktikums_typ: "mediation" ) ) # ---------------------------------------------------------- # Kilometer # ---------------------------------------------------------- @total_kilometer_pauschale = @entries.sum(&:kilometer_pauschale) # ---------------------------------------------------------- # Tatsächlich geleistete Zeit nach Typ / Art # ---------------------------------------------------------- build_time_by_typ_art # ---------------------------------------------------------- # Soll / Erledigt / Verbleibend # ---------------------------------------------------------- build_progress_matrices # ---------------------------------------------------------- # Prozentwerte + Diagramm # ---------------------------------------------------------- build_completion_data # ---------------------------------------------------------- # Kosten # ---------------------------------------------------------- @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) # ---------------------------------------------------------- # 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" 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.to_i @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 # ============================================================ # CREATE # ============================================================ 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." return end if @entry.save redirect_to entries_path, notice: "Eintrag gespeichert" else 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" else render :edit, status: :unprocessable_entity end end # ============================================================ # DESTROY # ============================================================ def destroy @entry.destroy! redirect_to entries_path, notice: "Eintrag gelöscht" end # ============================================================ # CSV EXPORT # ============================================================ 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 # ============================================================ # MONATSBERICHT # ============================================================ def monthly_report 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 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 # ============================================================ # ENTRY LADEN # ============================================================ def set_entry @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 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 # ============================================================ # 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] = {} arts.each do |art, remaining_minutes| 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 @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 # ============================================================ # 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 = 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" ) next unless entries.exists? spent_minutes = sum_minutes(entries) 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 # 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 @actual_hours_per_week[ [typ, art] ] = actual_hours_per_week.round(2) end end # ============================================================ # STRONG PARAMS # ============================================================ 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