diff --git a/app/components/dossiers/message_component/message_component.en.yml b/app/components/dossiers/message_component/message_component.en.yml index a24af5c49..22ac79059 100644 --- a/app/components/dossiers/message_component/message_component.en.yml +++ b/app/components/dossiers/message_component/message_component.en.yml @@ -7,3 +7,4 @@ en: automatic_email: Automatic email you: You deleted_body: Message deleted + flagged_pending_corrections: Modification requested diff --git a/app/components/dossiers/message_component/message_component.fr.yml b/app/components/dossiers/message_component/message_component.fr.yml index 4386b2ea2..af808590c 100644 --- a/app/components/dossiers/message_component/message_component.fr.yml +++ b/app/components/dossiers/message_component/message_component.fr.yml @@ -7,3 +7,4 @@ fr: automatic_email: Email automatique you: Vous deleted_body: Message supprimé + flagged_pending_corrections: Modification demandée diff --git a/app/components/dossiers/message_component/message_component.html.haml b/app/components/dossiers/message_component/message_component.html.haml index 2785815a2..992c60b45 100644 --- a/app/components/dossiers/message_component/message_component.html.haml +++ b/app/components/dossiers/message_component/message_component.html.haml @@ -6,6 +6,9 @@ = commentaire_issuer - if commentaire_from_guest? %span.fr-text--xs.fr-text-mention--grey.font-weight-normal= t('.guest') + - if commentaire.flagged_pending_corrections? + %span.fr-badge.fr-badge--sm.fr-badge--info + = t('.flagged_pending_corrections') %span.date{ class: ["fr-text--xs", "fr-text-mention--grey", "font-weight-normal", highlight_if_unseen_class], data: scroll_to_target } = commentaire_date .rich-text diff --git a/app/controllers/instructeurs/dossiers_controller.rb b/app/controllers/instructeurs/dossiers_controller.rb index 220fd83a5..53513e77b 100644 --- a/app/controllers/instructeurs/dossiers_controller.rb +++ b/app/controllers/instructeurs/dossiers_controller.rb @@ -223,6 +223,34 @@ module Instructeurs render :change_state end + def pending_corrections + message, piece_jointe = params.require(:dossier).permit(:motivation, :justificatif_motivation).values + + if message.empty? + flash.alert = "Vous devez préciser quelle modification est attendue." + elsif !dossier.may_flag_as_pending_correction? + flash.alert = dossier.termine? ? "Impossible de demander de corriger un dossier terminé." : "Le dossier est déjà en attente de correction." + else + commentaire = CommentaireService.create(current_instructeur, dossier, { body: message, piece_jointe: }) + dossier.flag_as_pending_correction!(commentaire) + dossier.update!(last_commentaire_updated_at: Time.zone.now) + current_instructeur.follow(dossier) + + flash.notice = "Dossier marqué comme en attente de correction." + end + + respond_to do |format| + format.turbo_stream do + @dossier = dossier + render :change_state + end + + format.html do + redirect_back(fallback_location: instructeur_procedure_path(procedure)) + end + end + end + def create_commentaire @commentaire = CommentaireService.create(current_instructeur, dossier, commentaire_params) diff --git a/app/models/commentaire.rb b/app/models/commentaire.rb index 1a90af788..a3eecafed 100644 --- a/app/models/commentaire.rb +++ b/app/models/commentaire.rb @@ -95,6 +95,10 @@ class Commentaire < ApplicationRecord update! body: '' end + def flagged_pending_corrections? + DossierResolution.exists?(commentaire: self) + end + private def notify diff --git a/app/models/concerns/dossier_resolvable_concern.rb b/app/models/concerns/dossier_resolvable_concern.rb index cb71fe885..f4d827b56 100644 --- a/app/models/concerns/dossier_resolvable_concern.rb +++ b/app/models/concerns/dossier_resolvable_concern.rb @@ -4,6 +4,22 @@ module DossierResolvableConcern included do has_many :resolutions, class_name: 'DossierResolution', dependent: :destroy + def flag_as_pending_correction!(commentaire) + return unless may_flag_as_pending_correction? + + resolutions.create(commentaire:) + + return if en_construction? + + repasser_en_construction!(instructeur: commentaire.instructeur) + end + + def may_flag_as_pending_correction? + return false if resolutions.pending.exists? + + en_construction? || may_repasser_en_construction? + end + def pending_resolution? # We don't want to show any alert if user is not allowed to modify the dossier return false unless en_construction? diff --git a/app/views/instructeurs/dossiers/_instruction_button.html.haml b/app/views/instructeurs/dossiers/_instruction_button.html.haml index fbf1b1b3b..c8ed93ca5 100644 --- a/app/views/instructeurs/dossiers/_instruction_button.html.haml +++ b/app/views/instructeurs/dossiers/_instruction_button.html.haml @@ -1,35 +1,56 @@ -- if dossier.en_instruction? +- if dossier.en_instruction? || (dossier.en_construction? && dossier.may_flag_as_pending_correction?) = render Dropdown::MenuComponent.new(wrapper: :div, wrapper_options: { data: { turbo_force: :server } }, button_options: { class: [button_or_label_class(dossier)]}, role: dossier.en_instruction? ? :region : :menu) do |menu| - menu.with_button_inner_html do - Instruire le dossier + = dossier.en_instruction? ? "Instruire le dossier" : "Demander une modification" - - menu.with_item do - = link_to('#', onclick: "DS.showMotivation(event, 'accept');", role: 'menuitem') do - %span.icon.accept - .dropdown-description - %h4 Accepter - L’usager sera informé que son dossier a été accepté + - if dossier.en_instruction? + - menu.with_item do + = link_to('#', onclick: "DS.showMotivation(event, 'accept');", role: 'menuitem') do + %span.icon.accept + .dropdown-description + %h4 Accepter + L’usager sera informé que son dossier a été accepté - - menu.with_item(class: "hidden inactive form-inside") do - = render partial: 'instructeurs/dossiers/instruction_button_motivation', locals: { dossier: dossier, placeholder: 'Expliquez au demandeur pourquoi ce dossier est accepté (facultatif)', popup_class: 'accept', process_action: 'accepter', title: 'Accepter', confirm: "Confirmez-vous l'acceptation ce dossier ?" } + - menu.with_item(class: "hidden inactive form-inside") do + = render partial: 'instructeurs/dossiers/instruction_button_motivation', locals: { dossier: dossier, placeholder: 'Expliquez au demandeur pourquoi ce dossier est accepté (facultatif)', popup_class: 'accept', process_action: 'accepter', title: 'Accepter', confirm: "Confirmez-vous l'acceptation ce dossier ?" } - - menu.with_item do - = link_to('#', onclick: "DS.showMotivation(event, 'without-continuation');", role: 'menuitem') do - %span.icon.without-continuation - .dropdown-description - %h4 Classer sans suite - L’usager sera informé que son dossier a été classé sans suite + - menu.with_item do + = link_to('#', onclick: "DS.showMotivation(event, 'without-continuation');", role: 'menuitem') do + %span.icon.without-continuation + .dropdown-description + %h4 Classer sans suite + L’usager sera informé que son dossier a été classé sans suite - - menu.with_item(class: "hidden inactive form-inside") do - = render partial: 'instructeurs/dossiers/instruction_button_motivation', locals: { dossier: dossier, placeholder: 'Expliquez au demandeur pourquoi ce dossier est classé sans suite (obligatoire)', popup_class: 'without-continuation', process_action: 'classer_sans_suite', title: 'Classer sans suite', confirm: 'Confirmez-vous le classement sans suite de ce dossier ?' } + - menu.with_item(class: "hidden inactive form-inside") do + = render partial: 'instructeurs/dossiers/instruction_button_motivation', locals: { dossier: dossier, placeholder: 'Expliquez au demandeur pourquoi ce dossier est classé sans suite (obligatoire)', popup_class: 'without-continuation', process_action: 'classer_sans_suite', title: 'Classer sans suite', confirm: 'Confirmez-vous le classement sans suite de ce dossier ?' } - - menu.with_item do - = link_to('#', onclick: "DS.showMotivation(event, 'refuse');", role: 'menuitem') do - %span.icon.refuse - .dropdown-description - %h4 Refuser - L’usager sera informé que son dossier a été refusé + - menu.with_item do + = link_to('#', onclick: "DS.showMotivation(event, 'refuse');", role: 'menuitem') do + %span.icon.refuse + .dropdown-description + %h4 Refuser + L’usager sera informé que son dossier a été refusé - - menu.with_item(class: "hidden inactive form-inside") do - = render partial: 'instructeurs/dossiers/instruction_button_motivation', locals: { dossier: dossier, placeholder: 'Expliquez au demandeur pourquoi ce dossier est refusé (obligatoire)', popup_class: 'refuse', process_action: 'refuser', title: 'Refuser', confirm: 'Confirmez-vous le refus de ce dossier ?' } + - menu.with_item(class: "hidden inactive form-inside") do + = render partial: 'instructeurs/dossiers/instruction_button_motivation', locals: { dossier: dossier, placeholder: 'Expliquez au demandeur pourquoi ce dossier est refusé (obligatoire)', popup_class: 'refuse', process_action: 'refuser', title: 'Refuser', confirm: 'Confirmez-vous le refus de ce dossier ?' } + + - if dossier.may_flag_as_pending_correction? + - menu.with_item do + = link_to('#', onclick: "DS.showMotivation(event, 'pending_corrections');", role: 'menuitem') do + %span.fr-icon.fr-icon-error-warning-line.fr-text-default--info{ "aria-hidden": "true" } + .dropdown-description + %h4 Demander une modification + L’usager sera informé que des modifications sont attendues + + - menu.with_item(class: class_names("inactive form-inside": true, hidden: dossier.en_instruction?)) do + = render partial: 'instructeurs/dossiers/instruction_button_motivation', locals: { dossier: dossier, + visible: !dossier.en_instruction?, + form_path: pending_corrections_instructeur_dossier_path(dossier.procedure, dossier), + placeholder: 'Expliquez au demandeur quelles modifications sont attendues', + popup_class: 'pending_corrections', + button_justificatif_label: "Ajouter une pièce jointe (facultatif)", + process_button: dossier.en_construction? ? 'Valider' : 'Valider et repasser en construction', + process_action: nil, + title: 'Marquer en attente de corrections', + confirm: 'Envoyer la demande de corrections ?'} diff --git a/app/views/instructeurs/dossiers/_instruction_button_motivation.html.haml b/app/views/instructeurs/dossiers/_instruction_button_motivation.html.haml index 4d089c6e4..31bc2c56f 100644 --- a/app/views/instructeurs/dossiers/_instruction_button_motivation.html.haml +++ b/app/views/instructeurs/dossiers/_instruction_button_motivation.html.haml @@ -1,5 +1,5 @@ -.motivation.hidden{ class: popup_class } - = form_tag(terminer_instructeur_dossier_path(dossier.procedure, dossier), data: { turbo: true, turbo_confirm: confirm }, method: :post, multipart: true) do +.motivation{ class: class_names(popup_class => true, hidden: !defined?(visible) || !visible) } + = form_tag(defined?(form_path) ? form_path : terminer_instructeur_dossier_path(dossier.procedure, dossier), data: { turbo: true, turbo_confirm: confirm }, method: :post, multipart: true) do - if title == 'Accepter' = text_area :dossier, :motivation, class: 'fr-input', placeholder: placeholder, required: false - if dossier.attestation_template&.activated? @@ -28,11 +28,11 @@ - else = text_area :dossier, :motivation, class: 'fr-input', placeholder: placeholder, required: true .optional-justificatif{ id: "justificatif_motivation_suggest_#{popup_class}", onclick: "DS.showImportJustificatif('#{popup_class}');" } - %button.fr-btn.fr-btn--tertiary-no-outline.fr-btn--icon-left.fr-icon-attachment-line.fr-ml-0{ type: 'button', onclick: "DS.showImportJustificatif('accept');" } Ajouter un justificatif (optionnel) + %button.fr-btn.fr-btn--tertiary-no-outline.fr-btn--icon-left.fr-icon-attachment-line.fr-ml-0{ type: 'button', onclick: "DS.showImportJustificatif('accept');" }= defined?(button_justificatif_label) ? button_justificatif_label : "Ajouter un justificatif (optionnel)" .hidden{ id: "justificatif_motivation_import_#{popup_class}" } = file_field :dossier, :justificatif_motivation, direct_upload: true, id: "dossier_justificatif_motivation_#{popup_class}",onchange: "DS.showDeleteJustificatif('#{popup_class}');" .hidden.js_delete_motivation{ id: "delete_motivation_import_#{popup_class}" } %button.fr-btn.fr-btn--tertiary-no-outline.fr-btn--icon-left.fr-icon-delete-line.fr-ml-0.fr-mt-1w{ type: 'button', onclick: "DS.deleteJustificatif('#{popup_class}');" } Supprimer le justificatif .fr-mt-2w = button_tag "Annuler", type: :reset, class: 'fr-btn fr-btn--secondary', onclick: 'DS.motivationCancel();' - = button_tag 'Valider la décision', name: :process_action, value: process_action, class: 'fr-btn fr-mr-0', title: title + = button_tag defined?(process_button) ? process_button : 'Valider la décision', name: :process_action, value: process_action, class: 'fr-btn fr-mr-0', title: title diff --git a/config/routes.rb b/config/routes.rb index 5c15cebf5..91d193c75 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -445,6 +445,7 @@ Rails.application.routes.draw do post 'repasser-en-construction' => 'dossiers#repasser_en_construction' post 'repasser-en-instruction' => 'dossiers#repasser_en_instruction' post 'terminer' + post 'pending_corrections' post 'send-to-instructeurs' => 'dossiers#send_to_instructeurs' post 'avis' => 'dossiers#create_avis' get 'print' => 'dossiers#print' diff --git a/spec/controllers/instructeurs/dossiers_controller_spec.rb b/spec/controllers/instructeurs/dossiers_controller_spec.rb index 4b499faf4..5961564ec 100644 --- a/spec/controllers/instructeurs/dossiers_controller_spec.rb +++ b/spec/controllers/instructeurs/dossiers_controller_spec.rb @@ -496,6 +496,90 @@ describe Instructeurs::DossiersController, type: :controller do end end + describe '#pending_corrections' do + let(:message) { 'do that' } + let(:justificatif) { nil } + + subject do + post :pending_corrections, params: { + procedure_id: procedure.id, dossier_id: dossier.id, + dossier: { motivation: message, justificatif_motivation: justificatif } + }, format: :turbo_stream + end + + before { sign_in(instructeur.user) } + + context "dossier en instruction" do + let(:dossier) { create(:dossier, :en_instruction, :with_individual, procedure: procedure) } + + before { subject } + + it 'pass en_construction and create a pending resolution' do + expect(response).to have_http_status(:ok) + expect(response.body).to include('en attente de modifications') + + expect(dossier.reload).to be_en_construction + expect(dossier).to be_pending_resolution + end + + it 'create a comment with text body' do + expect(dossier.commentaires.last.body).to eq("do that") + expect(dossier.commentaires.last).to be_flagged_pending_corrections + end + + context 'with an attachment' do + let(:justificatif) { fake_justificatif } + + it 'attach file to comment' do + expect(dossier.commentaires.last.piece_jointe).to be_attached + end + end + + context 'with an empty message' do + let(:message) { '' } + + it 'requires a message' do + expect(dossier.reload).not_to be_pending_resolution + expect(dossier.commentaires.count).to eq(0) + expect(response.body).to include('Vous devez préciser') + end + end + + context 'dossier already having pending corrections' do + before do + create(:dossier_resolution, dossier:) + end + + it 'does not create an new pending resolution' do + expect { subject }.not_to change { DossierResolution.count } + end + + it 'shows a flash alert' do + subject + + expect(response.body).to include('') + end + end + end + + context 'dossier en_construction' do + it 'can create a pending resolution' do + subject + expect(dossier.reload).to be_pending_resolution + expect(dossier.commentaires.last).to be_flagged_pending_corrections + end + end + + context 'dossier is termine' do + let(:dossier) { create(:dossier, :accepte, :with_individual, procedure: procedure) } + + it 'does not create a pending resolution' do + expect { subject }.not_to change { DossierResolution.count } + expect(response.body).to include('Impossible') + end + end + end + describe '#messagerie' do before { expect(controller.current_instructeur).to receive(:mark_tab_as_seen).with(dossier, :messagerie) } subject { get :messagerie, params: { procedure_id: procedure.id, dossier_id: dossier.id } } diff --git a/spec/models/concern/dossier_resolvable_concern_spec.rb b/spec/models/concern/dossier_resolvable_concern_spec.rb index 4127f6c1d..d02b0b539 100644 --- a/spec/models/concern/dossier_resolvable_concern_spec.rb +++ b/spec/models/concern/dossier_resolvable_concern_spec.rb @@ -25,4 +25,56 @@ describe DossierResolvableConcern do it { expect(dossier.pending_resolution?).to be_falsey } end end + + describe '#flag_as_pending_correction!' do + let(:dossier) { create(:dossier, :en_construction) } + let(:instructeur) { create(:instructeur) } + let(:commentaire) { create(:commentaire, dossier:, instructeur:) } + + context 'when dossier is en_construction' do + it 'creates a resolution' do + expect { dossier.flag_as_pending_correction!(commentaire) }.to change { dossier.resolutions.pending.count }.by(1) + end + + it 'does not change dossier state' do + expect { dossier.flag_as_pending_correction!(commentaire) }.not_to change { dossier.state } + end + end + + context 'when dossier is not en_instruction' do + let(:dossier) { create(:dossier, :en_instruction) } + + it 'creates a resolution' do + expect { dossier.flag_as_pending_correction!(commentaire) }.to change { dossier.resolutions.pending.count }.by(1) + end + + it 'repasse dossier en_construction' do + expect { dossier.flag_as_pending_correction!(commentaire) }.to change { dossier.state }.to('en_construction') + end + end + + context 'when dossier has already a pending resolution' do + before { create(:dossier_resolution, dossier:) } + + it 'does not create a resolution' do + expect { dossier.flag_as_pending_correction!(commentaire) }.not_to change { dossier.resolutions.pending.count } + end + end + + context 'when dossier has already a resolved resolution' do + before { create(:dossier_resolution, :resolved, dossier:) } + + it 'creates a resolution' do + expect { dossier.flag_as_pending_correction!(commentaire) }.to change { dossier.resolutions.pending.count }.by(1) + end + end + + context 'when dossier is not en_construction and may not be repassed en_construction' do + let(:dossier) { create(:dossier, :accepte) } + + it 'does not create a resolution' do + expect { dossier.flag_as_pending_correction!(commentaire) }.not_to change { dossier.resolutions.pending.count } + end + end + end end diff --git a/spec/views/instructeur/dossiers/_instruction_button.html.haml_spec.rb b/spec/views/instructeur/dossiers/_instruction_button.html.haml_spec.rb index be44ec1ac..a1cf9193e 100644 --- a/spec/views/instructeur/dossiers/_instruction_button.html.haml_spec.rb +++ b/spec/views/instructeur/dossiers/_instruction_button.html.haml_spec.rb @@ -29,15 +29,25 @@ describe 'instructeurs/dossiers/instruction_button', type: :view do end end + context 'en_construction' do + let(:dossier) { create(:dossier, :en_construction) } + + it 'renders a dropdown' do + expect(rendered).to have_dropdown_title('Demander une modification') + expect(rendered).to have_dropdown_items(count: 2) # form is already expanded so we have 2 visible items + end + end + context 'en_instruction' do let(:dossier) { create(:dossier, :en_instruction) } it 'renders a dropdown' do expect(rendered).to have_dropdown_title('Instruire le dossier') - expect(rendered).to have_dropdown_items(count: 3) + expect(rendered).to have_dropdown_items(count: 4) expect(rendered).to have_dropdown_item('Accepter') expect(rendered).to have_dropdown_item('Classer sans suite') expect(rendered).to have_dropdown_item('Refuser') + expect(rendered).to have_dropdown_item('Demander une modification') end end end diff --git a/spec/views/instructeur/dossiers/show.html.haml_spec.rb b/spec/views/instructeur/dossiers/show.html.haml_spec.rb index 945eb8f87..34a3a44f0 100644 --- a/spec/views/instructeur/dossiers/show.html.haml_spec.rb +++ b/spec/views/instructeur/dossiers/show.html.haml_spec.rb @@ -61,7 +61,8 @@ describe 'instructeurs/dossiers/show', type: :view do within("form[action=\"#{follow_instructeur_dossier_path(dossier.procedure, dossier)}\"]") do expect(subject).to have_button('Suivre le dossier') end - expect(subject).to have_selector('.header-actions ul:first-child .fr-btn', count: 2) + expect(subject).to have_button('Demander une modification') + expect(subject).to have_selector('.header-actions ul:first-child > li.instruction-button', count: 1) end end