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.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? %>
+
+ Meeting history
+
+
+
+
+
+
+
+ <% @meetings.each do |meeting| %>
+ <% if meeting.persisted? %>
+ <%= render "overview", meeting: meeting %>
+ <% end %>
+ <% end %>
+
+ <% 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