diff --git a/CLAUDE.md b/CLAUDE.md index 355760a668..dca010377f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,10 @@ Hotwire-first: Turbo for navigation/forms, Stimulus controllers in `app/javascri ## Style This project uses **Standard.rb** (not vanilla RuboCop). Don't fight it on spacing/quotes/trailing commas. There is also a `.standard_todo.yml` of grandfathered violations — leave older files alone unless touching them substantively. Older migration files (2020–2024) are intentionally excluded from linting. +## Design system (casadesign branch) + +This branch introduces a Tailwind CSS v4 design system, documented in **`design.md`** (repo root) — the source of truth for the new aesthetic. Read it before UI work and keep it updated. New pages use Tailwind-only layouts and coexist with the legacy Bootstrap UI (migrate page-by-page). **On every page, show user names without honorific prefixes (first + last only) — `display_person` for new UI, `formatted_name` at existing `.display_name` sites — never mutate the stored `display_name`.** **Workflow: on `casadesign`, commit and push to the `casadesign` branch at every checkpoint.** **Buttons come from the `button_classes(:primary|:secondary|:danger)` helper (one shared 40px height token, never hand-written class strings). Native-dialog modals use the `Dialog::` ViewComponent suite (`Dialog::GroupComponent` + `Dialog::HeaderComponent`/`BodyComponent`/`FooterComponent`), not the legacy Bootstrap `Modal::*`. Page headers use one primary CTA plus a `More` overflow menu (native `
` + the `dropdown` controller): keep core actions visible and overflow only occasional ones, never bury a core action (also breaks non-JS specs, which can't open `
`). On mobile, collapse the remaining visible secondaries into `More` too (render twice with `hidden sm:contents` / `sm:hidden`) so only the primary CTA and `More` share the top line. Leading icons on menu/list items top-align to the first line (`items-start`), never centered against wrapped text.** + ## Where to look for more - `.github/instructions/ruby.instructions.md` and `.github/instructions/copilot-review.instructions.md` — the project's own review checklist; the source of truth for "what is a bug-shaped change here." - `doc/architecture-decisions/` — ADRs (especially 0002 no-UI-signup, 0003 two-user-tables, 0006 few-controller-tests, 0007 inline-email-CSS). diff --git a/Procfile.dev b/Procfile.dev index 9164cb4634..a87f94a87e 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,3 +1,4 @@ web: bin/rails server -p 3000 -b 0.0.0.0 js: npm run build:dev --watch css: npm run build:css:dev --watch +tw: npm run build:tailwind:dev diff --git a/app/assets/stylesheets/tailwind.css b/app/assets/stylesheets/tailwind.css new file mode 100644 index 0000000000..50b95ef6f7 --- /dev/null +++ b/app/assets/stylesheets/tailwind.css @@ -0,0 +1,91 @@ +@import "tailwindcss"; +@import "tom-select/dist/css/tom-select.css"; + +/* Where Tailwind should look for class names (paths are relative to this file). */ +@source "../../components/**/*.{erb,rb}"; +@source "../../views/**/*.erb"; +@source "../../helpers/**/*.rb"; +@source "../../javascript/**/*.js"; + +/* ------------------------------------------------------------------ + CASA design system (Tailwind v4 CSS-first config). + A calm, professional indigo palette + Figtree — the visual direction + for the casadesign redesign. +------------------------------------------------------------------ */ +@theme { + --font-sans: "Figtree", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + + --color-brand-50: #eef2ff; + --color-brand-100: #e0e7ff; + --color-brand-200: #c7d2fe; + --color-brand-300: #a5b4fc; + --color-brand-400: #818cf8; + --color-brand-500: #6366f1; + --color-brand-600: #4f46e5; + --color-brand-700: #4338ca; + --color-brand-800: #3730a3; + --color-brand-900: #312e81; +} + +/* ------------------------------------------------------------------ + TomSelect (the `multiple-select` Stimulus controller) restyled to the + design system. tom-select.css above provides the structure; these rules + theme it to match our inputs (slate border, rounded-lg, brand focus + + brand-50 pills). Only loaded on casa_app; Bootstrap pages keep the + tom-select.bootstrap5 theme from application.scss. +------------------------------------------------------------------ */ +.ts-wrapper { font-size: 0.875rem; } +.ts-control { + border-radius: 0.5rem; + border: 1px solid #cbd5e1; /* slate-300 */ + padding: 0.375rem 0.5rem; + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + background-color: #fff; + gap: 0.375rem; +} +.ts-wrapper.focus .ts-control { + border-color: #6366f1; /* brand-500 */ + box-shadow: 0 0 0 2px rgb(99 102 241 / 0.3); +} +.ts-control > .item { + border-radius: 9999px; + background-color: #eef2ff; /* brand-50 */ + color: #4338ca; /* brand-700 */ + padding: 0.125rem 0.625rem; + font-weight: 500; + border: none; +} +.ts-control > .item .remove { + border-left: none; + color: #6366f1; + padding: 0 0.35rem; +} +.ts-control > .item .remove:hover { background: transparent; color: #4338ca; } +.ts-control input::placeholder { color: #64748b; } /* slate-500 */ +.ts-dropdown { + border-radius: 0.5rem; + border: 1px solid #e2e8f0; /* slate-200 */ + box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1); + margin-top: 0.25rem; +} +.ts-dropdown .option { padding: 0.5rem 0.75rem; color: #334155; } /* slate-700 */ +.ts-dropdown .active { background-color: #eef2ff; color: #4338ca; } /* brand-50 / brand-700 */ + +/* ------------------------------------------------------------------ + Native modals (the `modal` Stimulus controller). Tailwind's reset + zeroes the `margin: auto` the UA uses to center a modal dialog, which pins it + to the top-left. Position it explicitly: horizontally centered and sitting + about a third of the way down, capped so tall modals stay on screen. +------------------------------------------------------------------ */ +dialog[data-modal-target="dialog"] { + position: fixed; + left: 50%; + top: 24vh; + transform: translateX(-50%); + margin: 0; + max-height: 80vh; + overflow-y: auto; +} +@media (max-width: 640px) { + dialog[data-modal-target="dialog"] { top: 18vh; } +} diff --git a/app/components/dialog/body_component.html.erb b/app/components/dialog/body_component.html.erb new file mode 100644 index 0000000000..6597c6602f --- /dev/null +++ b/app/components/dialog/body_component.html.erb @@ -0,0 +1 @@ +
<%= content %>
diff --git a/app/components/dialog/body_component.rb b/app/components/dialog/body_component.rb new file mode 100644 index 0000000000..1ced93841d --- /dev/null +++ b/app/components/dialog/body_component.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# The body region of a design-system dialog. `centered` switches to the status +# layout (centered hero badge + title + text). Pass extra `classes` (e.g. +# "space-y-4") for form bodies with several stacked fields. +class Dialog::BodyComponent < ViewComponent::Base + def initialize(centered: false, classes: nil) + @centered = centered + @classes = classes + end + + def body_classes + base = @centered ? "px-5 pt-6 pb-4 text-center" : "px-5 py-4" + "#{base} #{@classes}".strip + end +end diff --git a/app/components/dialog/footer_component.html.erb b/app/components/dialog/footer_component.html.erb new file mode 100644 index 0000000000..cffc5f3ad8 --- /dev/null +++ b/app/components/dialog/footer_component.html.erb @@ -0,0 +1 @@ + diff --git a/app/components/dialog/footer_component.rb b/app/components/dialog/footer_component.rb new file mode 100644 index 0000000000..1689ce3042 --- /dev/null +++ b/app/components/dialog/footer_component.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# The footer region of a design-system dialog: a divided action row. Actions are +# right-aligned by default (`:end`); use `:center` for a single-action status modal. +# Build the actions with the `button_classes` helper. +class Dialog::FooterComponent < ViewComponent::Base + def initialize(align: :end) + @align = align + end + + def footer_classes + justify = (@align == :center) ? "justify-center" : "justify-end" + "flex items-center #{justify} gap-2 border-t border-slate-100 px-5 py-4" + end +end diff --git a/app/components/dialog/group_component.html.erb b/app/components/dialog/group_component.html.erb new file mode 100644 index 0000000000..52727bf437 --- /dev/null +++ b/app/components/dialog/group_component.html.erb @@ -0,0 +1,6 @@ +<%= tag.div class: @wrapper_class, data: wrapper_data do %> + <%= trigger %> + <%= tag.dialog id: @id, class: panel_classes, aria: {label: @label}, data: {modal_target: "dialog", action: "click->modal#backdropClose"} do %> + <%= content %> + <% end %> +<% end %> diff --git a/app/components/dialog/group_component.rb b/app/components/dialog/group_component.rb new file mode 100644 index 0000000000..dd6610e192 --- /dev/null +++ b/app/components/dialog/group_component.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# The native- shell for the design-system modal. Pairs with the `modal` +# Stimulus controller (`showModal()` gives a focus trap, Escape-to-close, and an +# inert background for free). Compose the inside from Dialog::Header / Dialog::Body +# / Dialog::Footer so the template cannot drift. This is the Tailwind replacement +# for the legacy Bootstrap Modal::* suite; do not restyle Bootstrap `.modal` markup. +class Dialog::GroupComponent < ViewComponent::Base + renders_one :trigger + + SIZES = {sm: "max-w-sm", md: "max-w-md", lg: "max-w-lg"}.freeze + + # size: panel max-width (:sm | :md | :lg) + # label: accessible name for the dialog (usually the title text) + # id: optional id on the (e.g. a JS / test hook) + # open_on_connect: auto-open on connect via the modal controller + # controllers: extra Stimulus controllers on the wrapper (e.g. "court-report") + # data: extra wrapper data attributes (e.g. local-storage-reset-key-value) + # wrapper_class: layout for the wrapper div; default "inline-flex". Use "contents" + # to dissolve the wrapper so the trigger and dialog sit directly in + # a parent such as a dropdown menu (the trigger becomes a menu item). + def initialize(size: :md, label: nil, id: nil, open_on_connect: false, controllers: nil, data: {}, wrapper_class: "inline-flex") + @size = size + @label = label + @id = id + @open_on_connect = open_on_connect + @controllers = controllers + @data = data + @wrapper_class = wrapper_class + end + + def wrapper_data + merged = @data.merge(controller: ["modal", @controllers].compact.join(" ").strip) + merged[:modal_open_on_connect_value] = true if @open_on_connect + merged + end + + def panel_classes + "w-[calc(100vw-2rem)] #{SIZES.fetch(@size)} overflow-hidden rounded-2xl p-0 text-left shadow-xl backdrop:bg-slate-900/40" + end +end diff --git a/app/components/dialog/header_component.html.erb b/app/components/dialog/header_component.html.erb new file mode 100644 index 0000000000..745629d50e --- /dev/null +++ b/app/components/dialog/header_component.html.erb @@ -0,0 +1,9 @@ +
+ <% if @icon %><% end %> +

<%= @title %>

+ <% if @closable %> + + <% end %> +
diff --git a/app/components/dialog/header_component.rb b/app/components/dialog/header_component.rb new file mode 100644 index 0000000000..e696e16a89 --- /dev/null +++ b/app/components/dialog/header_component.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# The header row of a design-system dialog: an optional status badge, the title, +# and a close button. Render it inside Dialog::GroupComponent (it may sit inside a +# form for form-driven modals). +class Dialog::HeaderComponent < ViewComponent::Base + BADGE = { + danger: "bg-rose-100 text-rose-600", + success: "bg-emerald-50 text-emerald-600", + info: "bg-brand-50 text-brand-600" + }.freeze + + # title: heading text + # icon: optional bi-* class for a 32px status badge + # variant: badge color when an icon is shown (:danger | :success | :info) + # closable: render the close (X) button (default true) + def initialize(title:, icon: nil, variant: :info, closable: true) + @title = title + @icon = icon + @variant = variant + @closable = closable + end + + def badge_classes + "grid h-8 w-8 shrink-0 place-items-center rounded-full #{BADGE.fetch(@variant, BADGE[:info])}" + end +end diff --git a/app/components/notification_component.html.erb b/app/components/notification_component.html.erb index 9394d3c2f8..aa3b8c86e0 100644 --- a/app/components/notification_component.html.erb +++ b/app/components/notification_component.html.erb @@ -1,21 +1,22 @@ +<% read = notification.read? %> -
-
- <% unless notification.read? %> - - <% end %> -
-
-
-
<%= notification.event.title %>
- <%= time_ago_in_words(notification.created_at) %> ago -
-
- <%= simple_format notification.event.message %> -
+ data-method="post" + data-notification-list-item + <%= "data-read" if read %> + class="group flex items-start gap-3.5 px-4 py-3.5 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500"> + "> + text-sm" aria-hidden="true"> + +
+
+

"><%= notification.event.title %>

+ <%= time_ago_in_words(notification.created_at) %> ago
+
<%= simple_format notification.event.message %>
+ <% unless read %> + + Unread + <% end %>
diff --git a/app/components/notification_component.rb b/app/components/notification_component.rb index 7641538115..f3606e875d 100644 --- a/app/components/notification_component.rb +++ b/app/components/notification_component.rb @@ -6,8 +6,4 @@ class NotificationComponent < ViewComponent::Base def initialize(notification:) @notification = notification end - - def muted_display - "bg-light text-muted" if notification.read? - end end diff --git a/app/controllers/all_casa_admins/sessions_controller.rb b/app/controllers/all_casa_admins/sessions_controller.rb index 1c41a5ec44..fac114086c 100644 --- a/app/controllers/all_casa_admins/sessions_controller.rb +++ b/app/controllers/all_casa_admins/sessions_controller.rb @@ -2,5 +2,6 @@ class AllCasaAdmins::SessionsController < Devise::SessionsController include Accessible + layout "casa_auth" skip_before_action :check_user, only: :destroy end diff --git a/app/controllers/casa_cases_controller.rb b/app/controllers/casa_cases_controller.rb index a486a2be33..d8a8e9ef3f 100644 --- a/app/controllers/casa_cases_controller.rb +++ b/app/controllers/casa_cases_controller.rb @@ -4,19 +4,26 @@ class CasaCasesController < ApplicationController before_action :require_organization! after_action :verify_authorized + SORT_COLUMNS = %w[case_number next_court_date status transition assigned].freeze + def index authorize CasaCase - org_cases = current_user.casa_org.casa_cases.includes(:assigned_volunteers, :casa_case_emancipation_categories) - @casa_cases = policy_scope(org_cases).includes([:hearing_type, :judge]) - @casa_cases_filter_id = policy(CasaCase).can_see_filters? ? "casa-cases" : "" - @duties = OtherDuty.where(creator_id: current_user.id) + @active_nav = "cases" + @sort = SORT_COLUMNS.include?(params[:sort]) ? params[:sort] : "case_number" + @direction = (params[:direction] == "desc") ? "desc" : "asc" + org_cases = current_user.casa_org.casa_cases.includes(:assigned_volunteers, :court_dates) + scope = policy_scope(org_cases) + scope = filter_casa_cases(scope) if policy(CasaCase).can_see_filters? + @pagy, @casa_cases = pagy(order_casa_cases(scope)) + render :index, layout: "casa_app" end def show authorize @casa_case + @active_nav = "cases" respond_to do |format| - format.html {} + format.html { render layout: "casa_app" } format.csv do case_contacts = @casa_case.decorate.case_contacts_ordered_by_occurred_at csv = CaseContactsExportCsvService.new(case_contacts, CaseContactReport::COLUMNS).perform @@ -140,6 +147,64 @@ def copy_court_orders private + # Orders the cases index by the whitelisted ?sort= column and ?direction=. Derived + # columns (next court date, assigned volunteer) use correlated subqueries; a secondary + # sort by case number keeps pagination stable. + def order_casa_cases(scope) + today = ActiveRecord::Base.connection.quote(Date.current) + clause = + case @sort + when "status" then "casa_cases.active" + when "transition" then "casa_cases.birth_month_year_youth" + when "next_court_date" + "(SELECT MIN(court_dates.date) FROM court_dates WHERE court_dates.casa_case_id = casa_cases.id AND court_dates.date >= #{today})" + when "assigned" + "(SELECT MIN(users.display_name) FROM case_assignments JOIN users ON users.id = case_assignments.volunteer_id WHERE case_assignments.casa_case_id = casa_cases.id AND case_assignments.active)" + else "casa_cases.case_number" + end + scope = scope.order(Arel.sql("#{clause} #{@direction} NULLS LAST")) + scope = scope.order(case_number: :asc) unless @sort == "case_number" + scope + end + + # Server-side filtering for the cases index (admins/supervisors). Params come from the + # filter bar selects; volunteers never reach this. Status defaults to active. + def filter_casa_cases(scope) + if params[:search].present? + term = "%#{ActiveRecord::Base.sanitize_sql_like(params[:search].strip)}%" + scope = scope.where( + "casa_cases.case_number ILIKE :term OR EXISTS (SELECT 1 FROM case_assignments ca " \ + "JOIN users u ON u.id = ca.volunteer_id WHERE ca.casa_case_id = casa_cases.id AND ca.active " \ + "AND u.display_name ILIKE :term)", + term: term + ) + end + + scope = case params[:status] + when "inactive" then scope.inactive + when "all" then scope + else scope.active + end + + case params[:assigned] + when "assigned" then scope = scope.where(id: CaseAssignment.active.select(:casa_case_id)) + when "unassigned" then scope = scope.where.not(id: CaseAssignment.active.select(:casa_case_id)) + end + + case params[:transition] + when "yes" then scope = scope.is_transitioned + when "no" then scope = scope.where.not(id: current_user.casa_org.casa_cases.is_transitioned.select(:id)) + end + + case params[:prefix] + when "CINA" then scope = scope.where("case_number ILIKE ?", "CINA%") + when "TPR" then scope = scope.where("case_number ILIKE ?", "TPR%") + when "None" then scope = scope.where.not("case_number ILIKE ? OR case_number ILIKE ?", "CINA%", "TPR%") + end + + scope + end + # Use callbacks to share common setup or constraints between actions. def set_casa_case @casa_case = current_organization.casa_cases.friendly.find(params[:id]) diff --git a/app/controllers/case_groups_controller.rb b/app/controllers/case_groups_controller.rb index 2a9c06e684..1db2a384e3 100644 --- a/app/controllers/case_groups_controller.rb +++ b/app/controllers/case_groups_controller.rb @@ -1,5 +1,7 @@ class CaseGroupsController < ApplicationController + layout "casa_app" before_action :require_organization! + before_action -> { @active_nav = "cases" } before_action :set_case_group, only: %i[edit update destroy] def index diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 087f370a9d..f332fc81fd 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -7,11 +7,17 @@ def show if volunteer_with_only_one_active_case? redirect_to new_case_contact_path elsif current_user.volunteer? - redirect_to casa_cases_path + @active_nav = "dashboard" + @dashboard = VolunteerDashboard.new(current_user) + render "dashboard/volunteer", layout: "casa_app" elsif current_user.supervisor? - redirect_to volunteers_path + @active_nav = "dashboard" + @dashboard = SupervisorDashboard.new(current_user) + render "dashboard/supervisor", layout: "casa_app" elsif current_user.casa_admin? - redirect_to supervisors_path + @active_nav = "dashboard" + @dashboard = AdminDashboard.new(current_organization) + render "dashboard/admin", layout: "casa_app" end end diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb index bbc16f505c..1597f858e5 100644 --- a/app/controllers/health_controller.rb +++ b/app/controllers/health_controller.rb @@ -6,10 +6,16 @@ class HealthController < ApplicationController skip_after_action :verify_policy_scoped # TODO: index should call policy_scope; remove this skip once it does before_action :verify_token_for_old_object_stats, only: [:old_objects] + ALLOWED_RANGES = [3, 6, 12].freeze + def index respond_to do |format| format.html do - render :index + @range = ALLOWED_RANGES.include?(params[:range].to_i) ? params[:range].to_i : 12 + @case_contacts = monthly_case_contacts(@range) + @active_users = monthly_active_users(@range) + @contact_heatmap = contact_creation_heatmap(@range) + render :index, layout: "metrics" end format.json { render json: {latest_deploy_time: Health.instance.latest_deploy_time} } @@ -85,6 +91,58 @@ def monthly_unique_users_graph_data private + def last_month_starts(months_back) + (0..months_back - 1).map { |i| (months_back - 1 - i).months.ago.beginning_of_month } + end + + def monthly_case_contacts(months_back) + months = last_month_starts(months_back) + pick = ->(counts) { months.map { |m| counts.find { |k, _| k.year == m.year && k.month == m.month }&.last || 0 } } + total = CaseContact.group_by_month(:created_at, last: months_back).count + with_notes = CaseContact.where.not(notes: [nil, ""]).group_by_month(:created_at, last: months_back).count + loggers = CaseContact.group_by_month(:created_at, last: months_back).distinct.count(:creator_id) + { + labels: months.map { |m| m.strftime("%b") }, + series: [ + {name: "Total contacts", data: pick.call(total)}, + {name: "With notes", data: pick.call(with_notes)}, + {name: "Unique loggers", data: pick.call(loggers)} + ], + distinct_loggers: CaseContact.where(created_at: months.first..).distinct.count(:creator_id) + } + end + + def monthly_active_users(months_back) + months = last_month_starts(months_back) + keys = months.map { |m| m.strftime("%b %Y") } + by_type = ->(type) { + LoginActivity + .joins("INNER JOIN users ON users.id = login_activities.user_id AND login_activities.user_type = 'User'") + .where(users: {type: type}, success: true) + .group_by_month(:created_at, format: "%b %Y").distinct.count(:user_id) + } + volunteers = by_type.call("Volunteer") + supervisors = by_type.call("Supervisor") + admins = by_type.call("CasaAdmin") + logged = CaseContact.joins(supervisor_volunteer: :volunteer).group_by_month(:created_at, format: "%b %Y").distinct.count(:creator_id) + { + labels: months.map { |m| m.strftime("%b") }, + series: [ + {name: "Volunteers", data: keys.map { |k| volunteers[k] || 0 }}, + {name: "Supervisors", data: keys.map { |k| supervisors[k] || 0 }}, + {name: "Admins", data: keys.map { |k| admins[k] || 0 }}, + {name: "Active loggers", data: keys.map { |k| logged[k] || 0 }} + ] + } + end + + def contact_creation_heatmap(months_back) + grid = CaseContact.where(created_at: months_back.months.ago.beginning_of_month..) + .group("EXTRACT(DOW FROM created_at)::int") + .group("EXTRACT(HOUR FROM created_at)::int").count + {grid: grid, max: grid.values.max || 0} + end + def each_old_object ObjectSpace.each_object do |obj| next unless ObjectSpace.dump(obj).include?('"old":true') diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb index 8726d2d21f..d2d55bbfcf 100644 --- a/app/controllers/notifications_controller.rb +++ b/app/controllers/notifications_controller.rb @@ -1,4 +1,6 @@ class NotificationsController < ApplicationController + layout "casa_app" + after_action :verify_authorized skip_after_action :verify_policy_scoped # TODO: index should call policy_scope; remove this skip once it does before_action :set_notification, only: %i[mark_as_read] diff --git a/app/controllers/users/invitations_controller.rb b/app/controllers/users/invitations_controller.rb index 7dbdbc8352..44df716590 100644 --- a/app/controllers/users/invitations_controller.rb +++ b/app/controllers/users/invitations_controller.rb @@ -4,6 +4,6 @@ def edit self.resource = resource_class.new set_minimum_password_length if respond_to?(:set_minimum_password_length, true) resource.invitation_token = params[:invitation_token] - render :edit + render :edit, layout: "casa_auth" end end diff --git a/app/controllers/users/passwords_controller.rb b/app/controllers/users/passwords_controller.rb index f1c2ea6a90..faf852acbc 100644 --- a/app/controllers/users/passwords_controller.rb +++ b/app/controllers/users/passwords_controller.rb @@ -3,6 +3,8 @@ class Users::PasswordsController < Devise::PasswordsController include PhoneNumberHelper include SmsBodyHelper + layout "casa_auth" + def create @email = params.dig(resource_name, :email) @phone_number = params.dig(resource_name, :phone_number) diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index a1afeb575d..ae8b49d324 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -2,5 +2,6 @@ class Users::SessionsController < Devise::SessionsController include Accessible + layout "casa_auth" skip_before_action :check_user, only: :destroy end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 7ad32b2109..2d83cf7c5a 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,4 +1,6 @@ class UsersController < ApplicationController + layout "casa_app" + before_action :get_user before_action :authorize_user_with_policy before_action :set_active_casa_admins diff --git a/app/datatables/case_contact_datatable.rb b/app/datatables/case_contact_datatable.rb index 7240caf1a9..cdcdd95147 100644 --- a/app/datatables/case_contact_datatable.rb +++ b/app/datatables/case_contact_datatable.rb @@ -33,7 +33,7 @@ def data medium_type: case_contact.medium_type&.titleize, creator: { id: case_contact.creator_id, - display_name: case_contact.creator&.display_name, + display_name: NamePresentation.strip_honorific(case_contact.creator&.display_name), email: case_contact.creator&.email, role: case_contact.creator&.role }, diff --git a/app/datatables/reimbursement_datatable.rb b/app/datatables/reimbursement_datatable.rb index edf82ffb31..0738127316 100644 --- a/app/datatables/reimbursement_datatable.rb +++ b/app/datatables/reimbursement_datatable.rb @@ -23,7 +23,7 @@ def data occurred_at: case_contact.occurred_at, volunteer: { address: case_contact.creator.address&.content, - display_name: case_contact.creator.display_name, + display_name: NamePresentation.strip_honorific(case_contact.creator.display_name), email: case_contact.creator.email, id: case_contact.creator.id } diff --git a/app/datatables/supervisor_datatable.rb b/app/datatables/supervisor_datatable.rb index 62425a202d..df85529f18 100644 --- a/app/datatables/supervisor_datatable.rb +++ b/app/datatables/supervisor_datatable.rb @@ -12,7 +12,7 @@ def data { id: supervisor.id, active: supervisor.active?, - display_name: supervisor.display_name, + display_name: NamePresentation.strip_honorific(supervisor.display_name), email: supervisor.email, volunteer_assignments: supervisor.volunteers.count, transitions_volunteers: supervisor.volunteers_serving_transition_aged_youth, diff --git a/app/datatables/volunteer_datatable.rb b/app/datatables/volunteer_datatable.rb index ffb8c7de5e..4d0eba266d 100644 --- a/app/datatables/volunteer_datatable.rb +++ b/app/datatables/volunteer_datatable.rb @@ -18,7 +18,7 @@ def data active: volunteer.active?, casa_cases: volunteer.casa_cases.map { |cc| {id: cc.id, case_number: cc.case_number} }, contacts_made_in_past_days: volunteer.contacts_made_in_past_days, - display_name: volunteer.display_name, + display_name: NamePresentation.strip_honorific(volunteer.display_name), email: volunteer.email, has_transition_aged_youth_cases: volunteer.has_transition_aged_youth_cases?, id: volunteer.id, @@ -27,7 +27,7 @@ def data case_id: volunteer.most_recent_attempt_case_id, occurred_at: I18n.l(volunteer.most_recent_attempt_occurred_at, format: :full, default: nil) }, - supervisor: {id: volunteer.supervisor_id, name: volunteer.supervisor_name}, + supervisor: {id: volunteer.supervisor_id, name: NamePresentation.strip_honorific(volunteer.supervisor_name)}, hours_spent_in_days: volunteer.hours_spent_in_days(30), extra_languages: volunteer.languages&.map { |lang| {id: lang.id, name: lang.name} } } diff --git a/app/decorators/casa_case_decorator.rb b/app/decorators/casa_case_decorator.rb index 096944b00c..03638a708c 100644 --- a/app/decorators/casa_case_decorator.rb +++ b/app/decorators/casa_case_decorator.rb @@ -71,6 +71,12 @@ def formatted_updated_at I18n.l(object.updated_at, format: :standard, default: nil) end + def formatted_next_court_date + upcoming = object.court_dates.select { |court_date| court_date.date.to_date >= Date.current }.min_by(&:date) + return nil unless upcoming + I18n.l(upcoming.date, format: :full, default: nil) + end + def inactive_class (!object.active) ? "table-secondary" : "" end diff --git a/app/decorators/case_contact_decorator.rb b/app/decorators/case_contact_decorator.rb index 3b40bd22f0..d2a4afd2be 100644 --- a/app/decorators/case_contact_decorator.rb +++ b/app/decorators/case_contact_decorator.rb @@ -90,6 +90,19 @@ def medium_icon_classes end end + # Bootstrap Icons variant of medium_icon_classes, for the Tailwind (casa_app) + # views, which load bootstrap-icons rather than the legacy LineIcons font. + def medium_icon + case object.medium_type + when CaseContact::IN_PERSON then "bi bi-people" + when CaseContact::TEXT_EMAIL then "bi bi-envelope" + when CaseContact::VIDEO then "bi bi-camera-video" + when CaseContact::VOICE_ONLY then "bi bi-telephone" + when CaseContact::LETTER then "bi bi-envelope-paper" + else "bi bi-question-circle" + end + end + def contact_groups groups = contact_groups_with_types.keys if groups.count > 0 diff --git a/app/helpers/design_system_helper.rb b/app/helpers/design_system_helper.rb new file mode 100644 index 0000000000..642a57d1e2 --- /dev/null +++ b/app/helpers/design_system_helper.rb @@ -0,0 +1,51 @@ +module DesignSystemHelper + # Strip an honorific prefix (Mr./Mrs./...) from a name for display. Use at any + # existing `.display_name` call site that shows a person's name. + def formatted_name(name) + NamePresentation.strip_honorific(name.to_s) + end + + # A person's display name (honorific-free) with an email fallback. Prefer this for + # new UI where you have the user object. + def display_person(user) + formatted_name(user.display_name.presence || user.email) + end + + # Two-letter initials for avatars: first + last name, ignoring honorific prefixes. + # Falls back to a single letter for one-word names / emails. + def avatar_initials(name) + tokens = NamePresentation.strip_honorific(name.to_s).gsub(/[^a-zA-Z ]/, " ").split + initials = + if tokens.size >= 2 + "#{tokens.first[0]}#{tokens.last[0]}" + else + tokens.first.to_s[0, 1] + end + initials.upcase.presence || "?" + end + + # Single source of truth for design-system button styling. These used to be + # copy-pasted class strings across views, which is how the variants drifted + # (mismatched heights, ad hoc padding). Repoint every button here instead. + # + # All variants are the same size by construction: the fixed `h-10` (40px) height + # token means `box-sizing: border-box` absorbs the outlined variant's 1px border, + # so a filled button (no border) and the outlined secondary render identically + # tall. Do NOT add a `border border-transparent` compensation to the filled + # variants; the height token already handles it. + # + # Keep every class a literal string per variant: Tailwind's source scan + # (`app/helpers/**/*.rb` is a @source) only sees class names written out in full. + # Never build them by interpolation (e.g. "bg-#{color}-600") or they won't compile. + def button_classes(variant = :primary) + base = "inline-flex h-10 items-center justify-center gap-2 rounded-lg px-4 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-60" + variant_classes = + case variant + when :primary then "bg-brand-600 font-semibold text-white hover:bg-brand-700 focus-visible:ring-brand-500" + when :secondary then "border border-slate-200 bg-white font-medium text-slate-700 hover:bg-slate-50 focus-visible:ring-brand-500" + when :danger then "bg-rose-600 font-semibold text-white hover:bg-rose-700 focus-visible:ring-rose-500" + else raise ArgumentError, "unknown button variant: #{variant.inspect}" + end + "#{base} #{variant_classes}" + end +end diff --git a/app/helpers/health_helper.rb b/app/helpers/health_helper.rb new file mode 100644 index 0000000000..dd924dcad7 --- /dev/null +++ b/app/helpers/health_helper.rb @@ -0,0 +1,231 @@ +module HealthHelper + SERIES_COLORS = %w[#4f46e5 #059669 #d97706 #e11d48].freeze + SERIES_DASH = ["", "7 4", "1.5 4", "10 4 1.5 4"].freeze + SERIES_SHAPE = %i[circle square triangle diamond].freeze + HEATMAP_RAMP = %w[#eef2ff #c7d2fe #a5b4fc #818cf8 #6366f1 #4338ca].freeze + DAY_LABELS = %w[Sun Mon Tue Wed Thu Fri Sat].freeze + + def metric_tiles(items) + tag.div(safe_join(items.map { |item| metric_tile(item) }), class: "grid grid-cols-2 gap-3 sm:flex sm:flex-wrap") + end + + def metric_empty_state(heading, body) + tag.div(class: "rounded-2xl border border-dashed border-slate-300 bg-white px-6 py-10 text-center") do + safe_join([ + tag.div(empty_chart_icon, class: "mx-auto mb-3 flex justify-center text-slate-400"), + tag.p(heading, class: "text-[15px] font-bold text-slate-900"), + tag.p(body, class: "mx-auto mt-1 max-w-[46ch] text-sm text-slate-600") + ]) + end + end + + def metric_legend(series) + items = series.each_with_index.map do |ser, i| + tag.span(class: "inline-flex items-center gap-1.5 text-[13px] text-slate-600") do + safe_join([line_key_svg(i), tag.span(ser[:name])]) + end + end + tag.div(safe_join(items), class: "mt-3 flex flex-wrap gap-x-4 gap-y-2") + end + + def metric_line_chart(id, labels, series, title:, desc:) + svg, config = build_line_chart(id, labels, series, title, desc) + tag.div( + safe_join([ + raw(svg), + tag.div("", class: "pointer-events-none absolute left-0 top-0 z-10 rounded-lg bg-slate-900 px-2.5 py-2 text-xs text-white opacity-0 shadow-lg transition-opacity", data: {"chart-hover-target": "tip"}) + ]), + class: "relative", + data: {controller: "chart-hover", "chart-hover-config-value": config.to_json} + ) + end + + def metric_range_filter(active) + presets = {3 => "Last 3 months", 6 => "Last 6 months", 12 => "Last 12 months"} + links = presets.map do |months, label| + current = (months == active) + classes = "rounded-lg border px-3 py-1.5 text-[13px] font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 " + + (current ? "border-brand-200 bg-brand-50 text-brand-700" : "border-slate-200 bg-white text-slate-600 hover:bg-slate-50") + link_to(label, health_index_path(range: months), class: classes, "aria-current": (current ? "page" : nil)) + end + tag.div(safe_join([tag.span("Range", class: "mr-1 text-[13px] text-slate-500")] + links), + class: "mb-5 flex flex-wrap items-center gap-2", role: "group", "aria-label": "Date range") + end + + def metric_data_table(labels, series, caption:, foot: nil, footnote: nil) + th = "px-2.5 py-1.5 text-right text-xs font-semibold text-slate-500" + th_row = "px-2.5 py-1.5 text-left text-xs font-semibold text-slate-700" + td = "px-2.5 py-1.5 text-right text-xs tabular-nums text-slate-700" + header = tag.tr(class: "border-b border-slate-200") do + safe_join([tag.th("Month", scope: "col", class: "#{th} text-left")] + + series.map { |ser| tag.th(ser[:name], scope: "col", class: th) }) + end + rows = labels.each_with_index.map do |lab, i| + tag.tr(class: (i.zero? ? "" : "border-t border-slate-50")) do + safe_join([tag.th(lab, scope: "row", class: th_row)] + + series.map { |ser| tag.td(ser[:data][i], class: td) }) + end + end + foot_html = if foot + tag.tfoot(tag.tr(safe_join( + [tag.th(foot[:label], scope: "row", class: "#{th_row} border-t-2 border-slate-200 text-slate-900")] + + foot[:cells].map { |c| tag.td(c, class: "#{td} border-t-2 border-slate-200 font-bold text-slate-900") } + ))) + else + "".html_safe + end + table = tag.table(class: "w-full") do + safe_join([tag.caption(caption, class: "sr-only"), tag.thead(header), tag.tbody(safe_join(rows)), foot_html]) + end + tag.details(class: "mt-3 border-t border-slate-100 pt-2.5") do + safe_join([ + tag.summary("View as table", class: "w-max cursor-pointer text-[13px] font-medium text-brand-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"), + tag.div(table, class: "mt-2.5 overflow-x-auto"), + (footnote ? tag.p(footnote, class: "mt-2 text-[11px] text-slate-500") : "".html_safe) + ]) + end + end + + def metric_heatmap(grid, max) + hours = (0..23).to_a + header = tag.tr do + safe_join([tag.th(tag.span("Day of week", class: "sr-only"), scope: "col", class: "sticky left-0 z-10 bg-white px-1 py-1")] + + hours.map { |hr| tag.th(hr, scope: "col", class: "px-1 py-1 text-center text-[10px] font-medium text-slate-500") }) + end + rows = DAY_LABELS.each_with_index.map do |day, di| + cells = hours.map do |hr| + count = grid[[di, hr]] || 0 + tag.td(count.positive? ? count : "", + style: "background:#{heat_color(count, max)};color:#{heat_ink(count, max)}", + class: "w-[30px] min-w-[30px] rounded border-2 border-white py-1 text-center text-[11px]", + title: "#{day} #{hr}:00, #{count} #{"contact".pluralize(count)}") + end + tag.tr(safe_join([tag.th(day, scope: "row", class: "sticky left-0 z-10 bg-white px-1 py-1 pr-2.5 text-right text-[11px] font-semibold text-slate-700")] + cells)) + end + tag.div(class: "overflow-x-auto") do + tag.table(class: "border-collapse") do + safe_join([ + tag.caption("Case contacts created by day of week (rows) and hour of day (columns, 0 to 23). Cell shade and number both encode the count.", class: "sr-only"), + tag.thead(header), + tag.tbody(safe_join(rows)) + ]) + end + end + end + + private + + def metric_tile(item) + value = item[:value] + value_html = + if value.nil? + tag.div("No data", class: "pt-1 text-[15px] font-semibold leading-tight text-slate-600") + elsif value.zero? + tag.div("0", class: "text-2xl font-bold leading-none text-slate-500") + else + tag.div(value, class: "text-2xl font-bold leading-none text-slate-900") + end + tag.div(class: "rounded-xl bg-slate-50 px-3.5 py-3 sm:min-w-[130px] sm:flex-1") do + safe_join([ + value_html, + tag.div(item[:label], class: "mt-1 text-sm text-slate-600"), + tag.div(item[:sub], class: "text-[11px] text-slate-500") + ]) + end + end + + def build_line_chart(id, labels, series, title, desc) + w = 720 + h = 300 + pad_l = 42 + pad_r = 40 + pad_t = 16 + pad_b = 34 + plot_w = w - pad_l - pad_r + plot_h = h - pad_t - pad_b + ymax = nice_ceiling(series.flat_map { |s| s[:data] }.max.to_i) + n = labels.size + xx = ->(i) { (pad_l + plot_w * i.to_f / [n - 1, 1].max).round(1) } + yy = ->(v) { (pad_t + plot_h * (1 - v.to_f / ymax)).round(1) } + out = %() + out << %(#{h(title)}#{h(desc)}) + 4.downto(0) do |t| + frac = t / 4.0 + y = (pad_t + plot_h * (1 - frac)).round(1) + out << %() + out << %(#{(ymax * frac).round}) + end + labels.each_with_index do |lab, i| + out << %(#{h(lab)}) + end + out << %() + series.each_with_index do |ser, si| + color = series_color(si) + dash = series_dash(si) + dash_attr = dash.empty? ? "" : %( stroke-dasharray="#{dash}") + pts = ser[:data].each_with_index.map { |v, i| "#{xx.call(i)},#{yy.call(v)}" }.join(" ") + out << %() + ser[:data].each_with_index do |v, i| + out << %(#{h(ser[:name])}, #{h(labels[i])}: #{v}#{metric_marker(SERIES_SHAPE[si], xx.call(i), yy.call(v), color)}) + end + out << %(#{ser[:data].last}) + end + out << %() + out << "" + config = { + plotTop: pad_t, + plotBottom: pad_t + plot_h, + xs: (0...n).map { |i| xx.call(i) }, + labels: labels, + series: series.each_with_index.map { |ser, si| {name: ser[:name], color: series_color(si), values: ser[:data], ys: ser[:data].map { |v| yy.call(v) }} } + } + [out, config] + end + + def line_key_svg(i) + color = series_color(i) + dash = series_dash(i) + dash_attr = dash.empty? ? "" : %( stroke-dasharray="#{dash}") + raw(%()) + end + + def metric_marker(shape, cx, cy, color, r = 3.6) + case shape + when :circle + %() + when :square + s = r * 1.8 + %() + when :triangle + s = r * 2.2 + %() + else + s = r * 1.5 + %() + end + end + + def nice_ceiling(v) + return 5 if v <= 5 + mag = 10**Math.log10(v).floor + [1, 2, 2.5, 5, 10].map { |f| f * mag }.find { |c| c >= v } || 10 * mag + end + + def heat_color(count, max) + return "#f8fafc" if count <= 0 || max <= 0 + idx = [((count.to_f / max) * (HEATMAP_RAMP.size - 1)).round, HEATMAP_RAMP.size - 1].min + HEATMAP_RAMP[idx] + end + + def heat_ink(count, max) + (max.positive? && count.to_f / max > 0.55) ? "#ffffff" : "#334155" + end + + def empty_chart_icon + raw(%()) + end + + def series_color(i) = SERIES_COLORS[i % SERIES_COLORS.size] + + def series_dash(i) = SERIES_DASH[i % SERIES_DASH.size] +end diff --git a/app/helpers/table_helper.rb b/app/helpers/table_helper.rb new file mode 100644 index 0000000000..7276fe249e --- /dev/null +++ b/app/helpers/table_helper.rb @@ -0,0 +1,29 @@ +module TableHelper + # A sortable column header: an containing a link that toggles the + # sort direction, plus a double-caret indicator whose active half is brand-coloured. + # Sorting is server-side, so the link preserves the current query (filters) and + # resets the page. `column` must be whitelisted by the controller. + def sortable_header(label, column, sort:, direction:) + active = column.to_s == sort.to_s + state = active ? direction : "none" + aria = {"asc" => "ascending", "desc" => "descending"}.fetch(state, "none") + next_direction = (active && direction == "asc") ? "desc" : "asc" + href = url_for(request.query_parameters.merge("sort" => column.to_s, "direction" => next_direction).except("page")) + link_class = "inline-flex items-center rounded text-xs font-semibold #{active ? "text-slate-900" : "text-slate-600"} hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500" + content_tag(:th, class: "px-4 py-3 text-left", "aria-sort": aria) do + link_to(href, class: link_class) { safe_join([label, sort_caret(state)]) } + end + end + + def sort_caret(state) + up = caret_color(state, "asc") + down = caret_color(state, "desc") + raw(%()) + end + + # Brand for the active half, light for the other half of a sorted column, mid when unsorted. + def caret_color(state, half) + return "#94a3b8" if state == "none" + (state == half) ? "#4f46e5" : "#cbd5e1" + end +end diff --git a/app/javascript/__tests__/add_to_calendar_controller.test.js b/app/javascript/__tests__/add_to_calendar_controller.test.js new file mode 100644 index 0000000000..80a3dda870 --- /dev/null +++ b/app/javascript/__tests__/add_to_calendar_controller.test.js @@ -0,0 +1,50 @@ +/* eslint-env jest */ +/** + * @jest-environment jsdom + */ +import { Application } from '@hotwired/stimulus' +import AddToCalendarController from '../controllers/add_to_calendar_controller' + +// The web component itself is browser-only; stub it out so importing the +// controller has no side effects and createElement makes an inert element. +jest.mock('add-to-calendar-button', () => ({})) + +describe('add_to_calendar_controller', () => { + let application + + const mount = async (html) => { + document.body.innerHTML = html + application = Application.start() + application.register('add-to-calendar', AddToCalendarController) + await new Promise((resolve) => setTimeout(resolve, 0)) + } + + afterEach(() => { + if (application) application.stop() + document.body.innerHTML = '' + }) + + test('replaces its content with a configured add-to-calendar-button', async () => { + await mount(` +
stale content
+ `) + + const host = document.querySelector('[data-controller="add-to-calendar"]') + const button = host.querySelector('add-to-calendar-button') + + expect(button).not.toBeNull() + expect(host.textContent).not.toContain('stale content') + expect(button.getAttribute('name')).toBe('Court Hearing') + expect(button.getAttribute('startDate')).toBe('2025-11-15') + expect(button.getAttribute('endDate')).toBe('2025-11-15') + expect(button.getAttribute('description')).toBe('Court Hearing') + expect(button.getAttribute('options')).toBe("'Apple','Google','iCal','Microsoft365','Outlook.com','Yahoo'") + expect(button.getAttribute('timeZone')).toBe('currentBrowser') + expect(button.getAttribute('lightMode')).toBe('bodyScheme') + expect(button.title).toBe('Add to calendar') + }) +}) diff --git a/app/javascript/__tests__/local_storage_reset_controller.test.js b/app/javascript/__tests__/local_storage_reset_controller.test.js new file mode 100644 index 0000000000..81cd32c7da --- /dev/null +++ b/app/javascript/__tests__/local_storage_reset_controller.test.js @@ -0,0 +1,35 @@ +/* eslint-env jest */ +/** + * @jest-environment jsdom + */ +import { Application } from '@hotwired/stimulus' +import LocalStorageResetController from '../controllers/local_storage_reset_controller' + +describe('local_storage_reset_controller', () => { + let application + + const mount = async (html) => { + document.body.innerHTML = html + application = Application.start() + application.register('local-storage-reset', LocalStorageResetController) + await new Promise((resolve) => setTimeout(resolve, 0)) + } + + afterEach(() => { + if (application) application.stop() + document.body.innerHTML = '' + window.localStorage.clear() + }) + + test('removes the configured key on connect', async () => { + window.localStorage.setItem('casa-contact-form', 'draft') + await mount('
') + expect(window.localStorage.getItem('casa-contact-form')).toBeNull() + }) + + test('leaves other keys untouched', async () => { + window.localStorage.setItem('keep-me', 'yes') + await mount('
') + expect(window.localStorage.getItem('keep-me')).toBe('yes') + }) +}) diff --git a/app/javascript/all_casa_admin.js b/app/javascript/all_casa_admin.js index 6324ded939..b0723b43f9 100644 --- a/app/javascript/all_casa_admin.js +++ b/app/javascript/all_casa_admin.js @@ -1,4 +1,3 @@ require('./src/all_casa_admin/tables') require('./src/all_casa_admin/patch_notes') require('./src/session_timeout_poller.js') -require('./src/display_app_metric.js') diff --git a/app/javascript/application.js b/app/javascript/application.js index 88f9aef317..3a9d5904b9 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -31,7 +31,6 @@ require('./src/select') require('./src/tooltip') require('./src/time_zone') require('./src/session_timeout_poller.js') -require('./src/display_app_metric.js') require('./src/casa_org') require('./src/sms_reactivation_toggle') require('./src/validated_form') diff --git a/app/javascript/controllers/add_to_calendar_controller.js b/app/javascript/controllers/add_to_calendar_controller.js new file mode 100644 index 0000000000..7f2eab374b --- /dev/null +++ b/app/javascript/controllers/add_to_calendar_controller.js @@ -0,0 +1,29 @@ +import { Controller } from '@hotwired/stimulus' +import 'add-to-calendar-button' + +// Hydrates an "Add to Calendar" web component from data values, so a court date +// (or the next court date) can be saved to a personal calendar. This is the +// Stimulus replacement for the legacy jQuery `div.cal-btn` scan +// (src/add_to_calendar_button.js): it hydrates on connect, so it also survives +// Turbo navigation, and each button owns its own data instead of a global sweep. +export default class extends Controller { + static values = { + title: String, + start: String, + end: String, + tooltip: String + } + + connect () { + const button = document.createElement('add-to-calendar-button') + button.setAttribute('name', this.titleValue) + button.setAttribute('startDate', this.startValue) + button.setAttribute('endDate', this.endValue) + button.setAttribute('description', this.titleValue) + button.setAttribute('options', "'Apple','Google','iCal','Microsoft365','Outlook.com','Yahoo'") + button.setAttribute('timeZone', 'currentBrowser') + button.setAttribute('lightMode', 'bodyScheme') + button.title = this.tooltipValue + this.element.replaceChildren(button) + } +} diff --git a/app/javascript/controllers/auto_submit_controller.js b/app/javascript/controllers/auto_submit_controller.js new file mode 100644 index 0000000000..fb64329686 --- /dev/null +++ b/app/javascript/controllers/auto_submit_controller.js @@ -0,0 +1,9 @@ +import { Controller } from '@hotwired/stimulus' + +// Submits the form when a control changes (e.g. the table filter selects). Turbo Drive +// keeps the navigation smooth, so filtering has no full-page flash. +export default class extends Controller { + submit () { + this.element.requestSubmit() + } +} diff --git a/app/javascript/controllers/chart_hover_controller.js b/app/javascript/controllers/chart_hover_controller.js new file mode 100644 index 0000000000..7524f93339 --- /dev/null +++ b/app/javascript/controllers/chart_hover_controller.js @@ -0,0 +1,107 @@ +import { Controller } from '@hotwired/stimulus' + +// Crosshair + tooltip for a server-rendered SVG line chart. Reads the chart +// geometry from the config value; every value is also in the table twin, so this +// is progressive enhancement, not the only way to read the data. +export default class extends Controller { + static values = { config: Object } + static targets = ['tip'] + + connect () { + this.cfg = this.configValue + this.svg = this.element.querySelector('svg') + if (!this.svg || !this.cfg || !this.cfg.series) return + const ns = 'http://www.w3.org/2000/svg' + this.cross = document.createElementNS(ns, 'line') + this.cross.setAttribute('stroke', '#94a3b8') + this.cross.setAttribute('stroke-width', '1') + this.cross.setAttribute('stroke-dasharray', '3 3') + this.cross.setAttribute('y1', this.cfg.plotTop) + this.cross.setAttribute('y2', this.cfg.plotBottom) + this.cross.style.opacity = '0' + this.cross.style.pointerEvents = 'none' + this.svg.appendChild(this.cross) + this.dots = this.cfg.series.map((s) => { + const dot = document.createElementNS(ns, 'circle') + dot.setAttribute('r', '4.5') + dot.setAttribute('fill', s.color) + dot.setAttribute('stroke', '#fff') + dot.setAttribute('stroke-width', '2') + dot.style.opacity = '0' + dot.style.pointerEvents = 'none' + this.svg.appendChild(dot) + return dot + }) + this.onMove = this.onMove.bind(this) + this.onLeave = this.onLeave.bind(this) + this.svg.addEventListener('pointermove', this.onMove) + this.svg.addEventListener('pointerleave', this.onLeave) + } + + disconnect () { + if (!this.svg) return + this.svg.removeEventListener('pointermove', this.onMove) + this.svg.removeEventListener('pointerleave', this.onLeave) + } + + onMove (event) { + const point = this.svg.createSVGPoint() + point.x = event.clientX + point.y = event.clientY + const x = point.matrixTransform(this.svg.getScreenCTM().inverse()).x + let index = 0 + let best = Infinity + this.cfg.xs.forEach((xv, idx) => { + const distance = Math.abs(xv - x) + if (distance < best) { + best = distance + index = idx + } + }) + const cx = this.cfg.xs[index] + this.cross.setAttribute('x1', cx) + this.cross.setAttribute('x2', cx) + this.cross.style.opacity = '1' + this.cfg.series.forEach((s, si) => { + this.dots[si].setAttribute('cx', cx) + this.dots[si].setAttribute('cy', s.ys[index]) + this.dots[si].style.opacity = '1' + }) + if (!this.hasTipTarget) return + const tip = this.tipTarget + tip.replaceChildren() + const month = document.createElement('div') + month.className = 'mb-1 text-[11px] font-bold text-slate-300' + month.textContent = this.cfg.labels[index] + tip.appendChild(month) + this.cfg.series.forEach((s) => { + const row = document.createElement('div') + row.className = 'flex items-center gap-1.5 leading-relaxed' + const key = document.createElement('span') + key.className = 'h-0.5 w-3.5 flex-none rounded' + key.style.background = s.color + const value = document.createElement('span') + value.className = 'font-bold tabular-nums' + value.textContent = s.values[index] + const name = document.createElement('span') + name.className = 'text-slate-300' + name.textContent = s.name + row.append(key, value, name) + tip.appendChild(row) + }) + tip.style.opacity = '1' + const rect = this.element.getBoundingClientRect() + let left = event.clientX - rect.left + 14 + if (left + tip.offsetWidth > rect.width) { + left = event.clientX - rect.left - tip.offsetWidth - 14 + } + tip.style.left = `${left}px` + tip.style.top = `${event.clientY - rect.top + 14}px` + } + + onLeave () { + this.cross.style.opacity = '0' + this.dots.forEach((dot) => { dot.style.opacity = '0' }) + if (this.hasTipTarget) this.tipTarget.style.opacity = '0' + } +} diff --git a/app/javascript/controllers/court_report_controller.js b/app/javascript/controllers/court_report_controller.js new file mode 100644 index 0000000000..ed099c7c32 --- /dev/null +++ b/app/javascript/controllers/court_report_controller.js @@ -0,0 +1,52 @@ +import { Controller } from '@hotwired/stimulus' + +// Generates a court report (docx) from the case-show modal. It posts the date +// range to the JSON endpoint (Rails wraps the flat body under case_court_report), +// shows a spinner while the docx is built, then opens the download in a new tab. +// The Tailwind + Stimulus replacement for the legacy jQuery handleGenerateReport. +export default class extends Controller { + static targets = ['form', 'timeZone', 'spinner', 'submit', 'error'] + + connect () { + if (this.hasTimeZoneTarget) { + this.timeZoneTarget.value = Intl.DateTimeFormat().resolvedOptions().timeZone + } + } + + async generate (event) { + event.preventDefault() + const form = this.formTarget + if (!form.reportValidity()) return + + this.setBusy(true) + try { + const response = await window.fetch(form.action, { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify(Object.fromEntries(new window.FormData(form))) + }) + const data = await response.json() + if (data.status !== 'ok') { + this.showError(data.error_messages) + return + } + window.open(data.link, '_blank') + } catch (error) { + this.showError('Something went wrong generating the report. Please try again.') + } finally { + this.setBusy(false) + } + } + + setBusy (busy) { + if (this.hasSpinnerTarget) this.spinnerTarget.classList.toggle('hidden', !busy) + if (this.hasSubmitTarget) this.submitTarget.disabled = busy + } + + showError (html) { + if (this.hasErrorTarget) { + this.errorTarget.innerHTML = html + this.errorTarget.classList.remove('hidden') + } + } +} diff --git a/app/javascript/controllers/disclosure_controller.js b/app/javascript/controllers/disclosure_controller.js new file mode 100644 index 0000000000..7dcec9cbda --- /dev/null +++ b/app/javascript/controllers/disclosure_controller.js @@ -0,0 +1,16 @@ +import { Controller } from '@hotwired/stimulus' + +// Progressive disclosure for a collapsible panel (e.g. the Change Password / +// Change Email sections on the edit-profile page). The panel starts hidden and +// the trigger button toggles it. +export default class extends Controller { + static targets = ['panel', 'trigger'] + + toggle () { + const hidden = this.panelTarget.classList.toggle('hidden') + + if (this.hasTriggerTarget) { + this.triggerTarget.setAttribute('aria-expanded', String(!hidden)) + } + } +} diff --git a/app/javascript/controllers/dismiss_controller.js b/app/javascript/controllers/dismiss_controller.js index fecbf7353e..b2972e95ce 100644 --- a/app/javascript/controllers/dismiss_controller.js +++ b/app/javascript/controllers/dismiss_controller.js @@ -13,7 +13,7 @@ export default class extends Controller { .then(response => response.json()) .then(data => { if (data.status === 'ok') { - this.elementTarget.classList.add('d-none') + this.elementTarget.classList.add('d-none', 'hidden') } }) } diff --git a/app/javascript/controllers/dropdown_controller.js b/app/javascript/controllers/dropdown_controller.js new file mode 100644 index 0000000000..6a5c37d747 --- /dev/null +++ b/app/javascript/controllers/dropdown_controller.js @@ -0,0 +1,44 @@ +import { Controller } from '@hotwired/stimulus' + +// Enhances a native
disclosure used as a menu: +// - only one dropdown is open at a time (opening one closes the others), +// - it closes on an outside click and on Escape (returning focus to the summary). +// Opening/closing is still the browser's native
toggle, so this is +// progressive enhancement — the menu works without JS. +export default class extends Controller { + connect () { + this.closeSiblings = this.closeSiblings.bind(this) + this.closeOnOutsideClick = this.closeOnOutsideClick.bind(this) + this.closeOnEscape = this.closeOnEscape.bind(this) + this.element.addEventListener('toggle', this.closeSiblings) + document.addEventListener('click', this.closeOnOutsideClick) + this.element.addEventListener('keydown', this.closeOnEscape) + } + + disconnect () { + this.element.removeEventListener('toggle', this.closeSiblings) + document.removeEventListener('click', this.closeOnOutsideClick) + this.element.removeEventListener('keydown', this.closeOnEscape) + } + + // When this menu opens, close every other open dropdown so only one is open. + closeSiblings () { + if (!this.element.open) return + document.querySelectorAll('details[data-controller~="dropdown"][open]').forEach((other) => { + if (other !== this.element) other.open = false + }) + } + + closeOnOutsideClick (event) { + if (this.element.open && !this.element.contains(event.target)) { + this.element.open = false + } + } + + closeOnEscape (event) { + if (event.key === 'Escape' && this.element.open) { + this.element.open = false + this.element.querySelector('summary')?.focus() + } + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 7bb572bdfb..5d0e5beda5 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -5,24 +5,38 @@ import RailsNestedForm from '@stimulus-components/rails-nested-form' import { application } from './application' +import AddToCalendarController from './add_to_calendar_controller' + import AlertController from './alert_controller' import AutosaveController from './autosave_controller' +import AutoSubmitController from './auto_submit_controller' + import CasaNestedFormController from './casa_nested_form_controller' import CaseContactFormController from './case_contact_form_controller' import CourtOrderFormController from './court_order_form_controller' +import CourtReportController from './court_report_controller' + import DisableFormController from './disable_form_controller' +import DisclosureController from './disclosure_controller' + import DismissController from './dismiss_controller' +import DropdownController from './dropdown_controller' + import HelloController from './hello_controller' import IconToggleController from './icon_toggle_controller' +import LocalStorageResetController from './local_storage_reset_controller' + +import ModalController from './modal_controller' + import MultipleSelectController from './multiple_select_controller' import NavbarController from './navbar_controller' @@ -36,15 +50,22 @@ import SidebarGroupController from './sidebar_group_controller' import TruncatedTextController from './truncated_text_controller' application.register('nested-form', RailsNestedForm) +application.register('add-to-calendar', AddToCalendarController) application.register('alert', AlertController) application.register('autosave', AutosaveController) +application.register('auto-submit', AutoSubmitController) application.register('casa-nested-form', CasaNestedFormController) application.register('case-contact-form', CaseContactFormController) application.register('court-order-form', CourtOrderFormController) +application.register('court-report', CourtReportController) application.register('disable-form', DisableFormController) +application.register('disclosure', DisclosureController) application.register('dismiss', DismissController) +application.register('dropdown', DropdownController) application.register('hello', HelloController) application.register('icon-toggle', IconToggleController) +application.register('local-storage-reset', LocalStorageResetController) +application.register('modal', ModalController) application.register('multiple-select', MultipleSelectController) application.register('navbar', NavbarController) application.register('select-all', SelectAllController) diff --git a/app/javascript/controllers/local_storage_reset_controller.js b/app/javascript/controllers/local_storage_reset_controller.js new file mode 100644 index 0000000000..146c6ed843 --- /dev/null +++ b/app/javascript/controllers/local_storage_reset_controller.js @@ -0,0 +1,12 @@ +import { Controller } from '@hotwired/stimulus' + +// Removes a localStorage key on connect, e.g. discarding a saved case-contact +// draft once the contact has been created (this controller is rendered only on +// the success redirect). +export default class extends Controller { + static values = { key: String } + + connect () { + if (this.keyValue) window.localStorage.removeItem(this.keyValue) + } +} diff --git a/app/javascript/controllers/modal_controller.js b/app/javascript/controllers/modal_controller.js new file mode 100644 index 0000000000..2ddf7f4a4f --- /dev/null +++ b/app/javascript/controllers/modal_controller.js @@ -0,0 +1,28 @@ +import { Controller } from '@hotwired/stimulus' + +// A modal built on the native element. `open` calls showModal(), which +// traps focus, wires Escape-to-close, and makes the background inert; `close` +// closes it; a click on the backdrop (the itself, outside its panel) +// also closes it. No custom focus-trap or key handling to maintain. +export default class extends Controller { + static targets = ['dialog'] + static values = { openOnConnect: Boolean } + + connect () { + if (this.openOnConnectValue) this.open() + } + + open () { + this.dialogTarget.showModal() + } + + close () { + this.dialogTarget.close() + } + + backdropClose (event) { + if (event.target === this.dialogTarget) { + this.close() + } + } +} diff --git a/app/javascript/metrics.js b/app/javascript/metrics.js new file mode 100644 index 0000000000..16a1e1986c --- /dev/null +++ b/app/javascript/metrics.js @@ -0,0 +1,4 @@ +import { application } from './controllers/application' +import ChartHoverController from './controllers/chart_hover_controller' + +application.register('chart-hover', ChartHoverController) diff --git a/app/javascript/src/display_app_metric.js b/app/javascript/src/display_app_metric.js deleted file mode 100644 index 4df412b94c..0000000000 --- a/app/javascript/src/display_app_metric.js +++ /dev/null @@ -1,242 +0,0 @@ -import { Chart, registerables } from 'chart.js' -import 'chartjs-adapter-luxon' - -const { Notifier } = require('./notifier') - -Chart.register(...registerables) - -const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] - -$(() => { // JQuery's callback for the DOM loading - const caseContactCreationTimesBubbleChart = document.getElementById('caseContactCreationTimeBubbleChart') - const monthLineChart = document.getElementById('monthLineChart') - const uniqueUsersMonthLineChart = document.getElementById('uniqueUsersMonthLineChart') - - const notificationsElement = $('#notifications') - const pageNotifier = notificationsElement.length ? new Notifier(notificationsElement) : null - - if (caseContactCreationTimesBubbleChart) { - fetchDataAndCreateChart('/health/case_contacts_creation_times_in_last_week', caseContactCreationTimesBubbleChart, function (data) { - const timestamps = data.timestamps - const graphData = formatTimestampsAsBubbleChartData(timestamps) - createChart(caseContactCreationTimesBubbleChart, graphData) - }) - } - - if (monthLineChart) { - fetchDataAndCreateChart('/health/monthly_line_graph_data', monthLineChart, function (data) { - console.log(data) - createLineChartForCaseContacts(monthLineChart, data) - }) - } - - if (uniqueUsersMonthLineChart) { - fetchDataAndCreateChart('/health/monthly_unique_users_graph_data', uniqueUsersMonthLineChart, function (data) { - console.log(data) - createLineChartForUniqueUsersMonthly(uniqueUsersMonthLineChart, data) - }) - } - - function fetchDataAndCreateChart (url, chartElement, successCallback) { - $.ajax({ - type: 'GET', - url, - success: successCallback, - error: handleAjaxError - }) - } - - function handleAjaxError (xhr, status, error) { - console.error('Failed to fetch data for case contact entry times chart display') - console.error(error) - pageNotifier?.notify('Failed to display metric chart. Check the console for error details.', 'error') - } -}) - -function formatTimestampsAsBubbleChartData (timestamps) { - const bubbleDataAsObject = {} - - for (const timestamp of timestamps) { - const contactCreationTime = new Date(timestamp * 1000) - const day = contactCreationTime.getDay() - const hour = contactCreationTime.getHours() - - // Group case contacts with the same hour and day creation time into the same data point - - let dayData - - if (!(day in bubbleDataAsObject)) { - dayData = {} - bubbleDataAsObject[day] = dayData - } else { - dayData = bubbleDataAsObject[day] - } - - if (!(hour in dayData)) { - dayData[hour] = 1 - } else { - dayData[hour]++ - } - } - - // Flatten data points - - const bubbleDataAsArray = [] - - for (const day in bubbleDataAsObject) { - const hours = bubbleDataAsObject[day] - - for (const hour in hours) { - bubbleDataAsArray.push({ - x: hour, - y: day, - r: Math.sqrt(hours[hour]) * 4 - }) - } - } - - return bubbleDataAsArray -} - -function createChart (chartElement, dataset) { - const ctx = chartElement.getContext('2d') - - return new Chart(ctx, { - type: 'bubble', - data: { - datasets: [ - { - label: 'Case Contact Creation Times', - data: dataset, - backgroundColor: 'rgba(255, 99, 132, 0.2)', - borderColor: 'rgba(255, 99, 132, 1)' - } - ] - }, - options: { - scales: { - x: { - min: 0, - max: 23, - ticks: { - beginAtZero: true, - stepSize: 1 - } - }, - y: { - min: 0, - max: 6, - ticks: { - beginAtZero: true, - callback: getYTickCallback, - stepSize: 1 - } - } - }, - plugins: { - legend: { - display: false - }, - title: { - display: true, - font: { - size: 18 - }, - text: 'Case Contact Creation Times in the Past Week' - }, - tooltip: { - callbacks: { - label: getTooltipLabelCallback - } - } - } - } - }) -} - -function getYTickCallback (value) { - return days[value] -} - -function getTooltipLabelCallback (context) { - const bubbleData = context.dataset.data[context.dataIndex] - const caseContactCountSqrt = bubbleData.r / 4 - return `${Math.round(caseContactCountSqrt * caseContactCountSqrt)} case contacts created on ${days[bubbleData.y]} at ${bubbleData.x}:00` -} - -function createLineChartForCaseContacts (chartElement, dataset) { - const datasetLabels = ['Total Case Contacts', 'Total Case Contacts with Notes', 'Total Case Contact Users'] - return createLineChart(chartElement, dataset, 'Case Contact Creation', datasetLabels) -} - -function createLineChartForUniqueUsersMonthly (chartElement, dataset) { - const datasetLabels = ['Total Volunteers', 'Total Supervisors', 'Total Admins', 'Total logged Volunteers'] - return createLineChart(chartElement, dataset, 'Monthly Active Users', datasetLabels) -} - -function createLineChart (chartElement, dataset, chartTitle, datasetLabels) { - const ctx = chartElement.getContext('2d') - const allMonths = extractChartData(dataset, 0) - const datasets = [] - const colors = ['#308af3', '#48ba16', '#FF0000', '#FFA500'] - - for (let i = 1; i < dataset[0].length; i++) { - const data = extractChartData(dataset, i) - const label = datasetLabels[i - 1] - const color = colors[i - 1] - datasets.push(createLineChartDataset(label, data, color, color)) - } - - return new Chart(ctx, { - type: 'line', - data: { - labels: allMonths, - datasets - }, - options: createChartOptions(chartTitle) - }) -} - -function extractChartData (dataset, index) { - return dataset.map(data => data[index]) -} - -function createLineChartDataset (label, data, borderColor, pointBackgroundColor) { - return { - label, - data, - fill: false, - borderColor, - pointBackgroundColor, - pointBorderWidth: 2, - pointHoverBackgroundColor: '#fff', - pointHoverBorderWidth: 2, - lineTension: 0.05 - } -} - -function createChartOptions (label) { - return { - legend: { display: true }, - plugins: { - legend: { display: true, position: 'bottom' }, - title: { - display: true, - font: { size: 18 }, - text: label - }, - tooltips: { - callbacks: { - label: function (tooltipItem, data) { - let label = data.datasets[tooltipItem.datasetIndex].label || '' - if (label) { - label += ': ' - } - label += Math.round(tooltipItem.yLabel * 100) / 100 - return label - } - } - } - } - } -} diff --git a/app/models/case_contact.rb b/app/models/case_contact.rb index 3e73ea490e..32a02dc3ba 100644 --- a/app/models/case_contact.rb +++ b/app/models/case_contact.rb @@ -16,8 +16,10 @@ class CaseContact < ApplicationRecord allow_nil: true } # NOTE: 'extra' day is a temporary fix for user selecting current date, but this validation failing + # NOTE: a lambda so the cutoff is evaluated per-validation, not frozen at class-load time (which + # made specs flaky when the class first loaded under travel_to a past date). validates :occurred_at, comparison: { - less_than: Time.zone.tomorrow + 1.day, + less_than: -> { Time.zone.tomorrow + 1.day }, message: :cant_be_future, allow_nil: true } diff --git a/app/models/concerns/name_presentation.rb b/app/models/concerns/name_presentation.rb new file mode 100644 index 0000000000..0f7ffc93db --- /dev/null +++ b/app/models/concerns/name_presentation.rb @@ -0,0 +1,17 @@ +# Shared name-formatting helpers. User display names should never show an +# honorific prefix (Mr./Mrs./Ms./...) anywhere in the app — see User#display_name. +module NamePresentation + HONORIFICS = %w[mr mrs ms miss mx dr prof rev sir madam].freeze + + module_function + + # "Mrs. Hung Bergstrom" -> "Hung Bergstrom"; names without a leading + # honorific are returned unchanged. + def strip_honorific(name) + parts = name.to_s.split(" ") + return name.to_s if parts.size <= 1 + + leading = parts.first.sub(/\.\z/, "").downcase + HONORIFICS.include?(leading) ? parts.drop(1).join(" ") : name.to_s + end +end diff --git a/app/services/admin_dashboard.rb b/app/services/admin_dashboard.rb new file mode 100644 index 0000000000..6b9bdcfc53 --- /dev/null +++ b/app/services/admin_dashboard.rb @@ -0,0 +1,65 @@ +# Builds an admin's org-wide landing dashboard: chapter-level stats plus the cases that +# need action (unassigned, or not contacted recently). Queries are aggregate/batched +# (active cases + one grouped contact lookup + count queries) so this stays cheap at org +# scale — do not reintroduce per-case/per-volunteer queries here. +class AdminDashboard + FOLLOWUP_DAYS = Volunteer::CONTACT_MADE_IN_DAYS_NUM + ATTENTION_LIMIT = 6 + + def initialize(casa_org) + @casa_org = casa_org + end + + def active_cases + @active_cases ||= @casa_org.casa_cases.active.order(:case_number).to_a + end + + def unassigned_cases + @unassigned_cases ||= active_cases.reject { |casa_case| assigned_case_ids.include?(casa_case.id) } + end + + def cases_needing_contact + @cases_needing_contact ||= active_cases.select do |casa_case| + last = last_contacts[casa_case.id] + last.nil? || (Date.current - last.to_date).to_i > FOLLOWUP_DAYS + end + end + + def needs_attention + unassigned_cases.first(ATTENTION_LIMIT) + end + + def empty? + active_cases.empty? && active_volunteers.zero? + end + + def stats + { + volunteers: active_volunteers, + cases: active_cases.size, + unassigned: unassigned_cases.size, + needs_contact: cases_needing_contact.size + } + end + + private + + def active_volunteers + @active_volunteers ||= Volunteer.in_organization(@casa_org).active.count + end + + def assigned_case_ids + @assigned_case_ids ||= CaseAssignment.active + .where(casa_case_id: active_cases.map(&:id)) + .distinct + .pluck(:casa_case_id) + .to_set + end + + def last_contacts + @last_contacts ||= CaseContact + .where(contact_made: true, casa_case_id: active_cases.map(&:id)) + .group(:casa_case_id) + .maximum(:occurred_at) + end +end diff --git a/app/services/supervisor_dashboard.rb b/app/services/supervisor_dashboard.rb new file mode 100644 index 0000000000..fff99a3b90 --- /dev/null +++ b/app/services/supervisor_dashboard.rb @@ -0,0 +1,96 @@ +# Builds the data for a supervisor's landing dashboard: the volunteers assigned to +# them, each volunteer's contact-follow-up status, and the summary stats up top. +# +# NOTE: runs a few queries per volunteer. Fine for a supervisor's assigned volunteers +# (typically a handful); revisit with a batched query (see VolunteerDatatable) if a +# supervisor ever carries a very large roster. +class SupervisorDashboard + AVATAR_COLORS = %w[sky violet emerald amber rose teal indigo].freeze + RECENT_CONTACT_DAYS = 60 + RECENT_HOURS_DAYS = 30 + + Row = Struct.new(:volunteer, :cases_count, :status, :last_contact_on, :contacts_recent, :minutes_recent, keyword_init: true) do + def needs_followup? + status == :follow_up + end + + def no_cases? + status == :no_cases + end + + def avatar_color + AVATAR_COLORS[volunteer.id % AVATAR_COLORS.size] + end + + def last_contact_label + return "No contact logged" unless last_contact_on + + days = (Date.current - last_contact_on.to_date).to_i + case days + when 0 then "Today" + when 1 then "Yesterday" + else "#{days} days ago" + end + end + + def hours_label + return "—" if minutes_recent.zero? + + hours, minutes = minutes_recent.divmod(60) + [("#{hours}h" if hours.positive?), ("#{minutes}m" if minutes.positive?)].compact.join(" ") + end + end + + def initialize(supervisor) + @supervisor = supervisor + end + + def volunteers + @volunteers ||= @supervisor.volunteers.to_a + end + + def rows + @rows ||= volunteers.map { |volunteer| build_row(volunteer) } + end + + def needs_attention + @needs_attention ||= rows.select(&:needs_followup?) + end + + def stats + { + active: rows.size, + needs_followup: needs_attention.size, + no_cases: rows.count(&:no_cases?), + hours_label: format_hours(rows.sum(&:minutes_recent)) + } + end + + private + + def build_row(volunteer) + cases_count = volunteer.actively_assigned_and_active_cases.size + made = CaseContact.where(creator_id: volunteer.id, contact_made: true) + + Row.new( + volunteer: volunteer, + cases_count: cases_count, + status: status_for(volunteer, cases_count), + last_contact_on: made.maximum(:occurred_at), + contacts_recent: made.where(occurred_at: RECENT_CONTACT_DAYS.days.ago.to_date..).count, + minutes_recent: made.where(occurred_at: RECENT_HOURS_DAYS.days.ago.to_date..).sum(:duration_minutes).to_i + ) + end + + def status_for(volunteer, cases_count) + return :no_cases if cases_count.zero? + + volunteer.made_contact_with_all_cases_in_days? ? :on_track : :follow_up + end + + def format_hours(minutes) + return "0h" if minutes.zero? + + "#{(minutes / 60.0).round(1)}h" + end +end diff --git a/app/services/volunteer_dashboard.rb b/app/services/volunteer_dashboard.rb new file mode 100644 index 0000000000..71cdf1fbdf --- /dev/null +++ b/app/services/volunteer_dashboard.rb @@ -0,0 +1,62 @@ +# Builds a volunteer's landing dashboard: their actively-assigned active cases, each +# case's contact-follow-up status, and the summary stats up top. Per-case last-contact +# lookups are batched into one query, so this is safe for a volunteer's roster of cases. +class VolunteerDashboard + RECENT_HOURS_DAYS = 30 + + Row = Struct.new(:casa_case, :last_contact_on, keyword_init: true) do + def needs_contact? + last_contact_on.nil? || (Date.current - last_contact_on.to_date).to_i > Volunteer::CONTACT_MADE_IN_DAYS_NUM + end + + def status + needs_contact? ? :needs_contact : :on_track + end + + def last_contact_label + return "No contact logged" unless last_contact_on + + days = (Date.current - last_contact_on.to_date).to_i + case days + when 0 then "Today" + when 1 then "Yesterday" + else "#{days} days ago" + end + end + end + + def initialize(volunteer) + @volunteer = volunteer + end + + def cases + @cases ||= @volunteer.actively_assigned_and_active_cases.to_a + end + + def rows + @rows ||= cases + .map { |casa_case| Row.new(casa_case: casa_case, last_contact_on: last_contacts[casa_case.id]) } + .sort_by { |row| [row.needs_contact? ? 0 : 1, row.last_contact_on || Date.new(0)] } + end + + def needs_attention + @needs_attention ||= rows.select(&:needs_contact?) + end + + def stats + { + cases: rows.size, + needs_contact: needs_attention.size, + hours_label: @volunteer.hours_spent_in_days(RECENT_HOURS_DAYS).presence || "0h" + } + end + + private + + def last_contacts + @last_contacts ||= CaseContact + .where(creator_id: @volunteer.id, contact_made: true, casa_case_id: cases.map(&:id)) + .group(:casa_case_id) + .maximum(:occurred_at) + end +end diff --git a/app/views/banners/index.html.erb b/app/views/banners/index.html.erb index fdfa86d7f8..c8a92d5141 100644 --- a/app/views/banners/index.html.erb +++ b/app/views/banners/index.html.erb @@ -48,7 +48,7 @@ <%= banner_expiration_time_in_words(banner) %> - <%= banner.user.display_name %> + <%= formatted_name(banner.user.display_name) %> <%= time_ago_in_words(banner.updated_at) %> ago diff --git a/app/views/casa_cases/_case_contact_card.html.erb b/app/views/casa_cases/_case_contact_card.html.erb new file mode 100644 index 0000000000..9ac22c13c4 --- /dev/null +++ b/app/views/casa_cases/_case_contact_card.html.erb @@ -0,0 +1,73 @@ +<%# Tailwind case-contact card for the case-show page. Show-specific (the shared + case_contacts/_case_contact partial is still Bootstrap and used by un-migrated + pages); consolidate when the contacts index migrates. `data-case-contact` is the + count hook the show spec targets. %> +
+
+ + + +
+
+

+ <%= "[DELETE] " if policy(contact).restore? && contact.deleted? %><%= contact.decorate.contact_groups %> +

+ <% unless contact.active? %> + Draft + <% end %> + <% if policy(contact).restore? && contact.deleted? %> + <%= link_to "Undelete", restore_case_contact_path(contact.id), method: :post, data: {turbo: false}, + class: "text-xs font-medium text-brand-600 hover:text-brand-700 focus-visible:underline focus-visible:outline-none" %> + <% end %> +
+

<%= contact.decorate.contact_types %>

+

<%= contact.decorate.subheading %>

+ + <% answers = contact.contact_topic_answers.reject { |answer| answer.value.blank? } %> + <% if answers.any? || contact.notes.present? %> +
+ <% answers.each do |answer| %> +
+
<%= answer.contact_topic.question %>
+
<%= answer.value %>
+
+ <% end %> + <% if contact.notes.present? %> +
+
Additional Notes
+
<%= contact.notes %>
+
+ <% end %> +
+ <% end %> +
+
+ +
+

+ Created by + <% creator = contact.creator %> + <% if policy(contact).edit? && !current_user.volunteer? && creator %> + <% creator_path = creator.supervisor? ? edit_supervisor_path(creator) : (creator.casa_admin? ? edit_users_path : edit_volunteer_path(creator)) %> + <%= link_to formatted_name(creator.display_name), creator_path, data: {turbo: false}, class: "font-medium text-brand-600 hover:text-brand-700" %> + <% else %> + <%= formatted_name(creator&.display_name) %> + <% end %> +

+
+ <% if policy(contact).update? %> + <%= render "casa_cases/case_contact_followup", contact: contact, followup: contact.requested_followup %> + <%= link_to edit_case_contact_path(contact), data: {turbo: false}, + class: "inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500" do %> + Edit + <% end %> + <% end %> + <% if policy(contact).destroy? && !contact.deleted? %> + <%= link_to case_contact_path(contact.id), method: :delete, data: {turbo: false}, + class: "inline-flex items-center gap-1.5 rounded-lg border border-rose-200 bg-white px-3 py-1.5 text-sm font-medium text-rose-700 shadow-sm hover:bg-rose-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500" do %> + Delete + <% end %> + <% end %> +
+
+
diff --git a/app/views/casa_cases/_case_contact_followup.html.erb b/app/views/casa_cases/_case_contact_followup.html.erb new file mode 100644 index 0000000000..9a40bdbf55 --- /dev/null +++ b/app/views/casa_cases/_case_contact_followup.html.erb @@ -0,0 +1,16 @@ +<%# Follow-up control for a case contact on the Tailwind case-show card. Keeps the + .followup-button class + followup-button- id so the existing jQuery handler + (src/case_contact.js) binds the "Make Reminder" prompt. Labels stay Title Case + because system specs click them by exact text. %> +<% if followup %> + <%= button_to resolve_followup_path(followup), method: :patch, id: "resolve", data: {turbo: false}, + form_class: "inline", + class: "inline-flex items-center gap-1.5 rounded-lg bg-emerald-700 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-emerald-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2" do %> + Resolve Reminder + <% end %> +<% else %> + +<% end %> diff --git a/app/views/casa_cases/_court_report_modal.html.erb b/app/views/casa_cases/_court_report_modal.html.erb new file mode 100644 index 0000000000..35d0de1198 --- /dev/null +++ b/app/views/casa_cases/_court_report_modal.html.erb @@ -0,0 +1,40 @@ +<%# Court report generator (design-system dialog + court-report Stimulus controller). + Locals: + casa_case the case (required) + trigger_class classes for the trigger button (required) + wrapper_class wrapper layout (default "inline-flex"; use "contents" in a dropdown menu) %> +<%= render Dialog::GroupComponent.new(id: "generate-court-report", label: "Download court report", controllers: "court-report", wrapper_class: local_assigns.fetch(:wrapper_class, "inline-flex")) do |dialog| %> + <% dialog.with_trigger do %> + + <% end %> + <%= form_with url: generate_case_court_reports_path, local: false, data: {court_report_target: "form"} do |form| %> + <%= render Dialog::HeaderComponent.new(title: "Download court report") %> + <%= render Dialog::BodyComponent.new(classes: "space-y-4") do %> +

Choose a date range for <%= casa_case.case_number %>.

+ + <%= form.hidden_field :case_number, value: casa_case.case_number %> + <%= form.hidden_field :time_zone, data: {court_report_target: "timeZone"} %> +
+
+ <%= form.label :start_date, "Starting from", class: "mb-1.5 block text-sm font-medium text-slate-700" %> + <%= form.date_field :start_date, value: (casa_case.most_recent_past_court_date&.date || Date.current), required: true, + class: "block w-full rounded-lg border border-slate-300 px-3.5 py-2.5 text-sm text-slate-900 shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30 focus:outline-none" %> +
+
+ <%= form.label :end_date, "Ending at", class: "mb-1.5 block text-sm font-medium text-slate-700" %> + <%= form.date_field :end_date, value: Date.current, required: true, + class: "block w-full rounded-lg border border-slate-300 px-3.5 py-2.5 text-sm text-slate-900 shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30 focus:outline-none" %> +
+
+ <% end %> + <%= render Dialog::FooterComponent.new do %> + + + <% end %> + <% end %> +<% end %> diff --git a/app/views/casa_cases/_filter.html.erb b/app/views/casa_cases/_filter.html.erb index aac24684da..9a7c864722 100644 --- a/app/views/casa_cases/_filter.html.erb +++ b/app/views/casa_cases/_filter.html.erb @@ -1,143 +1,50 @@ -
-
-
-

Filter by:

- - - - - +<%# Casa case filter bar (casadesign, bespoke). Clear server-side selects that submit on + change (auto-submit controller; Turbo Drive keeps it smooth). Volunteers never reach + this — the view gates it on can_see_filters?. %> +<% select_class = "block w-full appearance-none rounded-lg border border-slate-300 bg-white py-2 pl-3 pr-9 text-sm text-slate-900 shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30 focus:outline-none" %> +<% label_class = "mb-1 block text-xs font-medium text-slate-500" %> +<% chevron_class = "bi bi-chevron-down pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400" %> + +<%= form_with url: casa_cases_path, method: :get, data: {controller: "auto-submit", action: "change->auto-submit#submit"}, class: "mb-4 grid grid-cols-2 items-end gap-3 lg:flex lg:flex-wrap" do %> +
+ <%= label_tag :search, "Search", class: label_class %> +
+ + <%= search_field_tag :search, params[:search], placeholder: "Search case number or volunteer", autocomplete: "off", class: "block w-full rounded-lg border border-slate-300 bg-white py-2 pl-9 #{params[:search].present? ? 'pr-9' : 'pr-3'} text-sm text-slate-900 shadow-sm placeholder:text-slate-500 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30 focus:outline-none [&::-webkit-search-cancel-button]:hidden" %> + <% if params[:search].present? %> + <%= link_to casa_cases_path(request.query_parameters.except("search", "page")), "aria-label": "Clear search", class: "absolute right-2.5 top-1/2 -translate-y-1/2 grid h-5 w-5 place-items-center rounded text-slate-400 hover:text-slate-600" do %><% end %> + <% end %>
-
- -
-