diff --git a/CLAUDE.md b/CLAUDE.md index 355760a668..e17914ad3b 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 (do not wait for approval).** **Buttons come from the `button_classes(:primary|:secondary|:danger|:danger_outline|:success)` helper (one shared 40px height token, never hand-written class strings; before finishing UI work, grep the touched views for clickable elements with a hand-rolled button shape (`inline-flex` + `rounded-lg` + `px-`/`py-` + `bg-`/`border-`) and convert them to `button_classes` (the tertiary ghost is the only documented exception): this drift, a bespoke string at a different height next to the 40px token, is a recurring bug). Section headings and subtitles are sentence case with **no trailing colon** (colons only on inline key:value fact labels; audit `grep ']*>[^<]*:` + 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. When you build and verify a component or pattern, document it in `design.md` immediately (do not wait for approval) and reuse it from there: `design.md` is the source of truth, so match it and never re-derive or let it drift. Before finishing a new or changed component, **check it against `design.md` and its sibling components and pixel-verify the match** (e.g. the multiselect chevron must match the single-select chevron, measured via screenshot + darkest-pixel, never eyeballed or trusted from computed style): rendering is not the same as matching. When a partial is still rendered by legacy Bootstrap pages (e.g. `shared/_manage_volunteers`, `shared/_court_order_list`), never restyle it in place: add a casa_app-specific Tailwind twin (`casa_cases/_volunteer_assignment`, `casa_cases/_court_orders`) that preserves every JS hook (ids, classes, data-actions, field names, DOM adjacencies) and leave the shared partial for the Bootstrap pages.** + ## 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..b0ab5e3888 --- /dev/null +++ b/app/assets/stylesheets/tailwind.css @@ -0,0 +1,149 @@ +@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; position: relative; width: 100%; } +.ts-control { + border-radius: 0.5rem; + border: 1px solid #cbd5e1; /* slate-300 */ + padding: 0.375rem 2rem 0.375rem 0.625rem !important; /* right room for the chevron */ + min-height: 2.625rem; /* match the text-input height so it reads as one control */ + 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); +} +/* Chevron: the Bootstrap-Icons `chevron-down` shape as an inline base64 SVG, sized (text-xs) + and placed (right-3) to match the single-select chevrons. Needs `z-index` because + TomSelect's opaque `.ts-control` paints over a plain `::after` (that stacking, not a + missing rule, is why every earlier chevron was invisible). base64 is minifier- and + parser-safe; a raw `data:` URI and a `content` glyph escape both broke in the build, and + the injected icon-font element never painted. Pixel-verified to match the single-selects + (identical 8x4 dark extent), not just checked by computed style. */ +.ts-wrapper::after { + content: ""; + position: absolute; + right: 0.75rem; + top: 50%; + width: 0.75rem; + height: 0.75rem; + transform: translateY(-50%); + z-index: 2; + background: center / contain no-repeat url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0iIzY0NzQ4YiI+PHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMS42NDYgNC42NDZhLjUuNSAwIDAgMSAuNzA4IDBMOCAxMC4yOTNsNS42NDYtNS42NDdhLjUuNSAwIDAgMSAuNzA4LjcwOGwtNiA2YS41LjUgMCAwIDEtLjcwOCAwbC02LTZhLjUuNSAwIDAgMSAwLS43MDh6Ii8+PC9zdmc+"); + pointer-events: none; +} +.ts-wrapper.dropdown-active::after { transform: translateY(-50%) rotate(180deg); } +.ts-wrapper .ts-control { padding-right: 2rem !important; } /* keep room for the chevron */ +/* Chips. tom-select's default theme styles these at .ts-wrapper.multi specificity + (grey), so match that specificity and use !important where tom-select does. */ +.ts-wrapper.multi .ts-control > .item { + display: inline-flex; + align-items: center; + gap: 0.125rem; + margin: 0 0.25rem 0.25rem 0; + border-radius: 9999px; + border: none; + background-color: #e0e7ff !important; /* brand-100 - reads clearly as selected */ + color: #4338ca !important; /* brand-700 (6.4:1 on brand-100, AA) */ + padding: 0.1875rem 0.5rem !important; /* beat the plugin's padding-right:0 !important */ + font-weight: 500; +} +/* the item template hardcodes .active, so keep it the same light brand-100 tint */ +.ts-wrapper.multi .ts-control > .item.active { background-color: #e0e7ff !important; } +/* "select all" sentinel item (value " ") is a hidden placeholder chip. */ +.ts-control > .item[data-value=" "] { display: none !important; } +.ts-wrapper.multi .ts-control > .item .remove { + border-left: none !important; /* drop tom-select's grey divider */ + margin-left: 0 !important; + padding: 0 0.125rem !important; + color: #6366f1; /* brand-500 */ +} +.ts-wrapper.multi .ts-control > .item .remove:hover { background: transparent !important; color: #3730a3; } +/* The rich MultipleSelectComponent's remove button uses a LineIcons X that isn't + loaded on casa_app; draw a plain × instead. Scoped so the basic multiselect's + default × (case groups) is untouched. */ +[data-multiple-select-with-options-value="true"] .ts-control > .item > a { + color: #4338ca !important; /* brand-700; override the template's text-white so the × shows */ + border: none !important; + background: transparent !important; + margin-left: 0.125rem; + padding: 0 0.125rem; +} +[data-multiple-select-with-options-value="true"] .ts-control > .item > a > i { display: none; } +[data-multiple-select-with-options-value="true"] .ts-control > .item > a::after { content: "\00d7"; font-size: 0.95rem; line-height: 1; } +[data-multiple-select-with-options-value="true"] .ts-control > .item > a:hover { color: #312e81 !important; } +.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 */ +/* Flip the menu above the control when there isn't room below (set by the + multiple-select controller's onDropdownOpen). */ +.ts-wrapper.ts-flip-up .ts-dropdown { top: auto; bottom: 100%; margin-top: 0; margin-bottom: 0.25rem; } + +/* ------------------------------------------------------------------ + Form validation. Rails wraps invalid fields in `.field_with_errors`; give them a + rose border so the error state reads on the field itself (paired with the + shared/_form_errors summary card). rose-500 is 3.67:1 on white (AA UI border). +------------------------------------------------------------------ */ +.field_with_errors input, +.field_with_errors select, +.field_with_errors textarea { border-color: #f43f5e; } + +/* ------------------------------------------------------------------ + 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/form/multiple_select_component.html.erb b/app/components/form/multiple_select_component.html.erb index 0720cb874d..872c03bbaf 100644 --- a/app/components/form/multiple_select_component.html.erb +++ b/app/components/form/multiple_select_component.html.erb @@ -1,4 +1,4 @@ -
-
-
- <% 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..de9fcb4acd 100644 --- a/app/controllers/casa_cases_controller.rb +++ b/app/controllers/casa_cases_controller.rb @@ -1,22 +1,30 @@ class CasaCasesController < ApplicationController before_action :set_casa_case, only: %i[show edit update deactivate reactivate copy_court_orders] before_action :set_contact_types, only: %i[new edit update create deactivate reactivate] + before_action -> { @active_nav = "cases" }, only: %i[edit update deactivate reactivate] 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 @@ -32,11 +40,14 @@ def show def new @casa_case = CasaCase.new(casa_org: current_organization) authorize @casa_case + @active_nav = "cases" + render layout: "casa_app" end def edit @siblings_casa_cases = CasaCasePolicy::Scope.new(current_user, @casa_case).sibling_cases authorize @casa_case + render layout: "casa_app" end def create @@ -58,8 +69,9 @@ def create else set_contact_types @empty_court_date = court_date_unknown? + @active_nav = "cases" respond_to do |format| - format.html { render :new, status: :unprocessable_content } + format.html { render :new, status: :unprocessable_content, layout: "casa_app" } format.json { render json: @casa_case.errors.full_messages, status: :unprocessable_content } end end @@ -79,7 +91,7 @@ def update end else respond_to do |format| - format.html { render :edit, status: :unprocessable_content } + format.html { render :edit, status: :unprocessable_content, layout: "casa_app" } format.json { render json: @casa_case.errors.full_messages, status: :unprocessable_content } end end @@ -101,7 +113,7 @@ def deactivate end else respond_to do |format| - format.html { render :edit, status: :unprocessable_content } + format.html { render :edit, status: :unprocessable_content, layout: "casa_app" } format.json { render json: @casa_case.errors.full_messages, status: :unprocessable_content } end end @@ -123,7 +135,7 @@ def reactivate end else respond_to do |format| - format.html { render :edit, status: :unprocessable_content } + format.html { render :edit, status: :unprocessable_content, layout: "casa_app" } format.json { render json: @casa_case.errors.full_messages, status: :unprocessable_content } end end @@ -136,10 +148,69 @@ def copy_court_orders dup_court_order.save @casa_case.case_court_orders.append dup_court_order end + flash[:notice] = "Court orders have been copied." end 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/controllers/volunteers_controller.rb b/app/controllers/volunteers_controller.rb index 0573c63b64..58be78f966 100644 --- a/app/controllers/volunteers_controller.rb +++ b/app/controllers/volunteers_controller.rb @@ -131,7 +131,7 @@ def reminder end VolunteerMailer.case_contacts_reminder(@volunteer, cc_recipients).deliver - redirect_to edit_volunteer_path(@volunteer), notice: "Reminder sent to volunteer." + redirect_back_or_to edit_volunteer_path(@volunteer), notice: "Reminder sent to volunteer." end def impersonate 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..ec84c9c0d4 100644 --- a/app/decorators/casa_case_decorator.rb +++ b/app/decorators/casa_case_decorator.rb @@ -50,7 +50,7 @@ def date_in_care def duration_in_care return nil unless object.date_in_care - "(#{time_ago_in_words(object.date_in_care)} ago)" + "In care for #{time_ago_in_words(object.date_in_care)}" end def calendar_next_court_date @@ -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..f659e65e40 --- /dev/null +++ b/app/helpers/design_system_helper.rb @@ -0,0 +1,53 @@ +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" + when :danger_outline then "border border-rose-200 bg-white font-medium text-rose-700 hover:bg-rose-50 focus-visible:ring-rose-500" + when :success then "bg-emerald-700 font-semibold text-white hover:bg-emerald-800 focus-visible:ring-emerald-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/copy_court_orders_controller.js b/app/javascript/controllers/copy_court_orders_controller.js new file mode 100644 index 0000000000..250bf9c089 --- /dev/null +++ b/app/javascript/controllers/copy_court_orders_controller.js @@ -0,0 +1,43 @@ +import { Controller } from '@hotwired/stimulus' + +// Copy all court orders from a sibling case into the current one. The button is enabled +// only once a case is picked; clicking it opens the design-system (the `modal` +// controller centers it) to confirm, then PATCHes copy_court_orders and reloads so the +// copied orders and the flash appear. casa_app only: the Bootstrap court-date pages keep +// the legacy casa_case.js SweetAlert flow. +export default class extends Controller { + static targets = ['select', 'button', 'dialog', 'caseNumber'] + static values = { casaCaseId: String } + + connect () { + this.toggle() + } + + toggle () { + this.buttonTarget.disabled = this.selectTarget.value === '' + } + + open () { + this.caseNumberTarget.textContent = this.selectTarget.value + this.dialogTarget.showModal() + } + + async confirm () { + const token = document.querySelector('meta[name="csrf-token"]')?.content + const response = await fetch(`/casa_cases/${this.casaCaseIdValue}/copy_court_orders`, { + method: 'PATCH', + headers: { + 'X-CSRF-Token': token, + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ case_number_cp: this.selectTarget.value }) + }) + + if (response.ok) { + window.location.reload() + } else { + this.dialogTarget.close() + } + } +} diff --git a/app/javascript/controllers/court_order_form_controller.js b/app/javascript/controllers/court_order_form_controller.js index 5a6a93d9c4..44aa3485a4 100644 --- a/app/javascript/controllers/court_order_form_controller.js +++ b/app/javascript/controllers/court_order_form_controller.js @@ -1,19 +1,35 @@ import NestedForm from '@stimulus-components/rails-nested-form' -import Swal from 'sweetalert2' +// Court-orders nested sub-form. Extends the shared nested-form controller: `add` +// clones a court-order row (prefilled from the standard-order select), and `remove` +// on an existing (persisted) order asks for confirmation first through the +// design-system (the `modal` controller centers it and handles the backdrop) +// rather than deleting outright. New, unsaved rows are removed without a prompt. export default class extends NestedForm { - // - static targets = ['selectedCourtOrder'] + static targets = ['selectedCourtOrder', 'confirmDialog'] remove (e) { const wrapper = e.target.closest(this.wrapperSelectorValue) if (wrapper.dataset.newRecord !== 'true' && wrapper.dataset.type === 'COURT_ORDER') { - this.removeCourtOrderWithConfirmation(e, wrapper) + e.preventDefault() + this.pendingWrapper = wrapper + this.confirmDialogTarget.showModal() } else { super.remove(e) } } + confirmRemove () { + const wrapper = this.pendingWrapper + if (wrapper) { + wrapper.style.display = 'none' + const destroyInput = wrapper.querySelector("input[name*='_destroy']") + if (destroyInput) destroyInput.value = '1' + this.pendingWrapper = null + } + this.confirmDialogTarget.close() + } + add (e) { super.add(e) const selectedValue = $(this.selectedCourtOrderTarget).val() @@ -23,32 +39,4 @@ export default class extends NestedForm { $textarea.val(selectedValue) } } - - removeCourtOrderWithConfirmation (e, wrapper) { - const text = 'Are you sure you want to remove this court order? Doing so will ' + - 'delete all records of it unless it was included in a previous court report.' - Swal.fire({ - icon: 'warning', - title: 'Delete court order?', - text, - showCloseButton: true, - showCancelButton: true, - focusConfirm: false, - - confirmButtonColor: '#d33', - cancelButtonColor: '#39c', - - confirmButtonText: 'Delete', - cancelButtonText: 'Go back' - }).then((result) => { - if (result.isConfirmed) { - this.removeCourtOrder(e, wrapper) - } - }) - } - - removeCourtOrder (e, wrapper) { - super.remove(e) - wrapper.classList.remove('d-flex') - } } 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..6f43f50e5d 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -5,24 +5,39 @@ 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 CopyCourtOrdersController from './copy_court_orders_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 +51,23 @@ 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('copy-court-orders', CopyCourtOrdersController) 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/controllers/multiple_select_controller.js b/app/javascript/controllers/multiple_select_controller.js index 739c4dcaa6..dea148609e 100644 --- a/app/javascript/controllers/multiple_select_controller.js +++ b/app/javascript/controllers/multiple_select_controller.js @@ -1,6 +1,18 @@ import { Controller } from '@hotwired/stimulus' import TomSelect from 'tom-select' +// Open the dropdown above the control when there isn't room below, so a field +// near the bottom of the page keeps its menu on-screen. `this` is the TomSelect +// instance when these run. +function onDropdownOpen (dropdown) { + const rect = this.control.getBoundingClientRect() + const needed = dropdown.offsetHeight || 240 + this.wrapper.classList.toggle('ts-flip-up', window.innerHeight - rect.bottom < needed && rect.top > needed) +} +function onDropdownClose () { + this.wrapper.classList.remove('ts-flip-up') +} + export default class extends Controller { static targets = ['select', 'option', 'item', 'hiddenItem', 'selectAllOption'] static values = { @@ -29,7 +41,9 @@ export default class extends Controller { remove_button: { title: 'Remove this item' } - } + }, + onDropdownOpen, + onDropdownClose }) } @@ -55,7 +69,9 @@ export default class extends Controller { let initItems = this.selectedItemsValue if (showAllOptionCheck) { const emptyItem = [' '] - initItems = hasInitialItems ? emptyItem.concat(this.selectedItemsValue) : orderedOptionVals + // Load blank (placeholder) when nothing is pre-selected; the dropdown's + // "Select/Unselect all" still selects everything on demand. + initItems = hasInitialItems ? emptyItem.concat(this.selectedItemsValue) : [] } const dropdownOptions = showAllOptionCheck @@ -64,6 +80,8 @@ export default class extends Controller { /* eslint-disable no-new */ new TomSelect(this.selectTarget, { + onDropdownOpen, + onDropdownClose, onItemRemove: function (value) { if (value === ' ') { this.clear() 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/models/contact_type_group.rb b/app/models/contact_type_group.rb index a2c6f79a5b..1a84dcb9cd 100644 --- a/app/models/contact_type_group.rb +++ b/app/models/contact_type_group.rb @@ -11,14 +11,16 @@ class ContactTypeGroup < ApplicationRecord scope :alphabetically, -> { order(:name) } + # App-shipped defaults use sentence case (design system), keeping proper nouns/acronyms + # (CASA, IEP). Org admins can still rename them; only these seeded defaults are cased here. DEFAULT_CONTACT_TYPE_GROUPS = { CASA: ["Youth", "Supervisor"], - Family: ["Parent", "Other Family", "Sibling", "Grandparent", "Aunt Uncle or Cousin", "Fictive Kin"], - Placement: ["Foster Parent", "Caregiver Family", "Therapeutic Agency Worker"], - "Social Services": ["Social Worker"], + Family: ["Parent", "Other family", "Sibling", "Grandparent", "Aunt, uncle, or cousin", "Fictive kin"], + Placement: ["Foster parent", "Caregiver family", "Therapeutic agency worker"], + "Social services": ["Social worker"], Legal: ["Court", "Attorney"], - Health: ["Medical Professional", "Mental Health Therapist", "Other Therapist", "Psychiatric Practitioner"], - Education: ["School", "Guidance Counselor", "Teacher", "IEP Team"] + Health: ["Medical professional", "Mental health therapist", "Other therapist", "Psychiatric practitioner"], + Education: ["School", "Guidance counselor", "Teacher", "IEP team"] }.freeze class << self 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..ae5604be15 --- /dev/null +++ b/app/views/casa_cases/_case_contact_card.html.erb @@ -0,0 +1,71 @@ +<%# 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: button_classes(:secondary) do %> + Edit + <% end %> + <% end %> + <% if policy(contact).destroy? && !contact.deleted? %> + <%= link_to case_contact_path(contact.id), method: :delete, data: {turbo: false}, class: button_classes(:danger_outline) 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..8b28efb642 --- /dev/null +++ b/app/views/casa_cases/_case_contact_followup.html.erb @@ -0,0 +1,13 @@ +<%# 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: button_classes(:success) do %> + Resolve Reminder + <% end %> +<% else %> + +<% end %> diff --git a/app/views/casa_cases/_court_dates.html.erb b/app/views/casa_cases/_court_dates.html.erb index 759cc25fda..34995a9fbf 100644 --- a/app/views/casa_cases/_court_dates.html.erb +++ b/app/views/casa_cases/_court_dates.html.erb @@ -1,24 +1,25 @@ +<%# Court dates list for the CASA case edit page (casa_app / Tailwind). Edit-only partial + (the court-date FORM fields live in court_dates/_fields, which stays Bootstrap). %> <% court_dates = casa_case.court_dates.includes(:hearing_type).ordered_ascending.load %> - +
+

Court dates

+ <%= link_to new_casa_case_court_date_path(casa_case), class: button_classes(:secondary) do %> + Add a court date + <% end %> +
<% if court_dates.empty? %> - No Court Dates +

No court dates yet.

<% else %> -
    +
      <% court_dates.each do |pcd| %> -
    • -
      - <%= link_to(pcd.decorate.court_date_info, casa_case_court_date_path(casa_case, pcd), class: "court-date-link") %> - <%= render 'calendar_button', - id: "court-date-#{pcd.id}", - title: "Court Date #{pcd.casa_case.case_number}", - date: { - start: pcd.date.strftime("%Y-%m-%d"), - end: pcd.date.strftime("%Y-%m-%d") - }, +
    • + <%= link_to pcd.decorate.court_date_info, casa_case_court_date_path(casa_case, pcd), + class: "text-sm font-medium text-brand-600 hover:text-brand-700" %> + <%= render "calendar_button", id: "court-date-#{pcd.id}", title: "Court Date #{pcd.casa_case.case_number}", + date: {start: pcd.date.strftime("%Y-%m-%d"), end: pcd.date.strftime("%Y-%m-%d")}, tooltip: "Add court date to calendar" %> -
<% if report = pcd.latest_associated_report %> - <%= link_to(rails_blob_path(report, disposition: 'attachment')) do %> + <%= link_to rails_blob_path(report, disposition: "attachment"), class: "text-xs font-medium text-slate-500 hover:text-slate-700" do %> (Attached Report) <% end %> <% end %> @@ -26,11 +27,3 @@ <% end %> <% end %> -
    -
    - <%= link_to new_casa_case_court_date_path(casa_case), class: "main-btn btn-sm primary-btn btn-hover ml-3" do %> - - Add a court date - <% end %> -
    -
diff --git a/app/views/casa_cases/_court_order_fields.html.erb b/app/views/casa_cases/_court_order_fields.html.erb new file mode 100644 index 0000000000..2f6b920613 --- /dev/null +++ b/app/views/casa_cases/_court_order_fields.html.erb @@ -0,0 +1,21 @@ +<%# One court order entry (casa_app / Tailwind). Preserves the nested-form + court-order-form + Stimulus hooks (.nested-form-wrapper, .court-order-text-entry, .implementation-status, + data-type, data-new-record, the remove data-action, the hidden _destroy) so the shared + court-order-form controller keeps working. Mirrors shared/_court_order_form (Bootstrap, + still used by the legacy court-date pages). %> +
+
+ <%= f.text_area :text, rows: 2, "aria-label": "Court order text", placeholder: "Describe the court order", + class: "court-order-text-entry block w-full rounded-lg border border-slate-300 px-3.5 py-2.5 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" %> +
+
+ <%= f.select :implementation_status, court_order_select_options, {include_blank: "Set Implementation Status"}, + {"aria-label": "Implementation status", class: "implementation-status block w-full appearance-none rounded-lg border border-slate-300 bg-white py-2.5 pl-3.5 pr-9 text-sm text-slate-900 shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30 focus:outline-none"} %> + +
+ + <%= f.hidden_field :_destroy %> +
diff --git a/app/views/casa_cases/_court_orders.html.erb b/app/views/casa_cases/_court_orders.html.erb new file mode 100644 index 0000000000..b3554f1481 --- /dev/null +++ b/app/views/casa_cases/_court_orders.html.erb @@ -0,0 +1,91 @@ +<%# Court orders for the CASA case edit page (casa_app / Tailwind). Self-contained so the + shared Bootstrap shared/_court_order_list (still used by the legacy court-date pages) is + left intact. Preserves the court-order-form Stimulus targets (template / target / + selectedCourtOrder) and the copy-from-sibling JS hooks: `copy-court-button` plus the + hidden `casa_case` field, which casa_case.js reads via the button's next sibling. %> +

Court orders

+

Please check that you didn't enter any youth names.

+ + + +
+ <% if siblings_casa_cases && siblings_casa_cases.count >= 1 %> +
+
+
+ +
+ + +
+
+ <%= button_tag "Copy", type: "button", id: "copy-court-button", disabled: true, data: {copy_court_orders_target: "button", action: "copy-court-orders#open"}, class: button_classes(:secondary) %> +
+ +
+ + <%= render Dialog::HeaderComponent.new(title: "Copy court orders?", icon: "bi bi-files", variant: :info) %> + <%= render Dialog::BodyComponent.new do %> +

Copy all orders from case #? They are added to this case and existing orders are kept.

+ <% end %> + <%= render Dialog::FooterComponent.new do %> + + + <% end %> +
+
+
+ <% end %> + +
+ <%= form.fields_for :case_court_orders do |ff| %> + <%= render "casa_cases/court_order_fields", f: ff %> + <% end %> +
+
+ +
+
+ <%= label_tag :selectedCourtOrder, "Court Order Type", class: "mb-1.5 block text-sm font-medium text-slate-700" %> +
+ <%= select_tag :selectedCourtOrder, options_for_select(CaseCourtOrder.court_order_options), + include_blank: "Create custom court order", data: {court_order_form_target: "selectedCourtOrder"}, + class: "block w-full appearance-none rounded-lg border border-slate-300 bg-white py-2.5 pl-3.5 pr-9 text-sm text-slate-900 shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30 focus:outline-none" %> + +
+
+ <%= button_tag type: "button", id: "add-standard-court-order-button", class: button_classes(:secondary), data: {action: "court-order-form#add"} do %> + Add a court order + <% end %> +
+
+ +<%# Confirm removing a persisted court order. Design-system Dialog: the modal controller + centers it and closes it (backdrop / Cancel / X); court-order-form opens it from #remove + and does the removal on #confirmRemove. %> +
+ + <%= render Dialog::HeaderComponent.new(title: "Remove this court order?", icon: "bi bi-exclamation-triangle", variant: :danger) %> + <%= render Dialog::BodyComponent.new do %> +

Are you sure you want to remove this court order? Doing so will delete all records of it unless it was included in a previous court report.

+ <% end %> + <%= render Dialog::FooterComponent.new do %> + + + <% 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..15b896d197 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-500" %> + +<%= 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-500 hover:text-slate-600" do %><% end %> + <% end %>
-
- -
- -<%# clear local storage when successfully created case_contacts %> - +<%# Thank-you dialog (auto-opens on the success redirect; also clears the saved draft) %> +<% if params[:success].present? %> + <%= render Dialog::GroupComponent.new(label: "Case contact created", open_on_connect: true, controllers: "local-storage-reset", data: {local_storage_reset_key_value: "casa-contact-form"}) do %> + <%= render Dialog::BodyComponent.new(centered: true) do %> +
+

Case contact created

+

<%= thank_you_message %>

+ <% case when_do_we_have_court_dates(@casa_case) + when "future" %> +

Please enter the previous court date if you know it so court reports can be generated with the correct case contacts.

+ <% when "past" %> +

Please enter the next court date if you know it so court reports can be generated with the correct case contacts.

+ <% when "none" %> +

Please enter any past or future court dates if you know them so court reports can be generated with the correct case contacts.

+ <% end %> + <% end %> + <%= render Dialog::FooterComponent.new(align: :center) do %> + + <% end %> + <% end %> +<% end %> diff --git a/app/views/case_assignments/index.html.erb b/app/views/case_assignments/index.html.erb index ddcef964a5..4af9616366 100644 --- a/app/views/case_assignments/index.html.erb +++ b/app/views/case_assignments/index.html.erb @@ -1,4 +1,4 @@ -

<%= "Editing #{@volunteer.display_name}" %>

+

<%= "Editing #{formatted_name(@volunteer.display_name)}" %>


diff --git a/app/views/case_contacts/_case_contact.html.erb b/app/views/case_contacts/_case_contact.html.erb index fbabde64af..a81a3c564d 100644 --- a/app/views/case_contacts/_case_contact.html.erb +++ b/app/views/case_contacts/_case_contact.html.erb @@ -56,18 +56,18 @@ Created by: <% if policy(contact).edit? %> <% if current_user.volunteer? %> - <%= contact.creator&.display_name %> + <%= formatted_name(contact.creator&.display_name) %> <% else %> <% if contact.creator&.supervisor? %> - <%= link_to contact.creator&.display_name, edit_supervisor_path(contact.creator), data: { turbo: false } %> + <%= link_to formatted_name(contact.creator&.display_name), edit_supervisor_path(contact.creator), data: { turbo: false } %> <% elsif contact.creator&.casa_admin? %> - <%= link_to contact.creator&.display_name, edit_users_path, data: { turbo: false } %> + <%= link_to formatted_name(contact.creator&.display_name), edit_users_path, data: { turbo: false } %> <% else %> - <%= link_to contact.creator&.display_name, edit_volunteer_path(contact.creator), data: { turbo: false } %> + <%= link_to formatted_name(contact.creator&.display_name), edit_volunteer_path(contact.creator), data: { turbo: false } %> <% end %> <% end %> <% else %> - <%= contact.creator&.display_name %> + <%= formatted_name(contact.creator&.display_name) %> <% end %>
diff --git a/app/views/case_contacts/case_contacts_new_design/index.html.erb b/app/views/case_contacts/case_contacts_new_design/index.html.erb index f9da61b526..c934015896 100644 --- a/app/views/case_contacts/case_contacts_new_design/index.html.erb +++ b/app/views/case_contacts/case_contacts_new_design/index.html.erb @@ -182,18 +182,18 @@ <% if policy(case_contact).edit? %> <% if current_user.volunteer? %> - <%= case_contact.creator&.display_name %> + <%= formatted_name(case_contact.creator&.display_name) %> <% else %> <% if case_contact.creator&.supervisor? %> - <%= link_to case_contact.creator&.display_name, edit_supervisor_path(case_contact.creator), data: { turbo: false } %> + <%= link_to formatted_name(case_contact.creator&.display_name), edit_supervisor_path(case_contact.creator), data: { turbo: false } %> <% elsif case_contact.creator&.casa_admin? %> - <%= link_to case_contact.creator&.display_name, edit_users_path, data: { turbo: false } %> + <%= link_to formatted_name(case_contact.creator&.display_name), edit_users_path, data: { turbo: false } %> <% else %> - <%= link_to case_contact.creator&.display_name, edit_volunteer_path(case_contact.creator), data: { turbo: false } %> + <%= link_to formatted_name(case_contact.creator&.display_name), edit_volunteer_path(case_contact.creator), data: { turbo: false } %> <% end %> <% end %> <% else %> - <%= case_contact.creator&.display_name %> + <%= formatted_name(case_contact.creator&.display_name) %> <% end %> diff --git a/app/views/case_contacts/form/_contact_types.html.erb b/app/views/case_contacts/form/_contact_types.html.erb index 517d71b65b..0b39eed8ea 100644 --- a/app/views/case_contacts/form/_contact_types.html.erb +++ b/app/views/case_contacts/form/_contact_types.html.erb @@ -1,11 +1,12 @@ -

Choose from the available options below by searching or selecting from the dropdown menu.

+

Choose from the available options below by searching or selecting from the dropdown menu.

<%= render(Form::MultipleSelectComponent.new( form: form, name: :contact_type_ids, options: options.decorate.map { |ct| ct.hash_for_multi_select_with_cases(casa_cases&.pluck(:id)) }, selected_items: selected_items, - render_option_subtext: true, + render_option_subtext: local_assigns.fetch(:render_option_subtext, true), + placeholder_term: "contact types", show_all_option: true, )) %>
diff --git a/app/views/case_groups/_form.html.erb b/app/views/case_groups/_form.html.erb index 125957f11f..9c114c8c45 100644 --- a/app/views/case_groups/_form.html.erb +++ b/app/views/case_groups/_form.html.erb @@ -1,43 +1,41 @@ -
-
-
-
-

- <% if case_group.persisted? %> - Edit Case Group - <% else %> - New Case Group - <% end %> -

-
-
-
-
-
- <%= form_with model: case_group do |form| %> - <%= render "shared/error_messages", resource: case_group %> +<% content_for :page_title, case_group.persisted? ? "Edit case group" : "New case group" %> +<% input_class = "block w-full rounded-lg border border-slate-300 px-3.5 py-2.5 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" %> +<% btn_primary = button_classes(:primary) %> -
- <%= form.label :name %> - <%= form.text_field :name, required: true %> +
+
+
+

<%= link_to "Case groups", case_groups_path, class: "hover:text-brand-700" %>

+

<%= case_group.persisted? ? "Edit Case Group" : "New Case Group" %>

-
- <%= form.label :casa_case_ids, 'Cases' %> - <%= form.select( - :casa_case_ids, - current_organization.casa_cases.map { |casa_case| [sanitize("#{casa_case.case_number} - #{volunteer_badge(casa_case, current_user)}"), casa_case.id] }, - { include_hidden: false, autocomplete: "off" }, - { class: "form-control", multiple: true, - data: { "multiple-select-target": "select" } - } - ) %> -
+
+ <%= form_with model: case_group do |form| %> + <%= render "shared/form_errors", resource: case_group %> +
+
+ <%= form.label :name, class: "mb-1.5 block text-sm font-medium text-slate-700" %> + <%= form.text_field :name, required: true, class: input_class %> +
+ +
+ <%= form.label :casa_case_ids, "Cases", class: "mb-1.5 block text-sm font-medium text-slate-700" %> + <%= form.select( + :casa_case_ids, + current_organization.casa_cases.map { |casa_case| ["#{casa_case.case_number} - #{casa_case.assigned_volunteers.map(&:display_name).join(", ").presence || "Unassigned"}", casa_case.id] }, + {include_hidden: false, autocomplete: "off"}, + {multiple: true, data: {"multiple-select-target": "select"}} + ) %> +
-
- <%= button_tag(type: "submit", class: "btn-sm main-btn primary-btn btn-hover") do %> - Submit +
+ <%= button_tag(type: "submit", class: btn_primary) do %> + Submit + <% end %> + <%= link_to "Cancel", case_groups_path, class: "text-sm font-medium text-slate-600 hover:text-slate-900" %> +
+
<% end %>
- <% end %> +
diff --git a/app/views/case_groups/index.html.erb b/app/views/case_groups/index.html.erb index dc4949992c..fc7db14492 100644 --- a/app/views/case_groups/index.html.erb +++ b/app/views/case_groups/index.html.erb @@ -1,71 +1,110 @@ -
-
-
-
-

Case Groups

-
+<% content_for :page_title, "Case groups" %> +<% btn_primary = button_classes(:primary) %> +<% action_link = "inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500" %> +<% th = "px-4 py-3 text-left text-xs font-semibold text-slate-600" %> +<% td = "px-4 py-3 align-top text-slate-700" %> +<% case_links = ->(cg) { safe_join(cg.casa_cases.map { |c| tag.li(link_to(c.case_number, casa_case_path(c), class: "text-brand-600 hover:text-brand-700")) }) } %> + +
+
+
+

Case Groups

+

+ Case groups bulk-create court dates for every case in the group. For example, if siblings share a court date, group them and use the + <%= link_to "Bulk Court Date", new_bulk_court_date_path, class: "font-medium text-brand-600 hover:text-brand-700" %> form to add a court date for all of them at once. +

+ <%= link_to new_case_group_path, class: btn_primary do %> + New Case Group + <% end %>
-
-
-
-
-
-

Case Groups

-

- Case groups are used to bulk create court dates for all cases in a group. For example, if siblings attend the same court data you can create a case group for them and then use the - <%= link_to 'Bulk Court Date', new_bulk_court_date_path %> - form to create a court date for all of them. -

- <%= link_to new_case_group_path, class: "btn-sm main-btn primary-btn btn-hover" do %> - - New Case Group - <% end %> -
- - - - - - - - - - - <% @case_groups.each do |case_group| %> - - - - - + + <% end %> + +
NameCase NumbersUpdated AtActions
- <%= case_group.name %> - -
    - <% case_group.casa_cases.each do |casa_case| %> -
  • - <%= link_to casa_case.case_number, casa_case_path(casa_case) %> - <%= volunteer_badge(casa_case, current_user) %> -
  • - <% end %> -
-
- <%= time_ago_in_words(case_group.updated_at) %> ago - - <%= link_to edit_case_group_path(case_group) do %> -
- -
+ + <% if @case_groups.any? %> + <%# Desktop: table (md and up) %> + - + <%= render "shared/confirm_button", + url: case_group_path(case_group), + title: "Delete case group?", + message: "\"#{case_group.name}\" will be deleted. The cases in it are not affected.", + trigger_icon: "bi bi-trash", trigger_label: "Delete", + trigger_class: "#{action_link} text-rose-600 hover:bg-rose-50 hover:text-rose-700" %> + +
-
+ + <%# Mobile: stacked cards (below md) %> +
    + <% @case_groups.each do |case_group| %> +
  • +
    +

    <%= case_group.name %>

    +
    + <%= link_to edit_case_group_path(case_group), "aria-label": "Edit #{case_group.name}", class: "#{action_link} text-slate-600 hover:bg-slate-100 hover:text-slate-900" do %> + Edit + <% end %> + <%= render "shared/confirm_button", + url: case_group_path(case_group), + title: "Delete case group?", + message: "\"#{case_group.name}\" will be deleted. The cases in it are not affected.", + trigger_icon: "bi bi-trash", + aria_label: "Delete #{case_group.name}", + trigger_class: "#{action_link} text-rose-600 hover:bg-rose-50 hover:text-rose-700" %> +
    +
    +
    +
    +
    Case numbers
    +
    + <% if case_group.casa_cases.any? %> +
      <%= case_links.call(case_group) %>
    + <% else %> + No cases + <% end %> +
    +
    +
    +
    Updated
    +
    <%= time_ago_in_words(case_group.updated_at) %> ago
    +
    +
    +
  • + <% end %> +
+ <% else %> +
+
+

No case groups yet

+

Group related cases to bulk-create court dates for them.

+
+ <% end %>
diff --git a/app/views/dashboard/admin.html.erb b/app/views/dashboard/admin.html.erb new file mode 100644 index 0000000000..487d611fec --- /dev/null +++ b/app/views/dashboard/admin.html.erb @@ -0,0 +1,87 @@ +<%# + Admin org-wide landing dashboard (casadesign). Rendered by DashboardController#show + inside the casa_app (Tailwind) layout. Data comes from AdminDashboard. +%> +<% content_for :page_title, "Dashboard" %> + +
+
+
+

Dashboard

+

<%= @dashboard.empty? ? "Let's get your chapter set up." : "Your chapter at a glance." %>

+
+ <%= link_to new_volunteer_path, class: "inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2" do %> + Invite a volunteer + <% end %> +
+ + <% if @dashboard.empty? %> +
+
+

Welcome to your chapter

+

Invite volunteers and add cases to get started. Once your chapter is active, this is where you'll see what needs attention.

+
+ <%= link_to "Invite a volunteer", new_volunteer_path, class: "inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2" %> + <%= link_to "Add a case", new_casa_case_path, class: "inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500" %> +
+
+ <% else %> + <% stats = @dashboard.stats %> + +
+
+ +
<%= stats[:volunteers] %>
+
Active volunteers
+
+
+ +
<%= stats[:cases] %>
+
Active cases
+
+
"> + ">" aria-hidden="true"> +
"><%= stats[:unassigned] %>
+
Unassigned cases
+
No active volunteer
+
+
+ ">" aria-hidden="true"> +
<%= stats[:needs_contact] %>
+
Need a contact
+
Not contacted in <%= Volunteer::CONTACT_MADE_IN_DAYS_NUM %>+ days
+
+
+ +
+
+

Needs your attention

+ <% if @dashboard.unassigned_cases.any? %><%= link_to "All cases", casa_cases_path, class: "text-sm font-medium text-brand-600 hover:text-brand-700 focus-visible:outline-none focus-visible:underline" %><% end %> +
+ <% if @dashboard.needs_attention.any? %> +

<%= pluralize(@dashboard.stats[:unassigned], "active case") %> with no volunteer assigned.

+
    + <% @dashboard.needs_attention.each do |casa_case| %> +
  • +
    + +
    <%= link_to casa_case.case_number, casa_case_path(casa_case), class: "hover:text-brand-700 focus-visible:outline-none focus-visible:underline" %>
    +
    + <%= link_to casa_case_path(casa_case), 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 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500" do %> + Assign a volunteer + <% end %> +
  • + <% end %> +
+ <% else %> +
+ +
+

Every case has a volunteer

+

Nice — no active cases are waiting for an assignment right now.

+
+
+ <% end %> +
+ <% end %> +
diff --git a/app/views/dashboard/supervisor.html.erb b/app/views/dashboard/supervisor.html.erb new file mode 100644 index 0000000000..1f016bc051 --- /dev/null +++ b/app/views/dashboard/supervisor.html.erb @@ -0,0 +1,190 @@ +<%# + Supervisor landing dashboard (casadesign). Rendered by DashboardController#show + inside the casa_app (Tailwind) layout. Data comes from SupervisorDashboard. +%> +<% content_for :page_title, "Dashboard" %> +<% avatar_classes = { + "sky" => "bg-sky-100 text-sky-700", + "violet" => "bg-violet-100 text-violet-700", + "emerald" => "bg-emerald-100 text-emerald-700", + "amber" => "bg-amber-100 text-amber-700", + "rose" => "bg-rose-100 text-rose-700", + "teal" => "bg-teal-100 text-teal-700", + "indigo" => "bg-indigo-100 text-indigo-700" +} %> + +
+
+
+

Dashboard

+

<%= @dashboard.rows.any? ? "Track volunteer progress at a glance." : "Let's get your volunteers set up." %>

+
+ <% if @dashboard.rows.any? %> + <%= link_to volunteers_path, class: "inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3.5 py-2 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 %> + Manage all volunteers + <% end %> + <% end %> +
+ + <% if @dashboard.volunteers.empty? %> +
+
+

No volunteers assigned to you yet

+

Once volunteers are assigned to you, this is where you'll track their case-contact activity, follow-ups, and hours — so you can see who needs support at a glance.

+
+ <%= link_to new_volunteer_path, class: "inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2" do %> + Invite a volunteer + <% end %> + <%= link_to volunteers_path, class: "inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500" do %> + Assign existing volunteers + <% end %> +
+
+ <% else %> + <% stats = @dashboard.stats %> + <% follow = stats[:needs_followup] %> + +
+
+ +
<%= stats[:active] %>
+
Volunteers you supervise
+
+
"> + ">" aria-hidden="true"> +
"><%= follow %>
+
Need follow-up
+
A case not contacted in 14+ days
+
+
+ +
<%= stats[:no_cases] %>
+
No active cases
+
+
+ +
<%= stats[:hours_label] %>
+
Hours logged (30d)
+
+
+ +
+
+

Needs your attention

+ <% if @dashboard.needs_attention.any? %><%= pluralize(@dashboard.needs_attention.size, "volunteer") %><% end %> +
+ <% if @dashboard.needs_attention.any? %> +
    + <% @dashboard.needs_attention.each do |row| %> +
  • +
    + +
    +
    <%= display_person(row.volunteer) %>
    +
    <%= pluralize(row.cases_count, "active case") %> · last contact <%= row.last_contact_label.downcase %>
    +
    +
    +
    + <%= link_to edit_volunteer_path(row.volunteer), 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 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500" do %> + Send reminder + <% end %> + <%= link_to "View", edit_volunteer_path(row.volunteer), "aria-label": "View #{display_person(row.volunteer)}", class: "rounded-lg px-2 py-1.5 text-sm font-medium text-brand-600 hover:text-brand-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500" %> +
    +
  • + <% end %> +
+ <% else %> +
+ +
+

You're all caught up

+

Every volunteer has logged contact with their active cases in the last 14 days. Nice work.

+
+
+ <% end %> +
+ +
+

Your volunteers

+ +
+ <% @dashboard.rows.each do |row| %> +
+ +
+
+
+
<%= display_person(row.volunteer) %>
+
<%= row.volunteer.email %>
+
+ <%= link_to edit_volunteer_path(row.volunteer), "aria-label": "View #{display_person(row.volunteer)}", class: "-mr-1 grid h-8 w-8 flex-none place-items-center rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500" do %><% end %> +
+
+ <% if row.status == :on_track %> + On track + <% elsif row.status == :follow_up %> + Needs follow-up + <% else %> + No active cases + <% end %> + <%= row.cases_count %> <%= "case".pluralize(row.cases_count) %> +
+
<%= row.last_contact_label %><% if row.cases_count.positive? %> · <%= pluralize(row.contacts_recent, "contact") %> · <%= row.hours_label %><% end %>
+
+
+ <% end %> +
+
+ <% end %> +
diff --git a/app/views/dashboard/volunteer.html.erb b/app/views/dashboard/volunteer.html.erb new file mode 100644 index 0000000000..422db0a8d7 --- /dev/null +++ b/app/views/dashboard/volunteer.html.erb @@ -0,0 +1,125 @@ +<%# + Volunteer landing dashboard (casadesign). Rendered by DashboardController#show inside + the casa_app (Tailwind) layout. Data comes from VolunteerDashboard. +%> +<% content_for :page_title, "Dashboard" %> + +
+
+
+

Dashboard

+

<%= @dashboard.rows.any? ? "Stay on top of your cases and contacts." : "Your cases will appear here." %>

+
+ <% if @dashboard.rows.any? %> + <%= link_to new_case_contact_path, class: "inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2" do %> + Log a contact + <% end %> + <% end %> +
+ + <% if @dashboard.cases.empty? %> +
+
+

No cases assigned to you yet

+

When a supervisor assigns you to a case, it'll show up here so you can log contacts and track your work.

+
+ <% else %> + <% stats = @dashboard.stats %> + <% needs = stats[:needs_contact] %> + +
+
+ +
<%= stats[:cases] %>
+
Active cases
+
+
"> + ">" aria-hidden="true"> +
"><%= needs %>
+
Need a contact
+
Not contacted in <%= Volunteer::CONTACT_MADE_IN_DAYS_NUM %>+ days
+
+
+ +
<%= stats[:hours_label] %>
+
Hours logged (30d)
+
+
+ + <% if @dashboard.needs_attention.any? %> +
+
+

Needs your attention

+ <%= pluralize(@dashboard.needs_attention.size, "case") %> +
+
    + <% @dashboard.needs_attention.each do |row| %> +
  • +
    + +
    +
    <%= link_to row.casa_case.case_number, casa_case_path(row.casa_case), class: "hover:text-brand-700 focus-visible:outline-none focus-visible:underline" %>
    +
    <%= row.last_contact_label %>
    +
    +
    + <%= link_to new_case_contact_path, 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 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500" do %> + Log a contact + <% end %> +
  • + <% end %> +
+
+ <% end %> + +
+

Your cases

+ +
+ <% @dashboard.rows.each do |row| %> +
+
+ <%= link_to row.casa_case.case_number, casa_case_path(row.casa_case), class: "font-medium text-slate-800 hover:text-brand-700 focus-visible:outline-none focus-visible:underline" %> + <% if row.needs_contact? %> + Needs a contact + <% else %> + On track + <% end %> +
+
+ <%= row.last_contact_label %> + <%= link_to "Log contact", new_case_contact_path, class: "text-sm font-medium text-brand-600 hover:text-brand-700 focus-visible:outline-none focus-visible:underline" %> +
+
+ <% end %> +
+
+ <% end %> +
diff --git a/app/views/devise/invitations/edit.html.erb b/app/views/devise/invitations/edit.html.erb index 666d34b104..5f1b963c39 100644 --- a/app/views/devise/invitations/edit.html.erb +++ b/app/views/devise/invitations/edit.html.erb @@ -1,29 +1,32 @@ -
-

Set password

+<% content_for :page_title, "Accept your invitation" %> +<% field = "block w-full rounded-lg border border-slate-300 px-3.5 py-2.5 text-slate-900 shadow-sm transition placeholder:text-slate-500 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30 focus:outline-none" %> - <%= form_with(model: resource, as: resource_name, url: invitation_path(resource_name), local: true, html: {method: :put}) do |f| %> - <%= render "/shared/error_messages", resource: resource %> - <%= f.hidden_field :invitation_token, readonly: true %> +
+

Set your password

+

Welcome to CASA — choose a password to activate your account.

+
- <% if f.object.class.require_password_on_accepting %> -
- <%= f.label :password %> - <%= f.password_field :password, placeholder: "Password", required: true %> -
+<% if resource.errors.any? %> +
+
    + <% resource.errors.full_messages.each do |msg| %>
  • <%= msg %>
  • <% end %> +
+
+<% end %> -
- <%= f.label :password_confirmation %> - <%= f.password_field :password_confirmation, placeholder: "Password Confirmation", required: true %> -
- <% end %> +<%= form_with(model: resource, as: resource_name, url: invitation_path(resource_name), local: true, html: {method: :put}, class: "space-y-5") do |f| %> + <%= f.hidden_field :invitation_token, readonly: true %> -
- <%= button_tag( - type: "submit", - class: "main-btn primary-btn btn-hover btn-sm" - ) do %> - Set my password - <% end %> + <% if f.object.class.require_password_on_accepting %> +
+ <%= f.label :password, class: "mb-1.5 block text-sm font-medium text-slate-700" %> + <%= f.password_field :password, autofocus: true, autocomplete: "new-password", required: true, placeholder: "••••••••", class: field %> +
+
+ <%= f.label :password_confirmation, "Confirm password", class: "mb-1.5 block text-sm font-medium text-slate-700" %> + <%= f.password_field :password_confirmation, autocomplete: "new-password", required: true, placeholder: "••••••••", class: field %>
<% end %> -
+ + <%= f.submit "Activate account", class: "w-full cursor-pointer rounded-lg bg-brand-600 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-sm transition hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-brand-500/50 focus:ring-offset-2" %> +<% end %> diff --git a/app/views/devise/passwords/edit.html.erb b/app/views/devise/passwords/edit.html.erb index 9fa2313dbb..557758bd7e 100644 --- a/app/views/devise/passwords/edit.html.erb +++ b/app/views/devise/passwords/edit.html.erb @@ -1,55 +1,40 @@ -
-
-
-
-
-

Change your password

+<% content_for :page_title, "Choose a new password" %> +<% field = "block w-full rounded-lg border border-slate-300 px-3.5 py-2.5 text-slate-900 shadow-sm transition placeholder:text-slate-500 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30 focus:outline-none" %> - <%= form_with(model: resource, as: resource_name, url: password_path(resource_name), method: :put) do |f| %> - <%= render "/shared/error_messages", resource: resource %> - <%= f.hidden_field :reset_password_token %> - -
- <%= f.label :password, "New password" %> - <% if @minimum_password_length %> - (<%= @minimum_password_length %> characters minimum) - <% end %> - <%= f.password_field :password, - autofocus: true, - autocomplete: "new-password", - class: "password-new", - placeholder: "New Password", - required: true, - minlength: @minimum_password_length %> -
- -
- <%= f.label :password_confirmation, "Confirm new password" %> - <%= f.password_field :password_confirmation, - autocomplete: "new-password", - class: "password-confirmation", - placeholder: "Confirm Password", - required: true, - minlength: @minimum_password_length %> -
+
+

Choose a new password

+

Pick a new password for your account.

+
-
- <%= button_tag( - type: "submit", - class: "main-btn primary-btn btn-hover btn-sm submit-password" - ) do %> - Change my password - <% end %> -
- <% end %> +<% flash.each do |type, message| %> + <% next if message.blank? %> +
"><%= message %>
+<% end %> - <%= render "devise/shared/links" %> -
-
-
+<% if resource.errors.any? %> +
+
    + <% resource.errors.full_messages.each do |msg| %>
  • <%= msg %>
  • <% end %> +
+<% end %> -
- +<%= form_with(model: resource, as: resource_name, url: password_path(resource_name), method: :put, class: "space-y-5") do |f| %> + <%= f.hidden_field :reset_password_token %> +
+ <%= f.label :password, "New password", class: "mb-1.5 block text-sm font-medium text-slate-700" %> + <%= f.password_field :password, autofocus: true, autocomplete: "new-password", required: true, minlength: @minimum_password_length, placeholder: "••••••••", class: field %> + <% if @minimum_password_length %> +

<%= @minimum_password_length %> characters minimum

+ <% end %>
-
+
+ <%= f.label :password_confirmation, "Confirm new password", class: "mb-1.5 block text-sm font-medium text-slate-700" %> + <%= f.password_field :password_confirmation, autocomplete: "new-password", required: true, minlength: @minimum_password_length, placeholder: "••••••••", class: field %> +
+ <%= f.submit "Update password", class: "w-full cursor-pointer rounded-lg bg-brand-600 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-sm transition hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-brand-500/50 focus:ring-offset-2" %> +<% end %> + +

+ <%= link_to "← Back to sign in", new_session_path(resource_name), class: "font-medium text-brand-600 hover:text-brand-700" %> +

diff --git a/app/views/devise/passwords/new.html.erb b/app/views/devise/passwords/new.html.erb index 00c6551700..fae9013699 100644 --- a/app/views/devise/passwords/new.html.erb +++ b/app/views/devise/passwords/new.html.erb @@ -1,42 +1,41 @@ -
-
-
-
-
-

Forgot your password?

+<% content_for :page_title, "Reset your password" %> +<% field = "block w-full rounded-lg border border-slate-300 px-3.5 py-2.5 text-slate-900 shadow-sm transition placeholder:text-slate-500 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30 focus:outline-none" %> - <%= form_with(model: resource, as: resource_name, url: password_path(resource_name), html: {method: :post}) do |f| %> - <%= render "/shared/error_messages", resource: resource %> - -

Please enter email or phone number to receive reset instructions.

- -
- <%= f.label :email %> - <%= f.email_field :email, placeholder: "Email", autofocus: true, autocomplete: "email" %> -
- -
- <%= f.label :phone_number %> - <%= f.text_field :phone_number, placeholder: "Phone Number", autofocus: true, autocomplete: "phone number" %> -
+
+

Reset your password

+

Enter your email or phone number and we'll send reset instructions.

+
-
- <%= button_tag( - type: "submit", - class: "main-btn primary-btn btn-hover btn-sm" - ) do %> - Send me reset password instructions - <% end %> -
- <% end %> +<% flash.each do |type, message| %> + <% next if message.blank? %> +
"><%= message %>
+<% end %> - <%= render "devise/shared/links" %> -
-
-
+<% if resource.errors.any? %> +
+
    + <% resource.errors.full_messages.each do |msg| %>
  • <%= msg %>
  • <% end %> +
+<% end %> -
- +<%= form_with(model: resource, as: resource_name, url: password_path(resource_name), html: {method: :post}, class: "space-y-5") do |f| %> +
+ <%= f.label :email, class: "mb-1.5 block text-sm font-medium text-slate-700" %> + <%= f.email_field :email, autofocus: true, autocomplete: "email", placeholder: "you@example.com", class: field %>
-
+
+ + or + +
+
+ <%= f.label :phone_number, "Phone number", class: "mb-1.5 block text-sm font-medium text-slate-700" %> + <%= f.telephone_field :phone_number, autocomplete: "tel", placeholder: "+1 555 123 4567", class: field %> +
+ <%= f.submit "Send reset instructions", class: "w-full cursor-pointer rounded-lg bg-brand-600 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-sm transition hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-brand-500/50 focus:ring-offset-2" %> +<% end %> + +

+ <%= link_to "← Back to sign in", new_session_path(resource_name), class: "font-medium text-brand-600 hover:text-brand-700" %> +

diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index 9e2e3ffaf9..1a2dba0376 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -1,80 +1,51 @@ -