diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 52f915f31..ec25143de 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -1017,3 +1017,215 @@ display: flex; justify-content: space-between; } + +.grouping-legal-type-actions .fr-btn[aria-disabled="true"] { + background-color: var(--background-disabled-grey); + color: var(--text-disabled-grey); + pointer-events: none; + cursor: not-allowed; +} + +.grouping-member-form { + background-color: var(--background-alt-blue-france); + border-left: 4px solid var(--border-plain-blue-france); + padding: 32px 40px; +} + +#grouping_members_table .fr-table, +#grouping_members_table .fr-table__wrapper, +#grouping_members_table .fr-table__container, +#grouping_members_table .fr-table__content { + display: block !important; + width: 100% !important; + max-width: none !important; + border: none !important; +} + +#grouping_members_table table { + display: table !important; + width: 100% !important; + table-layout: fixed !important; + border-collapse: collapse !important; + border: 1px solid var(--light-decisions-border-border-default-grey, #ddd) !important; +} + +#grouping_members_table th, +#grouping_members_table td { + border: none !important; + background-image: none !important; + border-bottom: 1px solid var(--light-decisions-border-border-default-grey, #ddd) !important; +} + +#grouping_members_table thead, +#grouping_members_table tbody, +#grouping_members_table tbody::after, +#grouping_members_table thead::after { + background-image: none !important; +} + +#grouping_members_table tbody tr { + background-color: var(--background-default-grey, #fff) !important; +} + +.lot-selection-rc-notice { + display: flex; + align-items: stretch; + background-color: var(--background-alt-blue-france, #f5f5fe); +} + +.lot-selection-rc-notice__icon { + flex-shrink: 0; + display: flex; + align-items: flex-start; + padding: 1rem 0.5rem; + background-color: var(--background-flat-info, #0063cb); + color: var(--text-inverted-grey, #fff); +} + +.lot-selection-rc-notice__content { + padding: 1rem; +} + +.lot-selection-rc-notice__title { + font-weight: 700; + font-size: 1.25rem; + line-height: 1.75rem; + margin: 0 0 0.25rem; + color: var(--text-title-grey, #161616); +} + +.lot-selection-rc-notice__text { + margin: 0; + font-size: 1rem; + color: var(--text-default-grey, #3a3a3a); +} + +.lot-selection-mode-banner { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + font-size: 0.875rem; + color: var(--text-default-grey, #3a3a3a); +} + +.lot-selection-mode-bulk { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: nowrap; + white-space: nowrap; +} + +.lot-selection-mode-bulk__select { + appearance: none; + background-color: var(--background-default-grey, #fff); + border: 1px solid var(--border-default-grey, #ddd); + color: var(--text-action-high-blue-france, #000091); + font-size: 0.875rem; + font-weight: 500; + padding: 0.25rem 1.75rem 0.25rem 0.75rem; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='%23000091'%3E%3Cpath d='M12 15.5l-6-6 1.4-1.4L12 12.7l4.6-4.6L18 9.5z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.5rem center; +} + +.lot-mode-choices { + display: inline-flex; + border: 1px solid var(--border-default-grey); + border-radius: 0.25rem; +} + +.lot-mode-choice__input { + position: absolute; + opacity: 0; + width: 1px; + height: 1px; +} + +.lot-mode-choice__label { + display: inline-block; + padding: 0.25rem 0.75rem; + margin: -1px; + border: 1px solid transparent; + border-radius: 0.25rem; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + line-height: 1.5rem; + white-space: nowrap; + color: var(--text-action-high-grey); +} + +.lot-mode-choice__input:checked + .lot-mode-choice__label { + border-color: var(--border-action-high-blue-france, #000091); + color: var(--text-action-high-blue-france, #000091); +} + +.lot-mode-choice__input:focus-visible + .lot-mode-choice__label { + outline: 2px solid var(--border-action-high-blue-france); + outline-offset: -2px; +} + +.lot-selection-mode-section__header { + padding-bottom: 1rem; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; +} + +.lot-selection-mode-group__title { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.lot-selection-mode-group__title-text { + margin: 0; + font-weight: 700; + font-size: 1.25rem; + line-height: 2rem; + color: var(--text-title-grey, #000); +} + +.lot-selection-mode-group__badge .market-type-badge { + border-radius: 1rem; + padding: 0.375rem 0.75rem; + align-items: center; +} + +.lot-selection-mode-group__badge .market-type-badge img { + width: 18px; + height: 18px; + vertical-align: middle; +} + +.lot-selection-mode-table { + width: 100%; + border-collapse: collapse; + background-color: var(--background-default-grey, #fff); + border: 1px solid var(--border-default-grey, #ddd); +} + +.lot-selection-mode-table th, +.lot-selection-mode-table td { + border-bottom: 1px solid var(--border-default-grey, #ddd); + padding: 0.75rem 1rem; + text-align: left; + font-size: 0.875rem; + vertical-align: middle; +} + +.lot-selection-mode-table th { + font-weight: 700; + color: var(--text-default-grey, #3a3a3a); + background-color: var(--background-alt-grey, #f6f6f6); + padding-top: 1rem; + padding-bottom: 1rem; +} + +.lot-selection-mode-table td:last-child { + text-align: right; +} diff --git a/app/components/grouping_invitation_email_component.html.erb b/app/components/grouping_invitation_email_component.html.erb new file mode 100644 index 000000000..6b1042f88 --- /dev/null +++ b/app/components/grouping_invitation_email_component.html.erb @@ -0,0 +1,66 @@ + + + + +
+ + + <%# Header %> + + + + + <%# Body %> + + + + <%# Logo %> + + + + <%# Footer %> + + + + +
+

+ <%= t('grouping_invitation_mailer.invitation.subject') %> +

+
+

+ <%= t('grouping_invitation_mailer.invitation.greeting') %> +

+ +

+ <%= t('grouping_invitation_mailer.invitation.intro') %> + “<%= @market_name %>” +

+ +

+ <%= t('grouping_invitation_mailer.invitation.cta_intro') %> +

+ + + + + +
+ <%= link_to t('grouping_invitation_mailer.invitation.cta'), @url, + style: "display: inline-block; padding: 12px 24px; color: #ffffff; font-size: 15px; font-weight: 700; text-decoration: none; white-space: nowrap;" %> +
+
+ +
+

+ <%= t('auth_mailer.magic_link.footer.privacy') %> +  |  + <%= t('auth_mailer.magic_link.footer.legal') %> +

+

<%= t('auth_mailer.magic_link.footer.copyright') %>

+

<%= t('auth_mailer.magic_link.footer.contact') %>

+
+
diff --git a/app/components/grouping_invitation_email_component.rb b/app/components/grouping_invitation_email_component.rb new file mode 100644 index 000000000..4fb51856b --- /dev/null +++ b/app/components/grouping_invitation_email_component.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class GroupingInvitationEmailComponent < ViewComponent::Base + def initialize(url:, market_name:) + @url = url + @market_name = market_name + end + + private + + def logo_data_uri + image_path = Rails.root.join('app/assets/images/passe-marche-logo-email.jpg') + base64 = Base64.strict_encode64(File.binread(image_path)) + "data:image/jpeg;base64,#{base64}" + end +end diff --git a/app/controllers/api/v1/market_applications_controller.rb b/app/controllers/api/v1/market_applications_controller.rb index 5f2369adc..4f01e00b1 100644 --- a/app/controllers/api/v1/market_applications_controller.rb +++ b/app/controllers/api/v1/market_applications_controller.rb @@ -75,11 +75,8 @@ def success_response(market_application) end def application_url_for(market_application) - if market_application.application_mode_choice_required? - return Rails.application.routes.url_helpers.application_mode_candidate_market_application_url( - market_application.identifier - ) - end + target, step = market_application.next_required_wizard_step + return wizard_step_url(target, step) if target Rails.application.routes.url_helpers.step_candidate_market_application_url( market_application.identifier, @@ -87,6 +84,12 @@ def application_url_for(market_application) ) end + def wizard_step_url(market_application, step) + Rails.application.routes.url_helpers.grouping_wizard_step_candidate_market_application_url( + market_application.identifier, step + ) + end + def validate_application_completed return if @market_application.completed? diff --git a/app/controllers/candidate/application_modes_controller.rb b/app/controllers/candidate/application_modes_controller.rb deleted file mode 100644 index f5322d010..000000000 --- a/app/controllers/candidate/application_modes_controller.rb +++ /dev/null @@ -1,65 +0,0 @@ -# frozen_string_literal: true - -module Candidate - class ApplicationModesController < Candidate::ApplicationController - include Candidate::GroupementFeatureGuard - include Candidate::MarketApplicationGuard - - prepend_before_action :find_market_application - before_action :redirect_if_mode_already_chosen - - def show - @already_mandataire = already_mandataire_elsewhere? - @readonly = @market_application.application_mode.present? - @readonly_continue_path = next_step_path(@market_application) if @readonly - end - - def update - result = Candidate::CreateApplicationsForMode.call( - market_application: @market_application, - application_mode: params[:application_mode] - ) - - return handle_success(result) if result.success? - - @already_mandataire = already_mandataire_elsewhere? - @errors = result.errors - render :show, status: :unprocessable_content - end - - private - - def find_market_application - @market_application = MarketApplication.find_by!(identifier: params[:identifier]) - rescue ActiveRecord::RecordNotFound - render plain: "La candidature recherchée n'a pas été trouvée", status: :not_found - end - - def redirect_if_mode_already_chosen - return if @market_application.application_mode.nil? - return if action_name == 'show' && params[:readonly].present? - - redirect_to next_step_path(@market_application) - end - - def next_step_path(market_application) - if market_application.grouping_legal_type_choice_required? - grouping_legal_type_candidate_market_application_path(market_application.identifier) - else - company_identification_candidate_market_application_path(market_application.identifier) - end - end - - def already_mandataire_elsewhere? - Grouping - .joins(:mandataire_market_application) - .where(public_market: @market_application.public_market, market_applications: { siret: @market_application.siret }) - .where.not(market_applications: { id: @market_application.id }) - .exists? - end - - def handle_success(result) - redirect_to next_step_path(result.market_application) - end - end -end diff --git a/app/controllers/candidate/grouping_invitations_controller.rb b/app/controllers/candidate/grouping_invitations_controller.rb new file mode 100644 index 000000000..527322f87 --- /dev/null +++ b/app/controllers/candidate/grouping_invitations_controller.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Candidate + class GroupingInvitationsController < ::ApplicationController + def show + @grouping_member = GroupingMember.find_by(invitation_token: params[:token]) + render plain: "Cette invitation n'a pas été trouvée", status: :not_found if @grouping_member.nil? + end + end +end diff --git a/app/controllers/candidate/grouping_legal_types_controller.rb b/app/controllers/candidate/grouping_legal_types_controller.rb deleted file mode 100644 index 03da97a33..000000000 --- a/app/controllers/candidate/grouping_legal_types_controller.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -module Candidate - class GroupingLegalTypesController < Candidate::ApplicationController - include Candidate::GroupementFeatureGuard - include Candidate::MarketApplicationGuard - - prepend_before_action :find_market_application - before_action :redirect_unless_mandataire - - def show; end - - def update - result = Candidate::SetGroupingLegalType.call( - market_application: @market_application, - legal_type: params[:legal_type] - ) - - return handle_success if result.success? - - @errors = result.errors - render :show, status: :unprocessable_content - end - - private - - def find_market_application - @market_application = MarketApplication.find_by!(identifier: params[:identifier]) - rescue ActiveRecord::RecordNotFound - render plain: "La candidature recherchée n'a pas été trouvée", status: :not_found - end - - def redirect_unless_mandataire - @grouping = Grouping.joins(:mandataire_market_application).find_by(market_applications: { id: @market_application.id }) - return if @grouping - - redirect_to application_mode_candidate_market_application_path(@market_application.identifier) - end - - def handle_success - redirect_to company_identification_candidate_market_application_path(@market_application.identifier) - end - end -end diff --git a/app/controllers/candidate/grouping_wizard_controller.rb b/app/controllers/candidate/grouping_wizard_controller.rb new file mode 100644 index 000000000..476595a39 --- /dev/null +++ b/app/controllers/candidate/grouping_wizard_controller.rb @@ -0,0 +1,266 @@ +# frozen_string_literal: true + +module Candidate + class GroupingWizardController < Candidate::ApplicationController + include Wicked::Wizard + include Candidate::GroupementFeatureGuard + include Candidate::MarketApplicationGuard + + rescue_from Wicked::Wizard::InvalidStepError, with: :redirect_to_application_mode + + prepend_before_action :set_steps + prepend_before_action :find_market_application + skip_before_action :setup_wizard, only: %i[create_member destroy_member] + before_action :redirect_unless_mandataire, unless: -> { params[:id] == 'application_mode' } + before_action :redirect_to_composition_if_no_co_traitant, + if: -> { action_name == 'show' && params[:id] == 'grouping_composition_confirmation' } + + def show + case step + when :application_mode then show_application_mode + when :lot_selection_mode then show_lot_selection_mode + when :grouping_legal_type then show_grouping_legal_type + when :grouping_composition then show_grouping_composition + when :grouping_composition_confirmation then show_confirmation + end + + render_wizard unless performed? + end + + def update + case step + when :application_mode then update_application_mode + when :lot_selection_mode then update_lot_selection_mode + when :grouping_legal_type then update_grouping_legal_type + when :grouping_composition_confirmation then update_confirmation + end + end + + def create_member + result = Candidate::AddGroupingMember.call(grouping:, siret: params[:siret], email: params[:email]) + assign_create_member_result(result) + + render turbo_stream: member_form_turbo_streams, status: result.success? ? :ok : :unprocessable_content + end + + def destroy_member + Candidate::RemoveGroupingMember.call(grouping:, grouping_member_id: params[:id]) + + render turbo_stream: [ + turbo_stream.replace( + 'grouping_members_table', + partial: 'candidate/grouping_wizard/members_table', + locals: { grouping:, market_application: @market_application, editable: true } + ), + turbo_stream.replace( + 'grouping_composition_actions', + partial: 'candidate/grouping_wizard/composition_actions', + locals: { grouping: } + ) + ] + end + + private + + def set_steps + return self.steps = %i[application_mode] unless @market_application&.groupement? + + self.steps = [:application_mode, *lot_selection_mode_step, :grouping_legal_type, + :grouping_composition, :grouping_composition_confirmation] + end + + def lot_selection_mode_step + @market_application.public_market.lots.any? ? [:lot_selection_mode] : [] + end + + def find_market_application + @market_application = MarketApplication.find_by!(identifier: params[:identifier]) + rescue ActiveRecord::RecordNotFound + render plain: "La candidature recherchée n'a pas été trouvée", status: :not_found + end + + def finish_wizard_path + company_identification_candidate_market_application_path(@market_application.identifier) + end + + def redirect_to_application_mode + redirect_to grouping_wizard_step_candidate_market_application_path(@market_application.identifier, :application_mode) + end + + def redirect_unless_mandataire + return if grouping + + redirect_to_application_mode + end + + def redirect_to_composition_if_no_co_traitant + return if grouping.grouping_members.co_traitant.any? + + redirect_to grouping_wizard_step_candidate_market_application_path(@market_application.identifier, :grouping_composition) + end + + def next_wizard_step_path(from_step) + counterpart = @market_application.groupement_counterpart + return counterpart_next_wizard_step_path(counterpart) if counterpart + + next_real_step = next_step(from_step) + return finish_wizard_path if next_real_step == Wicked::FINISH_STEP + + wizard_step_path(@market_application, next_real_step) + end + + def counterpart_next_wizard_step_path(counterpart) + _target, step = counterpart.next_required_wizard_step + step ? wizard_step_path(counterpart, step) : finish_wizard_path + end + + def wizard_step_path(market_application, step) + grouping_wizard_step_candidate_market_application_path(market_application.identifier, step) + end + + # --- application_mode step --- + + def show_application_mode + return redirect_to(next_wizard_step_path(:application_mode)) if @market_application.application_mode.present? && !params[:readonly] + + @already_mandataire = already_mandataire_elsewhere? + @readonly = @market_application.application_mode.present? + @readonly_continue_path = readonly_continue_path if @readonly + end + + def readonly_continue_path + next_wizard_step_path(:application_mode) + end + + def update_application_mode + return redirect_to(next_wizard_step_path(:application_mode)) if @market_application.application_mode.present? + + result = Candidate::CreateApplicationsForMode.call( + market_application: @market_application, + application_mode: params[:application_mode] + ) + + return handle_application_mode_success(result) if result.success? + + @already_mandataire = already_mandataire_elsewhere? + @errors = result.errors + render_wizard(nil, status: :unprocessable_content) + end + + def handle_application_mode_success(result) + @market_application = result.market_application + set_steps + redirect_to next_wizard_step_path(:application_mode) + end + + def already_mandataire_elsewhere? + Grouping + .joins(:mandataire_market_application) + .where(public_market: @market_application.public_market, market_applications: { siret: @market_application.siret }) + .where.not(market_applications: { id: @market_application.id }) + .exists? + end + + # --- lot_selection_mode step --- + + def show_lot_selection_mode = @presenter = Candidate::LotSelectionModePresenter.new(@market_application) + + def update_lot_selection_mode + result = Candidate::SetLotSelectionModes.call(market_application: @market_application, lot_modes: lot_modes_param) + return redirect_to(next_wizard_step_path(:lot_selection_mode)) if result.success? + + @presenter = Candidate::LotSelectionModePresenter.new(@market_application) + @errors = result.errors + render_wizard(nil, status: :unprocessable_content) + end + + def lot_modes_param = params.fetch(:lot_modes, {}).permit!.to_h + + # --- grouping_legal_type step --- + + def show_grouping_legal_type + @grouping = grouping + end + + def update_grouping_legal_type + result = Candidate::SetGroupingLegalType.call( + market_application: @market_application, + legal_type: params[:legal_type] + ) + + if result.success? + redirect_to next_wizard_step_path(:grouping_legal_type) + else + @grouping = grouping + @errors = result.errors + render_wizard(nil, status: :unprocessable_content) + end + end + + # --- grouping_composition step --- + + def show_grouping_composition + resolve_mandataire_company_name + @grouping = grouping + @grouping_member = GroupingMember.new + end + + def assign_create_member_result(result) + if result.success? + @grouping_member = GroupingMember.new + else + @grouping_member = grouping.grouping_members.new(siret: params[:siret], email: params[:email]) + @errors = result.errors + end + end + + def member_form_turbo_streams + [ + turbo_stream.replace( + 'grouping_members_table', + partial: 'candidate/grouping_wizard/members_table', + locals: { grouping:, market_application: @market_application, editable: true } + ), + turbo_stream.replace( + 'grouping_member_form', + partial: 'candidate/grouping_wizard/member_form', + locals: { grouping:, market_application: @market_application, grouping_member: @grouping_member, errors: @errors } + ), + turbo_stream.replace( + 'grouping_composition_actions', + partial: 'candidate/grouping_wizard/composition_actions', + locals: { grouping: } + ) + ] + end + + def resolve_mandataire_company_name + mandataire_member = grouping.grouping_members.mandataire.first + return if mandataire_member.nil? || mandataire_member.company_name.present? + + result = FetchRaisonSociale.call(siret: mandataire_member.siret) + mandataire_member.update(company_name: result.raison_sociale) if result.success? + end + + # --- grouping_composition_confirmation step --- + + def show_confirmation + @grouping = grouping + end + + def update_confirmation + result = Candidate::ConfirmGroupingComposition.call(grouping:) + return redirect_to candidate_dashboard_path, notice: t('candidate.grouping_compositions.success') if result.success? + + @grouping = grouping + @errors = result.errors + render_wizard(nil, status: :unprocessable_content) + end + + def grouping + return @grouping if defined?(@grouping) + + @grouping = Grouping.joins(:mandataire_market_application).find_by(market_applications: { id: @market_application.id }) + end + end +end diff --git a/app/controllers/candidate/sessions_controller.rb b/app/controllers/candidate/sessions_controller.rb index cd84b3b17..27496f189 100644 --- a/app/controllers/candidate/sessions_controller.rb +++ b/app/controllers/candidate/sessions_controller.rb @@ -38,23 +38,35 @@ def destroy def sign_in_candidate(user, market_application) reconnection = market_application.user_id.present? - market_application.update!(user:) unless reconnection + link_candidate_to_application(user, market_application) unless reconnection session[:user_id] = user.id session[:market_application_identifier] = market_application.identifier redirect_to first_step_path(market_application) end + def link_candidate_to_application(user, market_application) + counterpart = market_application.groupement_counterpart + market_application.update!(user:) + counterpart&.update!(user:) + end + def first_step_path(market_application) - if market_application.completed? - return deadline_passed_candidate_market_application_path(market_application.identifier) unless market_application.public_market.open? + return completed_application_path(market_application) if market_application.completed? + + target, step = market_application.next_required_wizard_step + return company_identification_candidate_market_application_path(market_application.identifier) unless target - return candidate_sync_status_path(market_application.identifier) - end + wizard_step_path(target, step) + end + + def completed_application_path(market_application) + return deadline_passed_candidate_market_application_path(market_application.identifier) unless market_application.public_market.open? - return application_mode_candidate_market_application_path(market_application.identifier) if market_application.application_mode_choice_required? - return grouping_legal_type_candidate_market_application_path(market_application.identifier) if market_application.grouping_legal_type_choice_required? + candidate_sync_status_path(market_application.identifier) + end - company_identification_candidate_market_application_path(market_application.identifier) + def wizard_step_path(market_application, step) + grouping_wizard_step_candidate_market_application_path(market_application.identifier, step) end def handle_magic_link_sent(result) diff --git a/app/controllers/concerns/candidate/application_mode_guard.rb b/app/controllers/concerns/candidate/application_mode_guard.rb index 848431f76..de5d78995 100644 --- a/app/controllers/concerns/candidate/application_mode_guard.rb +++ b/app/controllers/concerns/candidate/application_mode_guard.rb @@ -11,9 +11,14 @@ module ApplicationModeGuard private def redirect_to_application_mode_choice - return unless @market_application&.application_mode_choice_required? + return unless @market_application - redirect_to application_mode_candidate_market_application_path(@market_application.identifier) + target, step = @market_application.next_required_wizard_step + redirect_to_wizard_step(step, target) if target + end + + def redirect_to_wizard_step(step, market_application = @market_application) + redirect_to grouping_wizard_step_candidate_market_application_path(market_application.identifier, step) end end end diff --git a/app/interactors/candidate/add_grouping_member.rb b/app/interactors/candidate/add_grouping_member.rb new file mode 100644 index 000000000..a22809586 --- /dev/null +++ b/app/interactors/candidate/add_grouping_member.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Candidate + class AddGroupingMember < ApplicationInteractor + delegate :grouping, :siret, :email, to: :context + + def call + validate_siret + validate_email + create_member + end + + private + + def validate_siret + return fail_siret_blank if siret.blank? + return fail_siret_invalid unless SiretValidator.valid?(siret) + return fail_siret_same_as_mandataire if siret == mandataire_siret + + fail_siret_already_added if grouping.grouping_members.exists?(siret:) + end + + def mandataire_siret + grouping.mandataire_grouping_member&.siret + end + + def validate_email + return fail_email_blank if email.blank? + + fail_email_invalid unless EmailValidator.valid?(email) + end + + def create_member + member = grouping.grouping_members.new(role: :co_traitant, siret:, email:, company_name: fetch_company_name) + return context.grouping_member = member if member.save + + context.fail!(errors: member.errors.to_hash) + end + + def fetch_company_name + result = FetchRaisonSociale.call(siret:) + result.raison_sociale if result.success? + rescue StandardError => e + Sentry.capture_exception(e) + nil + end + + def fail_siret_blank + context.fail!(errors: { siret: [I18n.t('candidate.validations.siret_blank')] }) + end + + def fail_siret_invalid + context.fail!(errors: { siret: [I18n.t('candidate.validations.siret_invalid')] }) + end + + def fail_siret_same_as_mandataire + context.fail!(errors: { siret: [I18n.t('candidate.validations.siret_same_as_mandataire')] }) + end + + def fail_siret_already_added + context.fail!(errors: { siret: [I18n.t('candidate.validations.siret_already_added')] }) + end + + def fail_email_blank + context.fail!(errors: { email: [I18n.t('candidate.validations.email_blank')] }) + end + + def fail_email_invalid + context.fail!(errors: { email: [I18n.t('candidate.validations.email_invalid')] }) + end + end +end diff --git a/app/interactors/candidate/confirm_grouping_composition.rb b/app/interactors/candidate/confirm_grouping_composition.rb new file mode 100644 index 000000000..c5aa5cbd5 --- /dev/null +++ b/app/interactors/candidate/confirm_grouping_composition.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Candidate + class ConfirmGroupingComposition < ApplicationInteractor + delegate :grouping, to: :context + + def call + pending_members = grouping.grouping_members.co_traitant.reject(&:invitation_sent?) + return fail_no_co_traitant if grouping.grouping_members.co_traitant.none? + + pending_members.each { |member| invite(member) } + end + + private + + def invite(member) + member.update!(invitation_token: SecureRandom.urlsafe_base64(32), invitation_token_created_at: Time.current) + GroupingInvitationMailer.invitation(member, invitation_url(member)).deliver_later + end + + def invitation_url(member) + Rails.application.routes.url_helpers.candidate_grouping_invitation_url(member.invitation_token) + end + + def fail_no_co_traitant + context.fail!(errors: { grouping: [I18n.t('candidate.validations.no_co_traitant')] }) + end + end +end diff --git a/app/interactors/candidate/create_applications_for_mode.rb b/app/interactors/candidate/create_applications_for_mode.rb index 964c48a11..6dea9d97f 100644 --- a/app/interactors/candidate/create_applications_for_mode.rb +++ b/app/interactors/candidate/create_applications_for_mode.rb @@ -28,20 +28,20 @@ def dispatch_mode end def set_solo - return context.fail!(errors: errors_from(market_application)) unless market_application.update(application_mode: :solo) + return context.fail!(errors: market_application.errors.to_hash) unless market_application.update(application_mode: :solo) context.market_application = market_application end def set_groupement - return context.fail!(errors: errors_from(market_application)) unless market_application.update(application_mode: :groupement) + return context.fail!(errors: market_application.errors.to_hash) unless market_application.update(application_mode: :groupement) context.market_application = market_application create_grouping(market_application) end def set_mixte - return context.fail!(errors: errors_from(market_application)) unless market_application.update(application_mode: :solo) + return context.fail!(errors: market_application.errors.to_hash) unless market_application.update(application_mode: :solo) create_groupement_counterpart end @@ -95,11 +95,5 @@ def uniqueness_error?(member) def fail_already_mandataire context.fail!(errors: { application_mode: [I18n.t('candidate.validations.already_mandataire')] }) end - - def errors_from(record) - record.errors.each_with_object({}) do |error, hash| - (hash[error.attribute] ||= []) << error.message - end - end end end diff --git a/app/interactors/candidate/remove_grouping_member.rb b/app/interactors/candidate/remove_grouping_member.rb new file mode 100644 index 000000000..6f2e1dbe7 --- /dev/null +++ b/app/interactors/candidate/remove_grouping_member.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Candidate + class RemoveGroupingMember < ApplicationInteractor + delegate :grouping, :grouping_member_id, to: :context + + def call + context.grouping_member = find_member + return fail_not_found unless context.grouping_member + return fail_already_invited if context.grouping_member.invitation_sent? + + context.grouping_member.destroy + end + + private + + def find_member + grouping.grouping_members.co_traitant.find_by(id: grouping_member_id) + end + + def fail_not_found + context.fail!(errors: { grouping_member: [I18n.t('candidate.validations.grouping_member_not_found')] }) + end + + def fail_already_invited + context.fail!(errors: { grouping_member: [I18n.t('candidate.validations.grouping_member_already_invited')] }) + end + end +end diff --git a/app/interactors/candidate/set_grouping_legal_type.rb b/app/interactors/candidate/set_grouping_legal_type.rb index ae9a9cb49..e6c0d72d7 100644 --- a/app/interactors/candidate/set_grouping_legal_type.rb +++ b/app/interactors/candidate/set_grouping_legal_type.rb @@ -17,7 +17,7 @@ def call def update_legal_type return if context.grouping.update(legal_type:) - context.fail!(errors: errors_from(context.grouping)) + context.fail!(errors: context.grouping.errors.to_hash) end def find_grouping @@ -35,11 +35,5 @@ def fail_grouping_not_found def fail_legal_type_invalid context.fail!(errors: { legal_type: [I18n.t('candidate.validations.legal_type_invalid')] }) end - - def errors_from(record) - record.errors.each_with_object({}) do |error, hash| - (hash[error.attribute] ||= []) << error.message - end - end end end diff --git a/app/interactors/candidate/set_lot_selection_modes.rb b/app/interactors/candidate/set_lot_selection_modes.rb new file mode 100644 index 000000000..29b6bdb43 --- /dev/null +++ b/app/interactors/candidate/set_lot_selection_modes.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Candidate + class SetLotSelectionModes < ApplicationInteractor + delegate :market_application, :lot_modes, to: :context + + def call + assign_applications + + return fail_groupement_application_not_found if groupement_application.blank? + return fail_invalid_lot_ids unless valid_lot_ids? + return fail_incomplete_selection unless selection_complete? + + ActiveRecord::Base.transaction do + solo_application&.update!(lot_ids: solo_lot_ids) + groupement_application.update!(lot_ids: groupement_lot_ids) + end + end + + private + + delegate :solo_application, :groupement_application, to: :context + + def assign_applications + context.solo_application = market_application.solo? ? market_application : market_application.solo_counterpart + context.groupement_application = market_application.groupement? ? market_application : market_application.groupement_counterpart + end + + def solo_lot_ids + lot_modes.select { |_id, mode| mode.to_s == 'solo' }.keys.map(&:to_i) + end + + def groupement_lot_ids + lot_modes.select { |_id, mode| mode.to_s == 'groupement' }.keys.map(&:to_i) + end + + def selection_complete? + return groupement_lot_ids.any? if solo_application.blank? + + solo_lot_ids.any? && groupement_lot_ids.any? + end + + def valid_lot_ids? + known_ids = market_application.public_market.lots.pluck(:id) + lot_modes.keys.map(&:to_i).all? { |id| known_ids.include?(id) } + end + + def fail_groupement_application_not_found + context.fail!(errors: { base: [I18n.t('candidate.validations.groupement_application_not_found')] }) + end + + def fail_invalid_lot_ids + context.fail!(errors: { lot_ids: [I18n.t('candidate.validations.lot_ids_invalid')] }) + end + + def fail_incomplete_selection + context.fail!(errors: { base: [I18n.t('candidate.validations.lot_selection_mode_incomplete')] }) + end + end +end diff --git a/app/interactors/create_market_application.rb b/app/interactors/create_market_application.rb index 28daf7dd0..acd374702 100644 --- a/app/interactors/create_market_application.rb +++ b/app/interactors/create_market_application.rb @@ -35,13 +35,7 @@ def create_new_application if application.save context.market_application = application else - context.fail!(errors: errors_from(application)) - end - end - - def errors_from(record) - record.errors.each_with_object({}) do |error, hash| - (hash[error.attribute] ||= []) << error.message + context.fail!(errors: application.errors.to_hash) end end end diff --git a/app/interactors/create_public_market.rb b/app/interactors/create_public_market.rb index a892247ed..395a30197 100644 --- a/app/interactors/create_public_market.rb +++ b/app/interactors/create_public_market.rb @@ -12,7 +12,7 @@ def call context.public_market = market FetchPublicMarketBuyerNameJob.perform_later(market.id) else - context.fail!(errors: errors_from(market)) + context.fail!(errors: market.errors.to_hash) end end @@ -36,10 +36,4 @@ def market_type_for_lot(lot_type_code) code = lot_type_code.presence || params[:market_type_codes]&.first MarketType.find_by(code:) end - - def errors_from(record) - record.errors.each_with_object({}) do |error, hash| - (hash[error.attribute] ||= []) << error.message - end - end end diff --git a/app/interactors/fetch_buyer_name/extract_name.rb b/app/interactors/fetch_raison_sociale/extract_name.rb similarity index 50% rename from app/interactors/fetch_buyer_name/extract_name.rb rename to app/interactors/fetch_raison_sociale/extract_name.rb index 408cfdbd0..6cd1d796c 100644 --- a/app/interactors/fetch_buyer_name/extract_name.rb +++ b/app/interactors/fetch_raison_sociale/extract_name.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true -class FetchBuyerName::ExtractName < ApplicationInteractor +class FetchRaisonSociale::ExtractName < ApplicationInteractor def call body = JSON.parse(context.response.body) - context.buyer_name = body.dig('data', 'unite_legale', 'personne_morale_attributs', 'raison_sociale') + context.raison_sociale = body.dig('data', 'unite_legale', 'personne_morale_attributs', 'raison_sociale') rescue JSON::ParserError context.fail!(error: 'Invalid JSON response') end diff --git a/app/interactors/fetch_buyer_name/make_request.rb b/app/interactors/fetch_raison_sociale/make_request.rb similarity index 62% rename from app/interactors/fetch_buyer_name/make_request.rb rename to app/interactors/fetch_raison_sociale/make_request.rb index 68d02411b..2aaa2bb33 100644 --- a/app/interactors/fetch_buyer_name/make_request.rb +++ b/app/interactors/fetch_raison_sociale/make_request.rb @@ -1,13 +1,15 @@ # frozen_string_literal: true -class FetchBuyerName::MakeRequest < MakeRequest +class FetchRaisonSociale::MakeRequest < MakeRequest + delegate :siret, to: :context + def call validate_credentials super end def endpoint_url - "v3/insee/sirene/etablissements/#{context.public_market.siret}" + "v3/insee/sirene/etablissements/#{siret}" end private @@ -19,10 +21,10 @@ def validate_credentials end def request_recipient - context.public_market.siret + siret end def request_object - "Configuration marché: #{context.public_market.name}" + context.request_object || 'Réponse appel offre' end end diff --git a/app/javascript/controllers/enable_on_fill_controller.js b/app/javascript/controllers/enable_on_fill_controller.js new file mode 100644 index 000000000..becb5a109 --- /dev/null +++ b/app/javascript/controllers/enable_on_fill_controller.js @@ -0,0 +1,18 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["input", "submitButton"] + + connect() { + this._update() + } + + toggle() { + this._update() + } + + _update() { + const allFilled = this.inputTargets.every(input => input.value.trim().length > 0) + this.submitButtonTargets.forEach(btn => { btn.disabled = !allFilled }) + } +} diff --git a/app/javascript/controllers/lot_selection_mode_controller.js b/app/javascript/controllers/lot_selection_mode_controller.js new file mode 100644 index 000000000..2d6eca7f8 --- /dev/null +++ b/app/javascript/controllers/lot_selection_mode_controller.js @@ -0,0 +1,32 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["radio", "submitButton", "section"] + static values = { requiresBoth: Boolean } + + connect() { + this.recompute() + } + + applyBulk(event) { + const mode = event.target.value + if (!mode) return + + const section = event.target.closest("[data-lot-selection-mode-target='section']") + section.querySelectorAll("[data-lot-selection-mode-target='radio']").forEach(radio => { + if (radio.value === mode) radio.checked = true + }) + + this.recompute() + } + + recompute() { + const checked = this.radioTargets.filter(radio => radio.checked) + const hasGroupement = checked.some(radio => radio.value === "groupement") + const hasSolo = checked.some(radio => radio.value === "solo") + + const complete = this.requiresBothValue ? hasSolo && hasGroupement : hasGroupement + + this.submitButtonTargets.forEach(btn => { btn.disabled = !complete }) + } +} diff --git a/app/mailers/grouping_invitation_mailer.rb b/app/mailers/grouping_invitation_mailer.rb new file mode 100644 index 000000000..af2bf089e --- /dev/null +++ b/app/mailers/grouping_invitation_mailer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class GroupingInvitationMailer < ApplicationMailer + def invitation(grouping_member, url) + @grouping_member = grouping_member + @url = url + @market_name = grouping_member.grouping.public_market.name + + mail(to: @grouping_member.email, subject: I18n.t('grouping_invitation_mailer.invitation.subject')) + end +end diff --git a/app/models/grouping_member.rb b/app/models/grouping_member.rb index bf14ef89b..4274c3570 100644 --- a/app/models/grouping_member.rb +++ b/app/models/grouping_member.rb @@ -13,14 +13,27 @@ class GroupingMember < ApplicationRecord before_validation :set_public_market validates :siret, presence: true, siret: true + validates :siret, uniqueness: { scope: :grouping_id } validates :siret, uniqueness: { scope: :public_market_id }, if: :mandataire? - validates :email, presence: true, if: :co_traitant? + validates :email, presence: true, email: true, if: :co_traitant? validates :grouping_id, uniqueness: { scope: :role }, if: :mandataire? validates :market_application, presence: true, if: :mandataire? + validate :siret_not_mandataire, if: :co_traitant? + + def invitation_sent? + invitation_token_created_at.present? + end private def set_public_market self.public_market_id = grouping.public_market_id if grouping end + + def siret_not_mandataire + mandataire_siret = grouping&.mandataire_grouping_member&.siret + return if siret != mandataire_siret + + errors.add(:siret, :same_as_mandataire) + end end diff --git a/app/models/market_application.rb b/app/models/market_application.rb index 36ee72eaf..d1ed05a03 100644 --- a/app/models/market_application.rb +++ b/app/models/market_application.rb @@ -52,11 +52,55 @@ def application_mode_choice_required? FeatureFlags::Groupement.enabled? && application_mode.nil? end + def lot_selection_mode_choice_required? + return false unless FeatureFlags::Groupement.enabled? && groupement? + return false if public_market.lots.none? + + mandataire_applications.sum { |app| app.lots.count }.zero? + end + def grouping_legal_type_choice_required? FeatureFlags::Groupement.enabled? && groupement? && Grouping.joins(:mandataire_market_application).exists?(legal_type: nil, market_applications: { id: }) end + def grouping_composition_choice_required? + return false unless FeatureFlags::Groupement.enabled? && groupement? + + grouping = mandataire_grouping + return false if grouping.nil? || grouping.legal_type.nil? + + grouping.grouping_members.co_traitant.none?(&:invitation_sent?) + end + + def mandataire_grouping + Grouping.joins(:mandataire_grouping_member) + .find_by(mandataire_grouping_member: { market_application_id: id }) + end + + def groupement_counterpart + return nil if groupement? + + MarketApplication.where(public_market:, siret:, application_mode: :groupement, user_id:).where.not(id:).first + end + + def solo_counterpart + return nil if solo? + + MarketApplication.where(public_market:, siret:, application_mode: :solo, user_id:).where.not(id:).first + end + + def next_required_wizard_step + return [self, :application_mode] if application_mode_choice_required? + + target = groupement_counterpart || self + return [target, :lot_selection_mode] if target.lot_selection_mode_choice_required? + return [target, :grouping_legal_type] if target.grouping_legal_type_choice_required? + return [target, :grouping_composition] if target.grouping_composition_choice_required? + + nil + end + def update_api_status(api_name, status:, fields_filled: 0) with_lock do updated_status = (api_fetch_status || {}).dup @@ -120,6 +164,10 @@ def all_security_scans_complete? private + def mandataire_applications + MarketApplication.where(public_market:, siret:, user_id:).where(application_mode: %i[solo groupement]) + end + def generate_identifier return if identifier.present? diff --git a/app/models/public_market.rb b/app/models/public_market.rb index 1fecfe493..82fef7e06 100644 --- a/app/models/public_market.rb +++ b/app/models/public_market.rb @@ -36,6 +36,10 @@ def multi_type_lots? effective_lot_market_types.size > 1 end + def lots_by_type_sorted + lots.ordered.includes(:market_type, :platform_market_type).group_by(&:effective_market_type) + end + def effective_market_type_codes lot_codes = effective_lot_market_types.map(&:code) (market_type_codes + lot_codes).uniq diff --git a/app/organizers/fetch_buyer_name.rb b/app/organizers/fetch_buyer_name.rb deleted file mode 100644 index 73b888c33..000000000 --- a/app/organizers/fetch_buyer_name.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -class FetchBuyerName < ApplicationOrganizer - organize FetchBuyerName::MakeRequest, - FetchBuyerName::ExtractName -end diff --git a/app/organizers/fetch_raison_sociale.rb b/app/organizers/fetch_raison_sociale.rb new file mode 100644 index 000000000..889e100b0 --- /dev/null +++ b/app/organizers/fetch_raison_sociale.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class FetchRaisonSociale < ApplicationOrganizer + organize FetchRaisonSociale::MakeRequest, + FetchRaisonSociale::ExtractName +end diff --git a/app/presenters/candidate/lot_selection_mode_presenter.rb b/app/presenters/candidate/lot_selection_mode_presenter.rb new file mode 100644 index 000000000..77c355de8 --- /dev/null +++ b/app/presenters/candidate/lot_selection_mode_presenter.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Candidate + class LotSelectionModePresenter + include LotLabelConcern + + def initialize(market_application) + @market_application = market_application + @solo_application = market_application.solo? ? market_application : market_application.solo_counterpart + @groupement_application = market_application.groupement? ? market_application : market_application.groupement_counterpart + end + + def requires_solo_and_groupement? + @solo_application.present? + end + + def lots_by_type_sorted + @lots_by_type_sorted ||= public_market.lots_by_type_sorted + end + + def mode_for(lot) + mode_by_lot_id.fetch(lot.id) { default_mode_for } + end + + private + + attr_reader :market_application + + def public_market + @public_market ||= market_application.public_market + end + + def mode_by_lot_id + @mode_by_lot_id ||= begin + modes = {} + @solo_application&.lot_ids&.each { |id| modes[id] = 'solo' } + @groupement_application.lot_ids.each { |id| modes[id] = 'groupement' } + modes + end + end + + def default_mode_for + mode_by_lot_id.any? ? 'none' : 'groupement' + end + end +end diff --git a/app/presenters/market_application_presenter.rb b/app/presenters/market_application_presenter.rb index 4773ca5d7..651722e7d 100644 --- a/app/presenters/market_application_presenter.rb +++ b/app/presenters/market_application_presenter.rb @@ -98,9 +98,7 @@ def selected_lot_types end def lots_by_type_sorted - @lots_by_type_sorted ||= public_market.lots.ordered - .includes(:market_type, :platform_market_type) - .group_by(&:effective_market_type) + @lots_by_type_sorted ||= public_market.lots_by_type_sorted end def selected_lots diff --git a/app/views/candidate/grouping_invitations/show.html.erb b/app/views/candidate/grouping_invitations/show.html.erb new file mode 100644 index 000000000..3434d2097 --- /dev/null +++ b/app/views/candidate/grouping_invitations/show.html.erb @@ -0,0 +1,4 @@ +
+

<%= t('candidate.grouping_invitations.title') %>

+

<%= t('candidate.grouping_invitations.pending') %>

+
diff --git a/app/views/candidate/grouping_wizard/_composition_actions.html.erb b/app/views/candidate/grouping_wizard/_composition_actions.html.erb new file mode 100644 index 000000000..d59b59191 --- /dev/null +++ b/app/views/candidate/grouping_wizard/_composition_actions.html.erb @@ -0,0 +1,9 @@ + diff --git a/app/views/candidate/grouping_wizard/_member_form.html.erb b/app/views/candidate/grouping_wizard/_member_form.html.erb new file mode 100644 index 000000000..6412491ce --- /dev/null +++ b/app/views/candidate/grouping_wizard/_member_form.html.erb @@ -0,0 +1,51 @@ +
+
+

<%= t('candidate.grouping_compositions.add_member_title') %>

+ + <%= form_with url: grouping_wizard_members_candidate_market_application_path(market_application.identifier), + method: :post, + data: { turbo_stream: true, controller: "enable-on-fill" } do |form| %> +
+
+
+ + <%= form.text_field :siret, value: grouping_member.siret, id: "grouping_member_siret", + class: "fr-input #{'fr-input--error' if errors&.dig(:siret)}", + data: { "enable-on-fill-target": "input", action: "input->enable-on-fill#toggle" } %> + <% if errors&.dig(:siret) %> +

<%= Array(errors[:siret]).first %>

+ <% end %> +
+
+ +
+
+ + <%= form.text_field :email, value: grouping_member.email, id: "grouping_member_email", + class: "fr-input #{'fr-input--error' if errors&.dig(:email)}", + data: { "enable-on-fill-target": "input", action: "input->enable-on-fill#toggle" } %> + <% if errors&.dig(:email) %> +

<%= Array(errors[:email]).first %>

+ <% end %> +
+
+
+ + <%= form.button t('candidate.grouping_compositions.add_button'), + type: "submit", + disabled: true, + class: "fr-btn fr-icon-user-add-line fr-btn--icon-left fr-mt-3w", + data: { "enable-on-fill-target": "submitButton" } %> + <% end %> +
+
diff --git a/app/views/candidate/grouping_wizard/_members_table.html.erb b/app/views/candidate/grouping_wizard/_members_table.html.erb new file mode 100644 index 000000000..1c4f6aae6 --- /dev/null +++ b/app/views/candidate/grouping_wizard/_members_table.html.erb @@ -0,0 +1,70 @@ +
+
+ + + + + + + <% if editable %> + + <% end %> + + + + + + + + <% if editable %> + + <% end %> + + + + <% grouping.grouping_members.order(:role).each do |member| %> + + + + + + <% if editable %> + + <% end %> + + <% end %> + <% if grouping.grouping_members.co_traitant.none? %> + + + + <% end %> + +
<%= t('candidate.grouping_compositions.table_company') %><%= t('candidate.grouping_compositions.table_email') %><%= t('candidate.grouping_compositions.table_role') %><%= t('candidate.grouping_compositions.table_status') %>
+ <%= member.company_name.presence || t('candidate.grouping_compositions.company_unknown') %>
+ <%= t('candidate.grouping_compositions.siret_label') %> <%= member.siret %> +
<%= member.email %> + + <%= t("candidate.grouping_compositions.role_#{member.role}") %> + + + <% if member.mandataire? %> + - + <% elsif member.invitation_sent? %> + <%= t('candidate.grouping_compositions.status_sent') %> + <% else %> + <%= t('candidate.grouping_compositions.status_not_sent') %> + <% end %> + + <% if member.co_traitant? && !member.invitation_sent? %> + <%= button_to t('candidate.grouping_compositions.remove_button'), + grouping_wizard_member_candidate_market_application_path(market_application.identifier, member), + method: :delete, + form: { data: { turbo_stream: true } }, + class: "fr-btn fr-btn--secondary fr-btn--sm fr-icon-delete-line fr-btn--icon-left" %> + <% end %> +
+ <%= t('candidate.grouping_compositions.no_co_traitant_line1') %>
+ <%= t('candidate.grouping_compositions.no_co_traitant_line2') %> +
+
+
diff --git a/app/views/candidate/application_modes/show.html.erb b/app/views/candidate/grouping_wizard/application_mode.html.erb similarity index 96% rename from app/views/candidate/application_modes/show.html.erb rename to app/views/candidate/grouping_wizard/application_mode.html.erb index ab949d30e..792df0a61 100644 --- a/app/views/candidate/application_modes/show.html.erb +++ b/app/views/candidate/grouping_wizard/application_mode.html.erb @@ -28,9 +28,7 @@ <% end %> - <%= form_with url: application_mode_candidate_market_application_path(@market_application.identifier), - method: :patch, - data: { controller: "enable-on-choice" } do |form| %> + <%= form_with url: wizard_path, method: :patch, data: { controller: "enable-on-choice" } do |form| %>
> <%= t('candidate.application_modes.title') %> diff --git a/app/views/candidate/grouping_wizard/grouping_composition.html.erb b/app/views/candidate/grouping_wizard/grouping_composition.html.erb new file mode 100644 index 000000000..42770a3bd --- /dev/null +++ b/app/views/candidate/grouping_wizard/grouping_composition.html.erb @@ -0,0 +1,25 @@ +<% content_for :title, t('candidate.grouping_compositions.title') %> + +
+

<%= t('candidate.grouping_compositions.step_current') %>

+

<%= t('candidate.grouping_compositions.title') %>

+ + + +

+ <%= t('candidate.grouping_compositions.step_next') %> + <%= t('candidate.grouping_compositions.step_next_label') %> +

+ +

<%= t('candidate.grouping_compositions.title') %>

+

<%= t('candidate.grouping_compositions.intro') %>

+ + <%= render 'members_table', grouping: @grouping, market_application: @market_application, editable: true %> + <%= render 'member_form', grouping: @grouping, market_application: @market_application, grouping_member: @grouping_member, errors: @errors %> + + <%= render 'composition_actions', grouping: @grouping %> +
diff --git a/app/views/candidate/grouping_wizard/grouping_composition_confirmation.html.erb b/app/views/candidate/grouping_wizard/grouping_composition_confirmation.html.erb new file mode 100644 index 000000000..069b77e92 --- /dev/null +++ b/app/views/candidate/grouping_wizard/grouping_composition_confirmation.html.erb @@ -0,0 +1,46 @@ +<% content_for :title, t('candidate.grouping_compositions.confirm.title') %> + +
+

<%= t('candidate.grouping_compositions.confirm.step_current') %>

+

<%= t('candidate.grouping_compositions.confirm.step_next_label') %>

+ + + +

<%= t('candidate.grouping_compositions.confirm.title') %>

+ + <% if @errors.present? %> + + <% end %> + +

+ <%= t('candidate.grouping_compositions.confirm.legal_type_label') %> + + <%= t("candidate.grouping_legal_types.#{@grouping.legal_type}.label") %> + +

+ + <%= render 'members_table', grouping: @grouping, market_application: @market_application, editable: false %> + + +
diff --git a/app/views/candidate/grouping_legal_types/show.html.erb b/app/views/candidate/grouping_wizard/grouping_legal_type.html.erb similarity index 88% rename from app/views/candidate/grouping_legal_types/show.html.erb rename to app/views/candidate/grouping_wizard/grouping_legal_type.html.erb index 632a4385d..5f2527ddb 100644 --- a/app/views/candidate/grouping_legal_types/show.html.erb +++ b/app/views/candidate/grouping_wizard/grouping_legal_type.html.erb @@ -2,7 +2,7 @@

<%= t('candidate.grouping_legal_types.step_current') %>

-

<%= t('candidate.grouping_legal_types.title') %>

+

<%= t('candidate.grouping_legal_types.title') %>