Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions app/controllers/event/tours/reports/prefill_revenues_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true

# Copyright (c) 2026, Schweizer Alpen-Club. This file is part of
# hitobito_sac_cas and licensed under the Affero General Public License version 3
# or later. See the COPYING file at the top-level directory or at
# https://github.com/hitobito/hitobito_sac_cas.

class Event::Tours::Reports::PrefillRevenuesController < ApplicationController
before_action :authorize_action, :assert_event_reportable

def show
render json: revenue_rows
end

private

def revenue_rows
participation_counts.filter_map do |(price_category, price), count|
next if price_category.nil? || (!count.zero? && !price&.positive?)

{
description: Event::Tour.human_attribute_name(price_category),
count: count,
amount: format("%.2f", price)
}
end
end

def participation_counts
@participation_counts ||= begin
counts = event.participations.group(:price_category, :price).count

(Event::Tour::PRICE_ATTRIBUTES.map(&:to_s) - counts.keys.map(&:first)).each do |category|
counts[[category, 0]] = 0
end

counts
end
end

def event = @event ||= group.events.find(params[:event_id])

def group = @group ||= Group.find(params[:group_id])

def authorize_action
authorize!(:update, event)
end

def assert_event_reportable
raise CanCan::AccessDenied unless event.reportable?
end
end
8 changes: 4 additions & 4 deletions app/controllers/event/tours/reports_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ def permitted_attrs
:remarks,
:status_action,
:mail_recipient_id,
participations_attributes: [
:state,
:means_of_transport
]
participations_attributes: [:state, :means_of_transport],
revenues_attributes: [:id, :description, :count, :amount, :_destroy],
expenses_attributes: [:id, :description, :count, :amount, :_destroy],
receipts_attributes: [:id, :description, :file, :_destroy]
)
end

Expand Down
6 changes: 6 additions & 0 deletions app/models/event/cost.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,10 @@ class Event::Cost < ApplicationRecord
inverse_of: :costs

validates :description, :count, :amount, presence: true

def total
return 0 if amount.nil? || count.nil?

amount * count
end
Comment thread
njaeggi marked this conversation as resolved.
end
6 changes: 6 additions & 0 deletions app/models/event/cost_receipt.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,16 @@
# https://github.com/hitobito/hitobito_sac_cas.

class Event::CostReceipt < ApplicationRecord
MAX_FILE_SIZE = Settings.event.attachments.max_file_size.megabytes
CONTENT_TYPES = Settings.event.attachments.content_types

belongs_to :report, class_name: "Event::Report",
inverse_of: :cost_receipts

has_one_attached :file

validates :description, presence: true
validates :file, attached: true,
size: {less_than_or_equal_to: MAX_FILE_SIZE},
content_type: CONTENT_TYPES
end
3 changes: 3 additions & 0 deletions app/models/event/report.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ class Event::Report < ApplicationRecord
class_name: "Event::CostReceipt",
inverse_of: :report

accepts_nested_attributes_for :costs, allow_destroy: true
accepts_nested_attributes_for :cost_receipts, allow_destroy: true

def status
return :closed if paid_at?
return :approved if approved_at?
Expand Down
75 changes: 69 additions & 6 deletions app/models/event/tour/report_form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class Event::Tour::ReportForm

attr_reader :report
attr_accessor :current_user
attr_writer :participations_attributes

delegate :event, :submitter, :approver, to: :report

Expand All @@ -38,6 +39,7 @@ class Event::Tour::ReportForm

validate :assert_participation_states_editable
validate :assert_mail_recipient_present_when_forwarding
validate :assert_cost_records_valid

def initialize(report, current_user = nil, attrs = {})
@report = report
Expand All @@ -52,17 +54,22 @@ def save
captured_recipients = capture_mail_recipients(original_status)

success = report.transaction do
unless report.update(review:, remarks:) &&
save_participations && apply_status_change(original_status)
raise ActiveRecord::Rollback
end
raise ActiveRecord::Rollback unless save_entry(original_status)

true
end

deliver_status_emails(original_status, captured_recipients) if success
success
end

def save_entry(original_status)
report.update(review:, remarks:) &&
save_participations &&
apply_status_change(original_status) &&
save_cost_records
end

def tour_completed?
[:ready, :closed].include?(event.state.to_sym)
end
Expand All @@ -80,8 +87,6 @@ def possible_mail_recipients
.select("people.*")
end

attr_writer :participations_attributes

def participations
@participations ||= event
.participations
Expand All @@ -90,6 +95,18 @@ def participations
.order_by_role(event)
end

def revenues
@revenues ||= report.costs.where(income: true).to_a
end

def expenses
@expenses ||= report.costs.where(income: false).to_a
end

def receipts
@receipts ||= report.cost_receipts.includes(:file_attachment).to_a
end

def editable_participation_state?(participation)
EDITABLE_PARTICIPATION_STATES.include?(participation.state)
end
Expand All @@ -101,6 +118,19 @@ def participation_state_labels(participation)
.to_a
end

def revenues_attributes=(attrs)
@revenues = build_records(attrs, collection: report.costs, fixed_attributes: {income: true})
end

def expenses_attributes=(attrs)
@expenses = build_records(attrs, collection: report.costs,
fixed_attributes: {income: false})
end

def receipts_attributes=(attrs)
@receipts = build_records(attrs, collection: report.cost_receipts)
end

private

def assert_participation_states_editable
Expand All @@ -122,6 +152,12 @@ def assert_mail_recipient_present_when_forwarding
errors.add(:mail_recipient_id, :blank) if mail_recipient_id.blank?
end

def assert_cost_records_valid
cost_records.flat_map { _1.reject(&:marked_for_destruction?).reject(&:valid?) }.each do |model|
model.errors.full_messages.each { errors.add(:base, _1) }
end
end

def save_participations
return true unless @participations_attributes

Expand All @@ -131,6 +167,12 @@ def save_participations
end
end

def save_cost_records
cost_records.each do |collection|
collection.each { _1.marked_for_destruction? ? _1.destroy! : _1.save! }
end
end

def participation_by_id
@participation_by_id ||= participations.index_by(&:id)
end
Expand Down Expand Up @@ -182,4 +224,25 @@ def mail_recipient = Person.where(id: mail_recipient_id).first

def mail_definition(original_status) = TRANSITION_MAIL_DEFINITIONS[[original_status,
status_action]] || {}

def cost_records
[@revenues, @expenses, @receipts].compact
end

def build_records(attrs, collection:, fixed_attributes: {})
attrs.values.map do |values|
record = if values[:id].present?
collection.index_by(&:id).transform_keys(&:to_s).fetch(values[:id].to_s)
else
collection.build
end

if ActiveRecord::Type::Boolean.new.cast(values[:_destroy])
record.mark_for_destruction
else
record.assign_attributes(values.except(:id, :_destroy).merge(fixed_attributes))
end
record
end
end
end
11 changes: 11 additions & 0 deletions app/views/event/tours/reports/_cost_summary_fields.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,14 @@
-# hitobito_sac_cas and licensed under the Affero General Public License version 3
-# or later. See the COPYING file at the top-level directory or at
-# https://github.com/hitobito/hitobito_sac_cas

= field_set_tag(t(".revenues")) do
%div{data: {controller: "sac--prefill-revenues", "sac--prefill-revenues-url-value": prefill_revenues_group_event_report_path(@group, @event)}}
= f.button t(".add_tour_fees"), type: :button, data: {action: "click->sac--prefill-revenues#prefill"}, class: "btn btn-sm btn-outline-primary mb-3"
= render "event/tours/reports/cost_table_fields", f: f, entries: entry.revenues, cost_type: :revenues

= field_set_tag(t(".expenses")) do
= render "event/tours/reports/cost_table_fields", f: f, entries: entry.expenses, cost_type: :expenses

= field_set_tag(t(".receipts")) do
= render "event/tours/reports/receipt_table_fields", f: f, entries: entry.receipts
62 changes: 62 additions & 0 deletions app/views/event/tours/reports/_cost_table_fields.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
-# Copyright (c) 2026, Schweizer Alpen-Club. This file is part of
-# hitobito_sac_cas and licensed under the Affero General Public License version 3
-# or later. See the COPYING file at the top-level directory or at
-# https://github.com/hitobito/hitobito_sac_cas

.d-none.d-xl-flex.border-bottom.row.fw-bold
.col-xl-4= Event::Cost.human_attribute_name(:description)
.col-xl-3= Event::Cost.human_attribute_name(:count)
.col-xl-3= Event::Cost.human_attribute_name(:amount)
.col-xl-1= Event::Cost.human_attribute_name(:total)

.nested-form{data: {controller: "nested-form field-addition", nested_form_assoc_value: cost_type, action: "field-multiplication:recalculated->field-addition#recalculate nested-form:remove->field-addition#recalculate"}}
%div{id: "#{cost_type}_fields"}
- entries.each do |record|
.fields{data: {controller: "field-multiplication"}}
= f.fields_for :"#{cost_type}_attributes", record, index: record.id do |ff|
.border-bottom.row.py-2
.col-xl-4
.d-xl-none.muted= Event::Cost.human_attribute_name(:description)
= ff.input_field :description
.col-xl-3
.d-xl-none.muted= Event::Cost.human_attribute_name(:count)
- formatted_count = format("%g", ff.object.count) if ff.object.count
= ff.input_field :count, step: 1, value: formatted_count, data: {field_multiplication_target: "source", action: "input->field-multiplication#recalculate"}
.col-xl-3
.d-xl-none.muted= Event::Cost.human_attribute_name(:amount)
= ff.input_field :amount, step: 0.05, data: {field_multiplication_target: "source", action: "input->field-multiplication#recalculate"}
.col-xl-1
.d-xl-none.muted= Event::Cost.human_attribute_name(:total)
%span{data: {field_multiplication_target: "result", field_addition_target: "source"}}= format("%.2f", record.total)
.col-xl-1
= link_to(ta(:remove), "javascript:void(0)", class: "remove_nested_fields", data: { action: "nested-form#remove" })

= ff.hidden_field :id
= ff.hidden_field :_destroy
%div{data: {nested_form_target: "target"}}

.controls.mt-3
.row
.col-xl-2= link_to t("global.associations.add"), "javascript:void(0)", data: {action: "nested-form#add"}
.col-xl-8.text-xl-end.fw-bold= Event::Cost.human_attribute_name(:total)
.col-xl-1.fw-bold{id: "#{cost_type}_total", data: {field_addition_target: "result"}}= entries.sum(&:total)
%template{data: {nested_form_target: "template"}}
.fields{data: {new_record: true, controller: "field-multiplication"}}
= f.fields_for :"#{cost_type}_attributes", Event::Cost.new, index: "NEW_#{cost_type.to_s.upcase}_RECORD" do |ff|
.border-bottom.row.py-2
.col-xl-4
.d-xl-none.muted= Event::Cost.human_attribute_name(:description)
= ff.input_field :description
.col-xl-3
.d-xl-none.muted= Event::Cost.human_attribute_name(:count)
= ff.input_field :count, step: 1, data: {field_multiplication_target: "source", action: "input->field-multiplication#recalculate"}
.col-xl-3
.d-xl-none.muted= Event::Cost.human_attribute_name(:amount)
= ff.input_field :amount, step: 0.05, data: {field_multiplication_target: "source", action: "input->field-multiplication#recalculate"}
.col-xl-1
.d-xl-none.muted= Event::Cost.human_attribute_name(:total)
%span{data: {field_multiplication_target: "result", field_addition_target: "source"}} 0.00
.col-xl-1
= link_to(ta(:remove), "javascript:void(0)", class: "remove_nested_fields", data: { action: "nested-form#remove" })
= ff.hidden_field :id
= ff.hidden_field :_destroy
44 changes: 44 additions & 0 deletions app/views/event/tours/reports/_receipt_table_fields.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
-# Copyright (c) 2026, Schweizer Alpen-Club. This file is part of
-# hitobito_sac_cas and licensed under the Affero General Public License version 3
-# or later. See the COPYING file at the top-level directory or at
-# https://github.com/hitobito/hitobito_sac_cas
.d-none.d-xl-flex.border-bottom.row.fw-bold
.col-xl-6= Event::CostReceipt.human_attribute_name(:description)
.col-xl-5= Event::CostReceipt.human_attribute_name(:file)
.nested-form{data: {controller: "nested-form", nested_form_assoc_value: "receipts"}}
%div{id: "receipts_fields"}
- entry.receipts.each do |record|
.fields
= f.fields_for :receipts_attributes, record, index: record.id do |ff|
.border-bottom.row.py-2
.col-xl-6
.d-xl-none.muted= Event::CostReceipt.human_attribute_name(:description)
= ff.string_field :description
.col-xl-5
.d-xl-none.muted= Event::CostReceipt.human_attribute_name(:file)
- if record.file.attached?
= link_to(record.file.filename, rails_blob_path(record.file, disposition: :inline), target: "_blank")
- else
= ff.file_field :file, class: "form-control form-control-sm"
.col-xl-1
= link_to(ta(:remove), "javascript:void(0)", class: "remove_nested_fields", data: {action: "nested-form#remove"})
= ff.hidden_field :id
= ff.hidden_field :_destroy
%div{data: {nested_form_target: "target"}}
.controls.mt-3
= link_to t("global.associations.add"), "javascript:void(0)", data: {action: "nested-form#add"}
%template{data: {nested_form_target: "template"}}
.fields{data: {new_record: true}}
= f.fields_for :receipts_attributes, Event::CostReceipt.new, index: "NEW_RECEIPTS_RECORD" do |ff|
.border-bottom.row.py-2
.col-xl-6
.d-xl-none.muted= Event::CostReceipt.human_attribute_name(:description)
= ff.string_field :description
.col-xl-5
.d-xl-none.muted= Event::CostReceipt.human_attribute_name(:file)
= ff.file_field :file, class: "form-control form-control-sm"
.col-xl-1
= link_to(ta(:remove), "javascript:void(0)", class: "remove_nested_fields", data: {action: "nested-form#remove"})
Comment thread
njaeggi marked this conversation as resolved.
= ff.hidden_field :id
= ff.hidden_field :_destroy
13 changes: 13 additions & 0 deletions config/locales/wagon.de.yml
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,14 @@ de:
review: In Freigabe
approved: Freigegeben
closed: Abgeschlossen
event/cost:
description: Bezeichnung
count: Anzahl
amount: Betrag
total: Total
event/cost_receipt:
description: Beschreibung
file: Datei
event/target_group:
label: Bezeichnung
short_description: Kurzbeschreibung
Expand Down Expand Up @@ -1730,6 +1738,11 @@ de:
draft: Deine Ablehnung wurde gespeichert und die Tour zurück in den Status <i>Entwurf</i> gesetzt.
reports:
title: Tourenrapport (%{status})
cost_summary_fields:
revenues: Einnahmen
add_tour_fees: Tourengebühren hinzufügen
expenses: Ausgaben
receipts: Belege
form_tabs:
status: Status
participations: Teilnehmende
Expand Down
Loading
Loading