Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/controllers/events/reconcile_affiliations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ def index
track_view("events.reconcile_affiliations", { event_id: @event.id })

reconcile = AffiliationServices::ReconcileEvent.new(@event)
@person_groups = reconcile.actionable_person_groups
@cancelled_groups, @person_groups = reconcile.actionable_person_groups
.partition { |group| group[:registration]&.status == "cancelled" }
@skipped_sections = reconcile.skipped_reason_sections
@has_rows = reconcile.any_rows?
# Restore the admin's per-row radio choices when they come back from confirm.
Expand Down
18 changes: 16 additions & 2 deletions app/services/affiliation_services/reconcile_event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,17 @@ module AffiliationServices
# the timestamp live here. Job affiliations are never touched.
class ReconcileEvent
Row = Struct.new(:person, :registration, :organization, :affiliation, :action, :reason, :key,
:minted_here, keyword_init: true) do
:minted_here, :jobs, keyword_init: true) do
def actionable?
action != :noop
end

# Whether removing or moving this facilitator row could leave the person
# unlinked from the org, so the page notes their remaining job affiliations.
def shows_jobs?
%i[ delete deactivate ].include?(action) ||
EventRegistration::INACTIVE_STATUSES.include?(registration&.status)
end
end

def initialize(event)
Expand Down Expand Up @@ -89,7 +96,7 @@ def all_rows
def row_for(person, registration, organization, decision)
Row.new(person:, registration:, organization:, affiliation: decision.affiliation,
action: decision.action, reason: decision.reason, minted_here: decision.minted_here,
key: row_key(person, organization, decision))
key: row_key(person, organization, decision), jobs: jobs_by_person_org.fetch([ person.id, organization.id ], []))
end

# Stable per-row identity for the outcome map: the affiliation itself when there
Expand Down Expand Up @@ -123,6 +130,13 @@ def other_facilitators(person)
.to_a
end

def jobs_by_person_org
@jobs_by_person_org ||= Affiliation.active_or_pending
.where(person_id: registrations_by_person.keys.compact.map(&:id))
.reject(&:facilitator?)
.group_by { |affiliation| [ affiliation.person_id, affiliation.organization_id ] }
end

def linked_org_ids(person)
linked_organizations(registrations_by_person[person]).map(&:id)
end
Expand Down
25 changes: 21 additions & 4 deletions app/services/affiliation_services/reconcile_person.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def actionable?
# ended without a dedicated column (ADR-0003 D6b).
COMMENT_TOPIC = "Reconciliation".freeze
NOT_ATTENDED = "Didn't attend β€” no affiliation created".freeze
CANCELLED = "Cancelled β€” no affiliation created".freeze
# `registered` after the event is a gap in the record, not an outcome:
# #attendance_recorded? deliberately excludes it. Acting on it would be acting
# on missing data, so the row is surfaced for an admin to resolve instead.
Expand Down Expand Up @@ -204,6 +205,10 @@ def transferred_out?
registration_here&.transferred_out?
end

def cancelled_here?
registration_here&.status == "cancelled"
end

# A transfer isn't a failure β€” they're training somewhere else. The affiliation
# follows them: it re-dates to the destination event and re-points at that
# registration, rather than being ended or deleted (ADR-0003 D6d).
Expand Down Expand Up @@ -251,11 +256,11 @@ def needs_affiliation?

def classify(affiliation)
if completed_training?
return Decision.new(affiliation:, action: :noop, reason: ACTIVE_ATTENDED) if affiliation.active?
return Decision.new(affiliation:, action: :noop, reason: ACTIVE_ATTENDED) if open?(affiliation)
return Decision.new(affiliation:, action: :reactivate) if ended_for_this_training?(affiliation)

Decision.new(affiliation:, action: :noop, reason: LAPSED)
elsif !affiliation.active?
elsif !open?(affiliation)
reason = ended_by_reconciliation?(affiliation) ? ALREADY_DEACTIVATED : ALREADY_ENDED
Decision.new(affiliation:, action: :noop, reason:)
elsif attendance_unrecorded?
Expand All @@ -271,6 +276,12 @@ def classify(affiliation)
end
end

# A row minted off an upcoming training starts on its date, so it reads as
# upcoming until then β€” still open, not ended.
def open?(affiliation)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: Also changes registered-before-the-event rows: they were labeled "FA end date is in the past" (upcoming β‰  active) and now show as "Training hasn't ended yet". That mislabel is what the reconcile screenshot showed.

affiliation.active? || affiliation.upcoming?
end

# Only the row this training minted, and only when the person didn't turn up at
# all. A partial attendance is kept as an auditable record that they were here β€”
# it confers no facilitation either way (ADR-0001 D8a), but the row and its
Expand All @@ -284,12 +295,18 @@ def partially_attended?
end

# Waits for the governing training to end, so a pre-event run never deactivates.
# A hand-entered row has no source training, so it waits on this event.
# A hand-entered row has no source training, so it waits on this event. A
# cancellation settles this event's outcome early, so its rows are ready at once.
def deactivation_ready?(affiliation)
affiliation.event_registration_id ? affiliation.event_registration&.event&.ended? : @event.ended?
if affiliation.event_registration_id && !minted_here?(affiliation)
return affiliation.event_registration&.event&.ended?
end

@event.ended? || cancelled_here?
end

def creation_decision
return Decision.new(action: :noop, reason: CANCELLED) if cancelled_here? && !completed_training?
return Decision.new(action: :create) if !@event.ended? || completed_training?

Decision.new(action: :noop, reason: NOT_ATTENDED)
Expand Down
4 changes: 4 additions & 0 deletions app/views/events/reconcile_affiliations/_job_note.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<% labels = jobs.map { |job| "#{job.title.presence || "Untitled"} Β· #{job.decorate.period_label}" } %>
<p class="text-xs text-gray-500">
<%= labels.any? ? "Job affiliation: #{labels.to_sentence}" : "No job affiliation" %>
</p>
69 changes: 69 additions & 0 deletions app/views/events/reconcile_affiliations/_person_group.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<% color_class = {
red: "has-[:checked]:bg-red-50 has-[:checked]:text-red-700 has-[:checked]:border-red-300",
green: "has-[:checked]:bg-green-50 has-[:checked]:text-green-700 has-[:checked]:border-green-300",
blue: "has-[:checked]:bg-blue-50 has-[:checked]:text-blue-700 has-[:checked]:border-blue-300",
gray: "has-[:checked]:bg-gray-100 has-[:checked]:text-gray-800 has-[:checked]:border-gray-400"
} %>
<% fa_hint = "facilitator affiliation (FA)" %>
<% outcome_options = {
create: [ [ "create", "Create new FA", :blue, fa_hint ], [ "keep", "Skip", :gray ] ],
deactivate: [ [ "keep", "Keep active", :green ], [ "delete", "Delete affiliation", :red ], [ "deactivate", "Deactivate affiliation", :red ] ],
delete: [ [ "delete", "Delete", :red ], [ "keep", "Keep", :gray ] ],
reactivate: [ [ "reactivate", "Reactivate", :green ], [ "create", "Create new FA", :blue, fa_hint ], [ "keep", "Leave inactive", :gray ] ],
retarget: [ [ "retarget", "Move to the new event", :blue ], [ "keep", "Leave as is", :gray ] ]
} %>
<% button_base = "group relative inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-500 shadow-sm cursor-pointer hover:bg-gray-50" %>

<section class="rounded-lg border border-gray-200 bg-white p-4">
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
<div class="flex items-center gap-2 min-w-0">
<%= link_to group[:person].decorate.full_name_with_display_name, edit_event_registration_path(group[:registration]), target: "_blank", rel: "noopener", class: "font-semibold text-gray-900 hover:underline hover:text-blue-700 shrink-0" %>
<% others = group[:other_facilitators] %>
<% if others.any? %>
<% note = "Also a facilitator at #{others.map { |a| a.organization.name }.to_sentence}." %>
<% anchor = others.one? ? dom_id(others.first) : "affiliations" %>
<%= link_to note, edit_person_path(group[:person], anchor: anchor), target: "_blank", rel: "noopener",
title: note, class: "text-xs text-gray-500 truncate hover:underline hover:text-blue-700 min-w-0" %>
<% end %>
</div>
<%= render "event_registrations/attendance_status_badge", registration: group[:registration], return_to: "reconcile_affiliations" %>
</div>

<% if group[:registration]&.status == "incomplete_attendance" && ticked.add?(group[:registration].id) %>
<%= render "attendance_days", registration: group[:registration], event: @event %>
<% end %>

<div class="divide-y divide-gray-100 border-t border-gray-100">
<% group[:rows].each do |row| %>
<%# A row this training created has nothing to preserve, so Delete leads and
Deactivate is shown but not selectable β€” the tooltip says why. %>
<% default_choice = row.action == :deactivate && row.minted_here ? "delete" : row.action.to_s %>
<% chosen = pre_outcome&.dig(row.key) || default_choice %>
<div class="flex flex-wrap items-center gap-3 py-2">
<div class="flex-1 min-w-0 text-sm">
<% if row.affiliation %>
<%= link_to edit_person_path(row.person, anchor: dom_id(row.affiliation)), target: "_blank", rel: "noopener", title: "Edit affiliation", class: "hover:underline" do %>
<span class="text-gray-800 hover:text-blue-700"><%= row.organization.name %></span>
<span class="text-gray-400">Β· <%= row.affiliation.decorate.period_label %></span>
<% end %>
<% else %>
<span class="text-gray-800"><%= row.organization.name %></span>
<% end %>
<% if row.shows_jobs? %>
<%= render "job_note", jobs: row.jobs %>
<% end %>
</div>
<div class="flex shrink-0 items-center gap-2">
<% outcome_options[row.action].each do |value, label, color, hint| %>
<% unavailable = value == "deactivate" && row.minted_here %>
<label class="<%= button_base %> <%= unavailable ? "opacity-50 cursor-not-allowed" : color_class[color] %>" <%= tag.attributes(title: hint) %>>
<%= radio_button_tag "outcome[#{row.key}]", value, chosen == value, id: "outcome_#{row.key}_#{value}", class: "h-4 w-4 border-gray-300", form: "reconcile_form", disabled: unavailable %>
<%= label %>
<%= render "tooltip", kind: value.to_sym %>
</label>
<% end %>
</div>
</div>
<% end %>
</div>
</section>
Loading
Loading