diff --git a/app/controllers/admin/appointments_controller.rb b/app/controllers/admin/appointments_controller.rb index d531e5c80..a5aa79291 100644 --- a/app/controllers/admin/appointments_controller.rb +++ b/app/controllers/admin/appointments_controller.rb @@ -10,6 +10,8 @@ def index .chronologically .includes(:member, loans: {item: :borrow_policy}, holds: {item: :borrow_policy}) + @unfinished_appointments_count = Appointment.unfinished.count + @pending_appointments = [] @pulled_appointments = [] @completed_appointments = [] diff --git a/app/controllers/admin/reports/unfinished_appointments_controller.rb b/app/controllers/admin/reports/unfinished_appointments_controller.rb new file mode 100644 index 000000000..983071b43 --- /dev/null +++ b/app/controllers/admin/reports/unfinished_appointments_controller.rb @@ -0,0 +1,22 @@ +module Admin + module Reports + class UnfinishedAppointmentsController < BaseController + def index + @appointments = Appointment.unfinished + .chronologically + .includes(:member, holds: :item) + end + + # Resolve a row straight from the worklist: mark the appointment complete + # so it drops off the list. Reversible from the appointment page if needed. + def complete + appointment = Appointment.find(params[:id]) + appointment.update!(completed_at: Time.current, staff_updating: true) + + redirect_to admin_reports_unfinished_appointments_path, + status: :see_other, + flash: {success: "Appointment marked complete."} + end + end + end +end diff --git a/app/models/appointment.rb b/app/models/appointment.rb index cbb83fc01..78e3ca165 100644 --- a/app/models/appointment.rb +++ b/app/models/appointment.rb @@ -23,6 +23,31 @@ class Appointment < ApplicationRecord scope :chronologically, -> { order("starts_at ASC") } scope :simultaneous, ->(appointment) { where(starts_at: appointment.starts_at, ends_at: appointment.ends_at).where.not(id: appointment.id) } scope :not_pulled, -> { where(pulled_at: nil) } + scope :pulled, -> { where.not(pulled_at: nil) } + scope :not_completed, -> { where(completed_at: nil) } + + # How far back the "unfinished appointments" report looks. Anything older is + # almost always a stale record whose item has long since circulated to other + # borrowers (an expired hold is a fine terminal state on its own), so we don't + # nag staff about it. + UNFINISHED_WINDOW = 30.days + + # Appointments from the last UNFINISHED_WINDOW that were pulled, never marked + # complete, and still have at least one held item that was neither checked out + # nor explicitly ended (cancelled / retired). These are where an item came off + # the shelf for a member and nobody closed the loop (#2182). What's unresolved + # is the appointment itself, which is why we key on completion rather than on + # the hold's own clock. + scope :unfinished, ->(now = Time.current) { + pulled + .not_completed + .where(starts_at: (now - UNFINISHED_WINDOW).beginning_of_day...now.beginning_of_day) + .where(id: AppointmentHold.where(hold: Hold.where(loan_id: nil, ended_at: nil)).select(:appointment_id)) + } + + def self.unfinished_window_days + (UNFINISHED_WINDOW / 1.day).to_i + end attr_accessor :member_updating, :staff_updating @@ -44,6 +69,11 @@ def completed? completed_at.present? end + # Held items on this appointment that never became a loan. + def holds_not_checked_out + holds.select { |hold| hold.loan_id.nil? } + end + def dropoff_only? holds.empty? && !loans.empty? end diff --git a/app/views/admin/appointments/_unfinished_banner.html.erb b/app/views/admin/appointments/_unfinished_banner.html.erb new file mode 100644 index 000000000..ae7167554 --- /dev/null +++ b/app/views/admin/appointments/_unfinished_banner.html.erb @@ -0,0 +1,9 @@ +<% if @unfinished_appointments_count.to_i > 0 %> +
+ <%= link_to "Review", admin_reports_unfinished_appointments_path, class: "btn btn-sm float-right" %> + <%= pluralize(@unfinished_appointments_count, "appointment") %> from the last + <%= pluralize(Appointment.unfinished_window_days, "day") %> + <%= (@unfinished_appointments_count == 1) ? "was" : "were" %> pulled but never completed, + with items that were never checked out. +
+<% end %> diff --git a/app/views/admin/appointments/index.html.erb b/app/views/admin/appointments/index.html.erb index 8615a8e99..cb8cee7f4 100644 --- a/app/views/admin/appointments/index.html.erb +++ b/app/views/admin/appointments/index.html.erb @@ -2,6 +2,8 @@ <%= index_header "Scheduled Appointments" %> <% end %> +<%= render "unfinished_banner" %> +