Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
63c35a9
Start: group FileMaker rolodex fields by meaning
maebeale Oct 13, 2026
fa5b06a
Group FileMaker rolodex fields so a record reads at a glance
maebeale Oct 13, 2026
266416b
List grouped rolodex fields on the features page
maebeale Oct 13, 2026
b4165d3
Keep the rolodex identity section to who someone is
maebeale Oct 13, 2026
3a5374e
Pair rolodex sections side by side
maebeale Oct 13, 2026
1f3003b
List every phone and address on a rolodex person's contact card
maebeale Oct 13, 2026
47505a5
Add a field and association reference to rolodex records
maebeale Oct 13, 2026
d4d8b85
Give sector and age group their own rolodex card
maebeale Oct 13, 2026
d0a98c3
Put email with identity and give phones and addresses their own card
maebeale Oct 13, 2026
fad6880
Keep best time to call with identity, matching the portal
maebeale Oct 13, 2026
5ec37d4
Hide blank rolodex fields from the cards, and show street numbers
maebeale Oct 13, 2026
581e589
Show each phone and address's FileMaker ID beside its type
maebeale Oct 13, 2026
98bbd73
Strike through archive IDs that point at no FileMaker record
maebeale Oct 13, 2026
84de1b1
Split rolodex cards into subcards and list dead phone/address IDs
maebeale Oct 13, 2026
d32f462
Give the rolodex mailing list flag its own subcard
maebeale Oct 13, 2026
9354cb9
Stack record keeping beside the organization card
maebeale Oct 13, 2026
c6bdf67
Link phone and address IDs, and show the mailing list flag on one row
maebeale Oct 13, 2026
0127b5f
Move rolodex classification into identity
maebeale Oct 13, 2026
1d9e029
Highlight related records the rolodex cards don't already show
maebeale Oct 13, 2026
ee3e34f
Lay related records out in columns and mark uncovered tables yellow
maebeale Oct 13, 2026
7552603
Cluster a rolodex person's related tables by kind
maebeale Oct 13, 2026
fc082f6
Mark related archive tables that have portal counterparts in orange
maebeale Oct 13, 2026
1aede40
Drop the yellow related-records highlight; orange marks portal counte…
maebeale Oct 13, 2026
ad5194a
Name a related table's portal counterpart when it's called something …
maebeale Oct 13, 2026
b670b49
Map a rolodex person's FileMaker funding to portal scholarships
maebeale Oct 13, 2026
4a81b7b
Label authored workshops without parentheses
maebeale Oct 13, 2026
50f4f1e
Group rolodex classification and status beside the affiliations
maebeale Oct 13, 2026
b2a8105
List a rolodex person's job and facilitator affiliations in their sub…
maebeale Oct 13, 2026
e713af4
Note the years FileMaker contacts cover in the field reference
maebeale Oct 13, 2026
9b8ab4b
Note the years FileMaker activities cover in the field reference
maebeale Oct 13, 2026
aa3a919
Map FileMaker notes to portal comments
maebeale Oct 13, 2026
2968bf9
Mark rolodex fields that import straight into a portal column
maebeale Oct 13, 2026
9650c71
Map rolodex entry and modification stamps to portal audit columns
maebeale Oct 13, 2026
7d091d8
Key the rolodex stamp fields that need a staff-to-user lookup
maebeale Oct 13, 2026
11cec68
Map rolodex emails and facilitator-since date to portal columns
maebeale Oct 13, 2026
346d779
Map FileMaker EMail to the person's main email
maebeale Oct 13, 2026
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
18 changes: 18 additions & 0 deletions app/decorators/fm_address_decorator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
class FmAddressDecorator < ApplicationDecorator
def kind
data["AddrsType"].presence || "Address"
end

def summary
state_and_zip = data.values_at("AddrsState", "AddrsPostalCode").compact_blank.join(" ")
[ street, *data.values_at("AddrsLine2", "AddrsCity"), state_and_zip, data["AddrsCountry"] ].compact_blank.join(", ")
end

def street
street_no = data["StreetNo"].to_s.strip
line1 = data["AddrsLine1"].to_s.strip
return line1 if street_no.empty? || line1.split.first == street_no

[ street_no, line1 ].compact_blank.join(" ")
end
end
10 changes: 10 additions & 0 deletions app/decorators/fm_people_to_organization_decorator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class FmPeopleToOrganizationDecorator < ApplicationDecorator
def kind
data["Title"].presence || "Affiliation"
end

def summary
org_id = data["OrgID"]
FmOrganization.find_by(fm_id: org_id)&.data&.dig("OrgName").presence || org_id
end
end
19 changes: 19 additions & 0 deletions app/decorators/fm_personnel_decorator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
class FmPersonnelDecorator < ApplicationDecorator
def kind
data["Role"].presence || "Personnel"
end

def summary
[ program, *data.values_at("Status", "AgreementStatus") ].compact_blank.join(" Β· ")
end

private

def program
project = FmProject.find_by(fm_id: data["ProjectID"])
return data["ProjectID"] unless project

organization = FmOrganization.find_by(fm_id: project.data["OrgID"])&.data&.dig("OrgName")
[ project.data["ProjectName"].presence || project.fm_id, organization ].compact_blank.join(" at ")
end
end
9 changes: 9 additions & 0 deletions app/decorators/fm_phone_number_decorator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class FmPhoneNumberDecorator < ApplicationDecorator
def kind
data["PhoneType"].presence || "Phone"
end

def summary
data.values_at("PhoneNumber", "Note").compact_blank.join(" Β· ")
end
end
10 changes: 10 additions & 0 deletions app/decorators/fm_rolodex_decorator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class FmRolodexDecorator < ApplicationDecorator
CONTACT_TAGS = {
FmPhoneNumber => { "Primary" => "PrimaryPhoneID" },
FmAddress => { "Primary" => "PrimaryAddrsID", "Worksite" => "WorksiteAddrsID" }
}.freeze

def contact_tags(record)
CONTACT_TAGS.fetch(record.class, {}).select { |_, key| data[key] == record.fm_id }.keys
end
end
22 changes: 21 additions & 1 deletion app/helpers/filemaker_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,27 @@ def filemaker_portal_link(record)
when Workshop then [ record.title, workshop_path(record) ]
end

link_to text, path, class: button_classes(:primary_outline, size: :sm)
link_to text, path, class: button_classes(:primary)
end

# An archive field's link to the record it names, struck through when the archive has no such record.
def fm_archive_reference_link(fm_id, table)
key = table.delete_prefix("fm_")
model = FmArchivesController::TABLES.dig(key, :model)
return tag.span(fm_id, class: "line-through opacity-60", title: "No FM record found") if model && !model.exists?(fm_id: fm_id)

link_to fm_id, fm_archive_path(fm_id, table: key), class: "text-primary hover:underline"
end

# The portal's name for a related table's counterpart, when the archive's label doesn't already lead with it.
def fm_portal_counterpart_name(record, table, label)
name = record.portal_counterpart(table)
name unless name.nil? || label.downcase.start_with?(name.downcase)
end

# Colors a related table orange when the portal keeps its own records of the same kind.
def fm_related_table_highlight(record, table)
"border-orange-200 bg-orange-100" if record.portal_counterpart(table)
end

# A code no archive table holds renders struck through, flagging a likely typo β€”
Expand Down
47 changes: 47 additions & 0 deletions app/models/concerns/fm_archive.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
module FmArchive
extend ActiveSupport::Concern

FIELD_GROUPS = {}.freeze
OTHER_FIELDS = "Other fields"
LISTED_FIELDS = [].freeze
TALL_GROUPS = [].freeze
PORTAL_COUNTERPARTS = {}.freeze
PORTAL_FIELDS = {}.freeze
USER_LOOKUP_FIELDS = [].freeze

included do
before_validation :set_defaults
end
Expand All @@ -13,6 +21,45 @@ def find_by_fm_id(id)
def find_by_data(column, value)
where("JSON_UNQUOTE(JSON_EXTRACT(data, CONCAT('$.', ?))) = ?", column.to_s, value)
end

# FIELD_GROUPS with each card's subcards lifted to the top level.
def flat_field_groups
self::FIELD_GROUPS.flat_map { |label, keys| keys.is_a?(Hash) ? keys.to_a : [ [ label, keys ] ] }.to_h
end
end

# The filled-in data fields as cards of subcards, { card => { subcard => fields } }, in FIELD_GROUPS order.
# A card listing its fields directly holds one unlabeled subcard; fields no group names trail under
# "Other fields". Ungrouped tables come back whole, as one unlabeled card.
def grouped_data
groups = self.class::FIELD_GROUPS
return { nil => { nil => data } } if groups.empty?

filled = data.compact_blank.except(*self.class::LISTED_FIELDS)
groups.merge(OTHER_FIELDS => ungrouped_keys).to_h do |label, keys|
subgroups = keys.is_a?(Hash) ? keys : { nil => keys }
[ label, subgroups.transform_values { |sub_keys| filled.slice(*sub_keys) }
.select { |sub_label, fields| fields.any? || records_listed_under(sub_label).any? } ]
end.compact_blank
end

def ungrouped_keys
data.keys - self.class.flat_field_groups.values.flatten
end

# A subcard of exactly one field, which reads as a single labeled row.
def single_field_group?(label)
self.class.flat_field_groups[label]&.one? && records_listed_under(label).none?
end

# What the portal calls its own records of the kind a related archive table holds.
def portal_counterpart(table)
self.class::PORTAL_COUNTERPARTS[table]
end

# Related records a subcard lists below its fields.
def records_listed_under(_label)
[]
end

private
Expand Down
134 changes: 121 additions & 13 deletions app/models/fm_rolodex.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,29 +101,137 @@ class FmRolodex < ApplicationRecord
"WorksiteAddrsID" => "fm_addresses"
}.freeze

PHONES = "Phones"
ADDRESSES = "Addresses"
JOB_AFFILIATION = "Job affiliation"
FACILITATOR_AFFILIATION = "Facilitator affiliation"
LISTED_FIELDS = %w[PrimaryPhoneID PrimaryAddrsID WorksiteAddrsID].freeze
TALL_GROUPS = [ "Organization & affiliations" ].freeze
PORTAL_FIELDS = {
"FirstName" => "people.first_name",
"LastName" => "people.last_name",
"Pronouns" => "people.pronouns",
"EmailPrimary" => "people.email",
"EMail" => "people.email",
"EmailType" => "people.email_type",
"BestTimeToCall" => "people.best_time_to_call",
"DateOfBirth" => "people.date_of_birth",
"Ethnicity" => "people.racial_ethnic_identity",
"Comments" => "people.notes",
"Title" => "affiliations.title",
"FacilitatorSinceMonth" => "people.member_since (with FacilitatorSinceYear)",
"FacilitatorSinceYear" => "people.member_since (with FacilitatorSinceMonth)",
"PrimaryServiceArea" => "the person's primary sector",
"OtherServiceArea" => "the person's additional sectors",
"PrimaryAgeGroup" => "the person's primary age group",
"DateEntered" => "people.created_at",
"DateTimeModified" => "people.updated_at",
"EnteredBy" => "people.created_by_id",
"ModifiedBy" => "people.updated_by_id"
}.freeze
USER_LOOKUP_FIELDS = %w[EnteredBy ModifiedBy].freeze

PORTAL_COUNTERPARTS = {
"fm_addresses" => "Addresses",
"fm_phone_numbers" => "Contact methods",
"fm_notes" => "Comments",
"fm_activities" => "Communications",
"fm_people_to_organizations" => "Affiliations",
"fm_personnels" => "Affiliations",
"fm_workshop_logs" => "Workshop logs",
"fm_workshops" => "Workshops",
"fm_participants" => "Event registrations",
"fm_payments" => "Payments",
"fm_fundings" => "Scholarships",
"fm_invoices" => "Invoices",
"fm_quotations" => "Quotes"
}.freeze

FIELD_GROUPS = {
"Identity" => %w[NamePrefix FirstName MiddleName LastName Suffix Pronouns Gender Gender2ndPerson isAgency__c
EMail EmailPrimary EmailType EmailNotes BestTimeToCall],
"Phones & addresses" => {
PHONES => %w[PrimaryPhoneID],
ADDRESSES => %w[PrimaryAddrsID WorksiteAddrsID],
"Mailing list" => %w[MailingList]
},
"Organization & affiliations" => {
"Classification & status" => %w[Classification Status],
"Organization" => %w[Organization OrgID WebSite Mission PrimaryContact PrimaryContactID],
JOB_AFFILIATION => %w[Title Department Dept2ndName],
FACILITATOR_AFFILIATION => %w[FacilitatorSinceMonth FacilitatorSinceYear FacilitatorStatusPending
HowTrained TrainingMotivation LifeExperiences Settings BBSLicense]
},
"Sector & age group" => %w[PrimaryServiceArea OtherServiceArea PrimaryAgeGroup],
"Record keeping" => %w[ID Delete DuplicateStatus EnteredBy DateEntered ModifiedBy DateTimeModified UserID Password],
"Notes" => %w[Comments NameNote Keywords Interest SourceOfName],
"Volunteering" => %w[VolEnrollDate DropDate AvailMon AvailTue AvailWed AvailThu AvailFri AvailSat],
"Giving" => %w[DonationCount DonationTotal Amount CkNo FunderPotential CallForArtRecord],
"Personal details" => %w[DateOfBirth BirthMonth BirthDay BirthYear BirthdayFlag Anniversary Children
Ethnicity Languages],
"Mailing" => %w[AddresseeGreetingStyles ListBy IncludeTitle FamilyCode Private]
}.freeze

HAS_MANY = {
"fm_addresses" => { via: "RolodexID", label: "Addresses" },
"fm_phone_numbers" => { via: "RolodexID", label: "Phone Numbers" },
"fm_payments" => { via: "RolodexID", label: "Payments" },
"fm_activities" => { via: "ID", label: "Activities" },
"fm_contacts" => { via: "ContacteeID", label: "Contacts", note: "Data from 2003–2018" },
"fm_notes" => { via: "RolodexID", label: "Notes" },
"fm_activities" => { via: "ID", label: "Activities", note: "Data from 2003–2025" },
"fm_form_letters" => { via: "SignatureID", label: "Form Letters (Signed)" },
"fm_people_to_organizations" => { via: "PersonID", label: "Org Affiliations" },
"fm_personnels" => { via: "PersonID", label: "Personnel" },
"fm_committee_members" => { via: "MemberID", label: "Committee Memberships" },
"fm_group_members" => { via: "ContactID", label: "Group Memberships" },
"fm_workshop_logs" => { via: "LeaderID", label: "Workshop Logs" },
"fm_workshops" => { via: "AuthorID", label: "Workshops Authored" },
"fm_participants" => { via: "ParticipantID", label: "Participations" },
"fm_volunteers" => { via: "VRecID", label: "Volunteer Records" },
"fm_payments" => { via: "RolodexID", label: "Payments" },
"fm_invoices" => { via: "RolodexID", label: "Invoices" },
"fm_fundings" => { via: "FunderID", label: "Funding" },
"fm_program_sponsorships" => { via: "FunderID", label: "Program Sponsorships" },
"fm_ancillary_data" => { via: "RolodexID", label: "Ancillary Data" },
"fm_art_registries" => { via: "ArtistID", label: "Art Registries (Artist)" },
"fm_committee_members" => { via: "MemberID", label: "Committee Memberships" },
"fm_contacts" => { via: "ContacteeID", label: "Contacts" },
"fm_solicitations" => { via: "DonorID", label: "Solicitations" },
"fm_donor_developers" => { via: "RolodexID", label: "Donor Developments" },
"fm_form_letters" => { via: "SignatureID", label: "Form Letters (Signed)" },
"fm_group_members" => { via: "ContactID", label: "Group Memberships" },
"fm_invoices" => { via: "RolodexID", label: "Invoices" },
"fm_people_to_organizations" => { via: "PersonID", label: "Org Affiliations" },
"fm_quotations" => { via: "RolodexID", label: "Quotations" },
"fm_solicitations" => { via: "DonorID", label: "Solicitations" },
"fm_volunteers" => { via: "VRecID", label: "Volunteer Records" },
"fm_workshops" => { via: "AuthorID", label: "Workshops (Authored)" }
"fm_art_registries" => { via: "ArtistID", label: "Art Registries (Artist)" },
"fm_ancillary_data" => { via: "RolodexID", label: "Ancillary Data" }
}.freeze

def phone_numbers
@phone_numbers ||= contact_records(FmPhoneNumber, primary_ids: data.values_at("PrimaryPhoneID"))
end

def addresses
@addresses ||= contact_records(FmAddress, primary_ids: data.values_at("PrimaryAddrsID", "WorksiteAddrsID"))
end

def job_affiliations
@job_affiliations ||= FmPeopleToOrganization.find_by_data("PersonID", fm_id).order(:fm_id).to_a
end

def facilitator_affiliations
@facilitator_affiliations ||= FmPersonnel.find_by_data("PersonID", fm_id).order(:fm_id).to_a
end

def records_listed_under(label)
case label
when PHONES then phone_numbers
when ADDRESSES then addresses
when JOB_AFFILIATION then job_affiliations
when FACILITATOR_AFFILIATION then facilitator_affiliations
else []
end
end

private

# Ones this record points at are listed first, even when their RolodexID names someone else; one the
# archive lacks stands in as an unsaved placeholder.
def contact_records(model, primary_ids:)
primary_ids = primary_ids.compact_blank.uniq
records = model.find_by_data("RolodexID", fm_id).or(model.where(fm_id: primary_ids)).order(:fm_id).to_a
records += (primary_ids - records.map(&:fm_id)).map { |id| model.new(fm_id: id, data: {}) }
records.sort_by { |record| primary_ids.index(record.fm_id) || primary_ids.size }
end
end
68 changes: 68 additions & 0 deletions app/views/fm_archives/_field_reference.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<% model = record.class %>
<section class="rounded-xl border border-blue-200 bg-blue-50 p-6">
<h2 class="mb-1 text-lg font-semibold">Available fields &amp; associations</h2>
<p class="mb-5 text-sm text-gray-600">Every field on a <%= model.name %> record. Grayed-out fields are blank on this one, so the cards above leave them out.</p>
<div class="grid gap-8 lg:grid-cols-2">
<div>
<h3 class="mb-1 text-sm font-semibold text-gray-700">Fields</h3>
<div class="mb-3 space-y-1 text-xs text-gray-600">
<p class="flex items-center gap-1">
<span class="size-3 rounded-sm border border-orange-200 bg-orange-100"></span>Imports straight into a portal field (hover to see which)
</p>
<% if model::USER_LOOKUP_FIELDS.any? %>
<p>* Needs a FileMaker staff β†’ portal user lookup</p>
<% end %>
</div>
<dl class="space-y-3">
<% model.flat_field_groups.merge(FmArchive::OTHER_FIELDS => record.ungrouped_keys).compact_blank.each do |label, keys| %>
<div>
<dt class="text-xs font-medium text-gray-500"><%= label %></dt>
<dd class="mt-1 flex flex-wrap gap-1">
<% keys.each do |key| %>
<% portal_field = model::PORTAL_FIELDS[key] %>
<%= tag.span "#{key}#{"*" if model::USER_LOOKUP_FIELDS.include?(key)}", class: class_names("rounded px-2 py-0.5 font-mono text-xs",
portal_field ? "bg-orange-100" : "bg-white",
record.data[key].present? ? "text-gray-700" : "text-gray-400"),
title: portal_field && "Imports to #{portal_field}" %>
<% end %>
</dd>
</div>
<% end %>
</dl>
</div>
<div class="space-y-6">
<div>
<h3 class="mb-3 text-sm font-semibold text-gray-700">Linked from this record</h3>
<ul class="space-y-1 text-sm">
<% model::FM_LINKS.each do |key, table| %>
<li>
<span class="rounded bg-white px-2 py-0.5 font-mono text-xs text-gray-700"><%= key %></span>
<span class="text-gray-400">β†’</span>
<%= link_to FmArchivesController::TABLES.dig(table.delete_prefix("fm_"), :label), fm_archives_path(table: table.delete_prefix("fm_")),
class: "text-primary hover:underline" %>
</li>
<% end %>
</ul>
</div>
<div>
<h3 class="mb-1 text-sm font-semibold text-gray-700">Records pointing here</h3>
<%= render "related_legend" %>
<ul class="space-y-1 text-sm">
<% model::HAS_MANY.each do |table, config| %>
<li class="<%= class_names("-mx-2 rounded px-2 py-0.5", fm_related_table_highlight(record, table)) %>">
<%= link_to config[:label], fm_archives_path(table: table.delete_prefix("fm_")), class: "text-primary hover:underline" %>
<% if (portal_name = fm_portal_counterpart_name(record, table, config[:label])) %>
<span class="text-gray-600">(<%= portal_name %>)</span>
<% end %>
<span class="text-gray-400">via</span>
<span class="rounded bg-white px-2 py-0.5 font-mono text-xs text-gray-700"><%= config[:via] %></span>
<% if config[:note] %>
<span class="text-xs text-gray-500 italic"><%= config[:note] %></span>
<% end %>
</li>
<% end %>
</ul>
</div>
</div>
</div>
</section>
Loading
Loading