diff --git a/app/components/task_list_items/assessment/meeting_component.rb b/app/components/task_list_items/assessment/meeting_component.rb new file mode 100644 index 0000000000..0644a9b3f9 --- /dev/null +++ b/app/components/task_list_items/assessment/meeting_component.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module TaskListItems + module Assessment + class MeetingComponent < TaskListItems::BaseComponent + def initialize(planning_application:) + @planning_application = planning_application + end + + private + + attr_reader :planning_application + + delegate :meeting, to: :planning_application + + def link_text + "Meeting" + end + + def link_path + if @planning_application.meetings.any? + planning_application_assessment_meetings_path(@planning_application) + else + new_planning_application_assessment_meeting_path(@planning_application) + end + end + + def status_tag_component + StatusTags::BaseComponent.new( + status: @planning_application.meetings.any? ? @planning_application.meetings.last.status : "not_started" + ) + end + end + end +end diff --git a/app/controllers/planning_applications/assessment/meetings_controller.rb b/app/controllers/planning_applications/assessment/meetings_controller.rb new file mode 100644 index 0000000000..0004d8be57 --- /dev/null +++ b/app/controllers/planning_applications/assessment/meetings_controller.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module PlanningApplications + module Assessment + class MeetingsController < AuthenticationController + before_action :set_planning_application + before_action :build_meeting, only: %i[new create index] + + def index + @meetings = @planning_application.meetings.by_occurred_at_desc.includes(:created_by) + respond_to do |format| + format.html + end + end + + def show + respond_to do |format| + format.html + end + end + + def new + respond_to do |format| + format.html + end + end + + def create + respond_to do |format| + if @meeting.update(meeting_params) + format.html do + redirect_to planning_application_assessment_tasks_path(@planning_application), notice: t(".success") + end + else + format.html { render :new } + end + end + end + + private + + def meeting_params + params.require(:meeting) + .permit(:occurred_at, :comment) + .merge(created_by: current_user, status: "complete") + end + + def build_meeting + @meeting = @planning_application.meetings.new + end + end + end +end diff --git a/app/models/meeting.rb b/app/models/meeting.rb new file mode 100644 index 0000000000..f89ad67d6c --- /dev/null +++ b/app/models/meeting.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class Meeting < ApplicationRecord + include DateValidateable + + belongs_to :created_by, class_name: "User" + + belongs_to :planning_application + + validates :status, presence: true + + validates :occurred_at, + presence: true, + date: { + on_or_before: :current + } + + enum :status, %i[ + not_started + complete + ].index_with(&:to_s) + + scope :by_occurred_at_desc, -> { order(occurred_at: :desc) } +end diff --git a/app/models/planning_application.rb b/app/models/planning_application.rb index a9bb0d2667..ca131f43f5 100644 --- a/app/models/planning_application.rb +++ b/app/models/planning_application.rb @@ -52,6 +52,7 @@ class WithdrawOrCancelError < RuntimeError; end has_many :planning_application_constraints_queries has_many :constraints, through: :planning_application_constraints, source: :constraint has_many :site_histories + has_many :meetings, -> { by_occurred_at_desc } has_many :site_notices has_many :site_visits, -> { by_created_at_desc } has_many :policy_classes, -> { order(:section) } diff --git a/app/views/planning_applications/assessment/meetings/_form.html.erb b/app/views/planning_applications/assessment/meetings/_form.html.erb new file mode 100644 index 0000000000..ce428842ce --- /dev/null +++ b/app/views/planning_applications/assessment/meetings/_form.html.erb @@ -0,0 +1,18 @@ +<%= form_with( + model: @meeting, + class: "govuk-!-margin-top-5", + url: planning_application_assessment_meetings_path(@planning_application, @meeting), + method: :post + ) do |form| %> + + <%= form.govuk_date_field(:occurred_at, rows: 6, legend: {text: "Meeting date"}) %> + + <%= form.govuk_text_area(:comment, rows: 6, label: {text: "Add notes (optional)"}) %> + +
+ <%= form.submit "Save and mark as complete", class: "govuk-button govuk-button--primary" %> + + <%= back_link %> +
+ +<% end %> diff --git a/app/views/planning_applications/assessment/meetings/_overview.html.erb b/app/views/planning_applications/assessment/meetings/_overview.html.erb new file mode 100644 index 0000000000..d5e8653480 --- /dev/null +++ b/app/views/planning_applications/assessment/meetings/_overview.html.erb @@ -0,0 +1,13 @@ + + + + <%= meeting.occurred_at&.to_date&.to_fs %> + + + <%= meeting.created_by.name %> + + + <%= meeting.comment %> + + + diff --git a/app/views/planning_applications/assessment/meetings/index.html.erb b/app/views/planning_applications/assessment/meetings/index.html.erb new file mode 100644 index 0000000000..28d96902cc --- /dev/null +++ b/app/views/planning_applications/assessment/meetings/index.html.erb @@ -0,0 +1,41 @@ +<% content_for :page_title do %> + Meetings - <%= t("page_title") %> +<% end %> + +<%= render( + partial: "shared/proposal_header", + locals: {heading: "View meetings"} + ) %> + +
+ <% if @meetings.any? %> + + + + + + + + + + <% @meetings.each do |meeting| %> + <% if meeting.persisted? %> + <%= render "overview", meeting: meeting %> + <% end %> + <% end %> +
Meeting history
Meeting dateCase officerNotes (optional)
+ <% end %> + +
+ + + Add a new meeting + + + <%= render "form" %> +
+ +
+ <%= back_link %> +
+
diff --git a/app/views/planning_applications/assessment/meetings/new.html.erb b/app/views/planning_applications/assessment/meetings/new.html.erb new file mode 100644 index 0000000000..77d1f460f9 --- /dev/null +++ b/app/views/planning_applications/assessment/meetings/new.html.erb @@ -0,0 +1,23 @@ +<% content_for :page_title do %> + Meeting - <%= t("page_title") %> +<% end %> + +<%= render( + partial: "shared/assessment_task_breadcrumbs", + locals: {planning_application: @planning_application} + ) %> +<% content_for :title, "Meeting" %> + +<%= render( + partial: "shared/proposal_header", + locals: {heading: "Add a meeting"} + ) %> +
+ + + Warning + This is NOT public. + +
+ +<%= render "form" %> diff --git a/app/views/planning_applications/assessment/tasks/_additional_services.html.erb b/app/views/planning_applications/assessment/tasks/_additional_services.html.erb index 2841e16b29..3018e732cb 100644 --- a/app/views/planning_applications/assessment/tasks/_additional_services.html.erb +++ b/app/views/planning_applications/assessment/tasks/_additional_services.html.erb @@ -8,5 +8,13 @@ ) ) %> <% end %> + + <% if @planning_application.additional_services.find_by(name: "meeting") %> + <%= render( + TaskListItems::Assessment::MeetingComponent.new( + planning_application: @planning_application + ) + ) %> + <% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 967abb3e73..436f686df4 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -304,6 +304,13 @@ en: invalid: Enter a valid url for the legislation title: blank: Enter a title for the legislation + meeting: + attributes: + occurred_at: + date_blank: Provide the date when the meeting took place + date_invalid: The date the meeting took place must be a valid date + date_not_on_or_after: The date the meeting took place must be on or after the consultation start date + date_not_on_or_before: The date the meeting took place must be on or before today new_policy_class: attributes: name: @@ -1292,6 +1299,9 @@ en: success: Informative was successfully saved update: success: Informatives were successfully saved + meetings: + create: + success: Meeting record was successfully added. ownership_certificates: update: success: Ownership certificate was checked @@ -2244,6 +2254,8 @@ en: email_consultees: Send emails to consultees fee_component: check_fee: Check fee + meeting_component: + meeting: Meeting neighbour_responses_component: neighbour_responses: View neighbour responses other_change_request_component: diff --git a/config/routes.rb b/config/routes.rb index af78197691..189ad39cad 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -156,6 +156,8 @@ resources :site_visits, except: %i[destroy] + resources :meetings, except: %i[edit update destroy] + resources :heads_of_terms, only: %i[index new] do get :edit, on: :collection get :edit diff --git a/db/migrate/20250128164930_create_meetings.rb b/db/migrate/20250128164930_create_meetings.rb new file mode 100644 index 0000000000..bfdb587741 --- /dev/null +++ b/db/migrate/20250128164930_create_meetings.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateMeetings < ActiveRecord::Migration[7.2] + def change + create_table :meetings do |t| + t.references :created_by, null: false, foreign_key: {to_table: :users}, type: :bigint + t.references :planning_application, foreign_key: true + t.string :status, default: "not_started", null: false + t.text :comment + t.datetime :occurred_at, null: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 0028c93b92..68bdc3419f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -586,6 +586,18 @@ t.index ["local_policy_id"], name: "ix_local_policy_areas_on_local_policy_id" end + create_table "meetings", force: :cascade do |t| + t.bigint "created_by_id", null: false + t.bigint "planning_application_id" + t.string "status", default: "not_started", null: false + t.text "comment" + t.datetime "occurred_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_by_id"], name: "ix_meetings_on_created_by_id" + t.index ["planning_application_id"], name: "ix_meetings_on_planning_application_id" + end + create_table "neighbour_letter_batches", force: :cascade do |t| t.bigint "consultation_id" t.string "text" @@ -1146,6 +1158,8 @@ add_foreign_key "local_authority_policy_references", "local_authorities" add_foreign_key "local_policies", "planning_applications" add_foreign_key "local_policy_areas", "local_policies" + add_foreign_key "meetings", "planning_applications" + add_foreign_key "meetings", "users", column: "created_by_id" add_foreign_key "neighbour_letter_batches", "consultations" add_foreign_key "neighbour_letters", "neighbour_letter_batches", column: "batch_id" add_foreign_key "neighbour_letters", "neighbours" diff --git a/spec/factories/additional_services.rb b/spec/factories/additional_services.rb new file mode 100644 index 0000000000..ced7438ab5 --- /dev/null +++ b/spec/factories/additional_services.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :additional_service do + planning_application + + trait :with_meeting do + name { "meeting" } + end + end +end diff --git a/spec/factories/meeting.rb b/spec/factories/meeting.rb new file mode 100644 index 0000000000..b6e10a0fcf --- /dev/null +++ b/spec/factories/meeting.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :meeting do + association :created_by, factory: :user + planning_application + + comment { "A comment about the meeting" } + occurred_at { 1.day.ago } + end +end diff --git a/spec/models/meeting_spec.rb b/spec/models/meeting_spec.rb new file mode 100644 index 0000000000..fbb6ba2db4 --- /dev/null +++ b/spec/models/meeting_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Meeting do + describe "validations" do + subject(:meeting) { described_class.new } + + describe "#planning_application" do + it "validates presence" do + expect { meeting.valid? }.to change { meeting.errors[:planning_application] }.to ["must exist"] + end + end + + describe "#created_by" do + it "validates presence" do + expect { meeting.valid? }.to change { meeting.errors[:created_by] }.to ["must exist"] + end + end + + describe "#occurred_at" do + let!(:planning_application) { create(:planning_application, :pre_application) } + + it "validates presence" do + meeting = described_class.build + + expect { meeting.save }.to change { meeting.errors[:occurred_at] }.to ["Provide the date when the meeting took place"] + end + end + + describe "scopes" do + describe ".by_occurred_at_desc" do + let!(:default_local_authority) { create(:local_authority, :default) } + let!(:planning_application) { create(:planning_application, :pre_application, local_authority: default_local_authority) } + let!(:meetings1) { create(:meeting, occurred_at: 1.day.ago, planning_application: planning_application) } + let!(:meetings2) { create(:meeting, occurred_at: Time.zone.now, planning_application: planning_application) } + let!(:meetings3) { create(:meeting, occurred_at: 2.days.ago, planning_application: planning_application) } + + it "returns meetings sorted by occurred at desc (i.e. most recent first)" do + expect(described_class.by_occurred_at_desc).to eq([meetings2, meetings1, meetings3]) + end + end + end + end +end diff --git a/spec/system/planning_applications/assessing/meeting_spec.rb b/spec/system/planning_applications/assessing/meeting_spec.rb new file mode 100644 index 0000000000..e6858a52b5 --- /dev/null +++ b/spec/system/planning_applications/assessing/meeting_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Meeting" do + let!(:local_authority) { create(:local_authority, :default) } + let!(:assessor) { create(:user, :assessor, local_authority:) } + let!(:application_type) { create(:application_type, :pre_application) } + + let!(:planning_application) do + create(:planning_application, :from_planx_prior_approval, + application_type:, local_authority:) + end + + before do + travel_to("2024-12-24") + sign_in(assessor) + visit "/planning_applications/#{planning_application.reference}" + end + + context "when a meeting is not required" do + it "does not show the meeting item in the tasklist" do + click_link "Check and assess" + expect(page).not_to have_css("#meeting") + end + end + + context "when a meeting is required" do + let!(:additional_service) { create(:additional_service, :with_meeting, planning_application: planning_application) } + + it "shows the meeting item in the tasklist" do + click_link "Check and assess" + within("#additional-services-tasks") do + expect(page).to have_css("#meeting") + end + end + + it "I can add a new meeting record" do + click_link "Check and assess" + click_link "Meeting" + expect(page).to have_selector("h1", text: "Add a meeting") + + fill_in "Day", with: "12" + fill_in "Month", with: "12" + fill_in "Year", with: "2024" + fill_in "Add notes (optional)", with: "Met with applicant" + click_button "Save and mark as complete" + + expect(page).to have_content("Meeting record was successfully added.") + + within("#meeting") do + expect(page).to have_content("Completed") + end + + expect(page).to have_link( + "Meeting", + href: "/planning_applications/#{planning_application.reference}/assessment/meetings" + ) + + click_link "Meeting" + + within(".govuk-table") do + expect(page).to have_selector("caption", text: "Meeting history") + expect(page).to have_content("Met with applicant") + expect(page).to have_content("12 December 2024") + expect(page).to have_content(assessor.name) + end + end + + context "when there are validation errors" do + before do + click_link "Check and assess" + click_link "Meeting" + end + + it "there is a validation error when no date is entered" do + click_button "Save and mark as complete" + + within("#meeting-occurred-at-error") do + expect(page).to have_content("Provide the date when the meeting took place") + end + end + + it "I can't add a meeting after the current date" do + fill_in "Day", with: "1" + fill_in "Month", with: "1" + fill_in "Year", with: "2026" + click_button "Save and mark as complete" + + expect(page).to have_content "The date the meeting took place must be on or before today" + end + + it "I can't add an incomplete meeting date" do + fill_in "Day", with: "1" + fill_in "Month", with: "1" + click_button "Save and mark as complete" + + expect(page).to have_content "The date the meeting took place must be a valid date" + end + end + + context "when a meeting record exists" do + let!(:meeting) { create(:meeting, planning_application: planning_application) } + + before do + click_link "Check and assess" + click_link "Meeting" + end + + it "I can see an existing meeting record" do + within(".govuk-table") do + expect(page).to have_content(meeting.comment) + expect(page).to have_content(meeting.occurred_at&.to_date&.to_fs) + expect(page).to have_content(meeting.created_by.name) + end + end + end + end +end