Skip to content

Commit 9e0c00a

Browse files
committed
Add cost summary fields (#2527)
1 parent e064727 commit 9e0c00a

21 files changed

Lines changed: 887 additions & 8 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright (c) 2026, Schweizer Alpen-Club. This file is part of
4+
# hitobito_sac_cas and licensed under the Affero General Public License version 3
5+
# or later. See the COPYING file at the top-level directory or at
6+
# https://github.com/hitobito/hitobito_sac_cas.
7+
8+
class Event::Tours::Reports::PrefillRevenuesController < ApplicationController
9+
before_action :authorize_action, :assert_event_reportable
10+
11+
def show
12+
render json: revenue_rows
13+
end
14+
15+
private
16+
17+
def revenue_rows
18+
participation_counts.filter_map do |(price_category, price), count|
19+
next if price_category.nil? || (!count.zero? && !price&.positive?)
20+
21+
{
22+
description: Event::Tour.human_attribute_name(price_category),
23+
count: count,
24+
amount: format("%.2f", price)
25+
}
26+
end
27+
end
28+
29+
def participation_counts
30+
@participation_counts ||= begin
31+
counts = event.participations.group(:price_category, :price).count
32+
33+
(Event::Tour::PRICE_ATTRIBUTES.map(&:to_s) - counts.keys.map(&:first)).each do |category|
34+
counts[[category, 0]] = 0
35+
end
36+
37+
counts
38+
end
39+
end
40+
41+
def event = @event ||= group.events.find(params[:event_id])
42+
43+
def group = @group ||= Group.find(params[:group_id])
44+
45+
def authorize_action
46+
authorize!(:update, event)
47+
end
48+
49+
def assert_event_reportable
50+
raise CanCan::AccessDenied unless event.reportable?
51+
end
52+
end

app/controllers/event/tours/reports_controller.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,10 @@ def permitted_attrs
3333
.permit(
3434
:review,
3535
:remarks,
36-
participations_attributes: [
37-
:state,
38-
:means_of_transport
39-
]
36+
participations_attributes: [:state, :means_of_transport],
37+
revenues_attributes: [:id, :description, :count, :amount, :_destroy],
38+
expenditures_attributes: [:id, :description, :count, :amount, :_destroy],
39+
receipts_attributes: [:id, :description, :file, :_destroy]
4040
)
4141
end
4242

app/models/event/cost.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,8 @@ class Event::Cost < ApplicationRecord
1010
inverse_of: :costs
1111

1212
validates :description, :count, :amount, presence: true
13+
14+
def total
15+
amount * count
16+
end
1317
end

app/models/event/cost_receipt.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,16 @@
66
# https://github.com/hitobito/hitobito_sac_cas.
77

88
class Event::CostReceipt < ApplicationRecord
9+
MAX_FILE_SIZE = Settings.event.attachments.max_file_size.megabytes
10+
CONTENT_TYPES = Settings.event.attachments.content_types
11+
912
belongs_to :report, class_name: "Event::Report",
1013
inverse_of: :cost_receipts
1114

1215
has_one_attached :file
1316

1417
validates :description, presence: true
18+
validates :file, attached: true,
19+
size: {less_than_or_equal_to: MAX_FILE_SIZE},
20+
content_type: CONTENT_TYPES
1521
end

app/models/event/report.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ class Event::Report < ApplicationRecord
2222
class_name: "Event::CostReceipt",
2323
inverse_of: :report
2424

25+
accepts_nested_attributes_for :costs, allow_destroy: true
26+
accepts_nested_attributes_for :cost_receipts, allow_destroy: true
27+
2528
def status
2629
return :closed if paid_at?
2730
return :approved if approved_at?

app/models/event/tour/report_form.rb

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@ class Event::Tour::ReportForm
1212
EDITABLE_PARTICIPATION_STATES = %w[assigned attended absent].freeze
1313

1414
attr_reader :report
15+
attr_writer :participations_attributes
1516

1617
delegate :event, to: :report
1718

1819
attribute :review, :string
1920
attribute :remarks, :string
2021

2122
validate :assert_participation_states_editable
23+
validate :assert_cost_records_valid
2224

2325
def initialize(report, attrs = {})
2426
@report = report
@@ -28,15 +30,15 @@ def initialize(report, attrs = {})
2830
def save
2931
return false unless valid?
3032

31-
report.update(review:, remarks:) && save_participations
33+
ActiveRecord::Base.transaction do
34+
report.update!(review:, remarks:) && save_participations && save_cost_records
35+
end
3236
end
3337

3438
def tour_completed?
3539
[:ready, :closed].include?(event.state.to_sym)
3640
end
3741

38-
attr_writer :participations_attributes
39-
4042
def participations
4143
@participations ||= event
4244
.participations
@@ -45,6 +47,18 @@ def participations
4547
.order_by_role(event)
4648
end
4749

50+
def revenues
51+
@revenues ||= report.costs.where(income: true).to_a
52+
end
53+
54+
def expenditures
55+
@expenditures ||= report.costs.where(income: false).to_a
56+
end
57+
58+
def receipts
59+
@receipts ||= report.cost_receipts.includes(:file_attachment).to_a
60+
end
61+
4862
def editable_participation_state?(participation)
4963
EDITABLE_PARTICIPATION_STATES.include?(participation.state)
5064
end
@@ -56,6 +70,19 @@ def participation_state_labels(participation)
5670
.to_a
5771
end
5872

73+
def revenues_attributes=(attrs)
74+
@revenues = build_records(attrs, collection: report.costs, fixed_attributes: {income: true})
75+
end
76+
77+
def expenditures_attributes=(attrs)
78+
@expenditures = build_records(attrs, collection: report.costs,
79+
fixed_attributes: {income: false})
80+
end
81+
82+
def receipts_attributes=(attrs)
83+
@receipts = build_records(attrs, collection: report.cost_receipts)
84+
end
85+
5986
private
6087

6188
def assert_participation_states_editable
@@ -70,6 +97,12 @@ def assert_participation_states_editable
7097
end
7198
end
7299

100+
def assert_cost_records_valid
101+
cost_records.flat_map { _1.reject(&:valid?) }.each do |model|
102+
model.errors.full_messages.each { errors.add(:base, _1) }
103+
end
104+
end
105+
73106
def save_participations
74107
return true unless @participations_attributes
75108

@@ -79,7 +112,34 @@ def save_participations
79112
end
80113
end
81114

115+
def save_cost_records
116+
cost_records.each do |collection|
117+
collection.each { _1.marked_for_destruction? ? _1.destroy! : _1.save! }
118+
end
119+
end
120+
82121
def participation_by_id
83122
@participation_by_id ||= participations.index_by(&:id)
84123
end
124+
125+
def cost_records
126+
[@revenues, @expenditures, @receipts].compact
127+
end
128+
129+
def build_records(attrs, collection:, fixed_attributes: {})
130+
attrs.values.map do |values|
131+
record = if values[:id].present?
132+
collection.index_by(&:id).transform_keys(&:to_s).fetch(values[:id].to_s)
133+
else
134+
collection.build
135+
end
136+
137+
if ActiveRecord::Type::Boolean.new.cast(values[:_destroy])
138+
record.mark_for_destruction
139+
else
140+
record.assign_attributes(values.except(:id, :_destroy).merge(fixed_attributes))
141+
end
142+
record
143+
end
144+
end
85145
end

app/views/event/tours/reports/_cost_summary_fields.html.haml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,14 @@
22
-# hitobito_sac_cas and licensed under the Affero General Public License version 3
33
-# or later. See the COPYING file at the top-level directory or at
44
-# https://github.com/hitobito/hitobito_sac_cas
5+
6+
= field_set_tag(t(".revenues")) do
7+
%div{data: {controller: "sac--prefill-revenues", "sac--prefill-revenues-url-value": prefill_revenues_group_event_report_path(@group, @event)}}
8+
= f.button t(".add_tour_fees"), type: :button, data: {action: "click->sac--prefill-revenues#prefill"}, class: "btn btn-sm btn-outline-primary mb-3"
9+
= render "event/tours/reports/cost_table_fields", f: f, entries: entry.revenues, cost_type: :revenues
10+
11+
= field_set_tag(t(".expenditures")) do
12+
= render "event/tours/reports/cost_table_fields", f: f, entries: entry.expenditures, cost_type: :expenditures
13+
14+
= field_set_tag(t(".receipts")) do
15+
= render "event/tours/reports/receipt_table_fields", f: f, entries: entry.receipts
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
-# Copyright (c) 2026, Schweizer Alpen-Club. This file is part of
2+
-# hitobito_sac_cas and licensed under the Affero General Public License version 3
3+
-# or later. See the COPYING file at the top-level directory or at
4+
-# https://github.com/hitobito/hitobito_sac_cas
5+
6+
.d-none.d-xl-flex.border-bottom.row.fw-bold
7+
.col-xl-4= Event::Cost.human_attribute_name(:description)
8+
.col-xl-3= Event::Cost.human_attribute_name(:count)
9+
.col-xl-3= Event::Cost.human_attribute_name(:amount)
10+
.col-xl-1= Event::Cost.human_attribute_name(:total)
11+
12+
.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"}}
13+
%div{id: "#{cost_type}_fields"}
14+
- entries.each do |record|
15+
.fields{data: {controller: "field-multiplication"}}
16+
= f.fields_for :"#{cost_type}_attributes", record, index: record.id do |ff|
17+
.border-bottom.row.py-2
18+
.col-xl-4
19+
.d-xl-none.muted= Event::Cost.human_attribute_name(:description)
20+
= ff.input_field :description
21+
.col-xl-3
22+
.d-xl-none.muted= Event::Cost.human_attribute_name(:count)
23+
= ff.input_field :count, step: 1, data: {field_multiplication_target: "source", action: "input->field-multiplication#recalculate"}
24+
.col-xl-3
25+
.d-xl-none.muted= Event::Cost.human_attribute_name(:amount)
26+
= ff.input_field :amount, step: 0.05, data: {field_multiplication_target: "source", action: "input->field-multiplication#recalculate"}
27+
.col-xl-1
28+
.d-xl-none.muted= Event::Cost.human_attribute_name(:total)
29+
%span{data: {field_multiplication_target: "result", field_addition_target: "source"}}= format("%.2f", record.total)
30+
.col-xl-1
31+
= link_to(ta(:remove), "javascript:void(0)", class: "remove_nested_fields", data: { action: "nested-form#remove" })
32+
33+
= ff.hidden_field :id
34+
= ff.hidden_field :_destroy
35+
%div{data: {nested_form_target: "target"}}
36+
37+
.controls.mt-3
38+
.row
39+
.col-xl-2= link_to t("global.associations.add"), "javascript:void(0)", data: {action: "nested-form#add"}
40+
.col-xl-8.text-xl-end.fw-bold= Event::Cost.human_attribute_name(:total)
41+
.col-xl-1.fw-bold{id: "#{cost_type}_total", data: {field_addition_target: "result"}}= entries.sum(&:total)
42+
%template{data: {nested_form_target: "template"}}
43+
.fields{data: {new_record: true, controller: "field-multiplication"}}
44+
= f.fields_for :"#{cost_type}_attributes", Event::Cost.new, index: "NEW_#{cost_type.to_s.upcase}_RECORD" do |ff|
45+
.border-bottom.row.py-2
46+
.col-xl-4
47+
.d-xl-none.muted= Event::Cost.human_attribute_name(:description)
48+
= ff.input_field :description
49+
.col-xl-3
50+
.d-xl-none.muted= Event::Cost.human_attribute_name(:count)
51+
= ff.input_field :count, step: 1, data: {field_multiplication_target: "source", action: "input->field-multiplication#recalculate"}
52+
.col-xl-3
53+
.d-xl-none.muted= Event::Cost.human_attribute_name(:amount)
54+
= ff.input_field :amount, step: 0.05, data: {field_multiplication_target: "source", action: "input->field-multiplication#recalculate"}
55+
.col-xl-1
56+
.d-xl-none.muted= Event::Cost.human_attribute_name(:total)
57+
%span{data: {field_multiplication_target: "result", field_addition_target: "source"}} 0.00
58+
.col-xl-1
59+
= link_to(ta(:remove), "javascript:void(0)", class: "remove_nested_fields", data: { action: "nested-form#remove" })
60+
= ff.hidden_field :id
61+
= ff.hidden_field :_destroy
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
-# Copyright (c) 2026, Schweizer Alpen-Club. This file is part of
2+
-# hitobito_sac_cas and licensed under the Affero General Public License version 3
3+
-# or later. See the COPYING file at the top-level directory or at
4+
-# https://github.com/hitobito/hitobito_sac_cas
5+
6+
.d-none.d-xl-flex.border-bottom.row.fw-bold
7+
.col-xl-6= Event::CostReceipt.human_attribute_name(:description)
8+
.col-xl-5= Event::CostReceipt.human_attribute_name(:file)
9+
.nested-form{data: {controller: "nested-form", nested_form_assoc_value: "receipts"}}
10+
%div{id: "receipts_fields"}
11+
- entry.receipts.each do |record|
12+
.fields
13+
= f.fields_for :receipts_attributes, record, index: record.id do |ff|
14+
.border-bottom.row.py-2
15+
.col-xl-6
16+
.d-xl-none.muted= Event::CostReceipt.human_attribute_name(:description)
17+
= ff.string_field :description
18+
.col-xl-5
19+
.d-xl-none.muted= Event::CostReceipt.human_attribute_name(:file)
20+
- if record.file.attached?
21+
= link_to(record.file.filename, rails_blob_path(record.file, disposition: :inline), target: "_blank")
22+
- else
23+
= ff.file_field :file, class: "form-control form-control-sm"
24+
.col-xl-1
25+
= link_to(ta(:remove), "javascript:void(0)", class: "remove_nested_fields", data: {action: "nested-form#remove"})
26+
= ff.hidden_field :id
27+
= ff.hidden_field :_destroy
28+
%div{data: {nested_form_target: "target"}}
29+
.controls.mt-3
30+
= link_to t("global.associations.add"), "javascript:void(0)", data: {action: "nested-form#add"}
31+
%template{data: {nested_form_target: "template"}}
32+
.fields{data: {new_record: true}}
33+
= f.fields_for :receipts_attributes, Event::CostReceipt.new, index: "NEW_RECEIPTS_RECORD" do |ff|
34+
.border-bottom.row.py-2
35+
.col-xl-6
36+
.d-xl-none.muted= Event::CostReceipt.human_attribute_name(:description)
37+
= ff.string_field :description
38+
.col-xl-5
39+
.d-xl-none.muted= Event::CostReceipt.human_attribute_name(:file)
40+
= ff.file_field :file, class: "form-control form-control-sm"
41+
.col-xl-1
42+
= link_to(ta(:remove), "javascript:void(0)", class: "remove_nested_fields", data: {action: "nested-form#remove"})
43+
= ff.hidden_field :id
44+
= ff.hidden_field :_destroy

app/views/event/tours/reports/edit.html.haml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
-# https://github.com/hitobito/hitobito_sac_cas
55
66
= standard_form(entry, url: group_event_report_path(@group, @event), method: :put) do |f|
7+
= f.error_messages
78
= render "form_tabs"
89

910
.tab-content

0 commit comments

Comments
 (0)