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 %> +
+ Appointments from the last <%= pluralize(Appointment.unfinished_window_days, "day") %> + that were pulled but never marked complete, where at least one held item was + never checked out. Older appointments aren't shown here, since their items have + almost always circulated on to other borrowers by now. +
++ Use the appointment or member links to figure out what happened to an item, then + Mark complete to clear the row. (You can undo that from the + appointment page if you need to.) +
+ + <% if @appointments.empty? %> + <%= empty_state "No unfinished appointments. Nice and tidy." %> + <% else %> +| Appointment | +Member | +Items not checked out | ++ |
|---|---|---|---|
| + <%= link_to l(appointment.starts_at, format: :date), admin_appointment_path(appointment) %> + | ++ <%= link_to preferred_or_default_name(appointment.member), admin_member_path(appointment.member) %> + | +
+
|
+ + <%= button_to "Mark complete", complete_admin_reports_unfinished_appointment_path(appointment), + method: :post, class: "btn btn-sm", + data: {turbo_submits_with: "Marking complete..."} %> + | +