diff --git a/app/assets/stylesheets/tailwind.css b/app/assets/stylesheets/tailwind.css index 775b48703c..c8907a3298 100644 --- a/app/assets/stylesheets/tailwind.css +++ b/app/assets/stylesheets/tailwind.css @@ -59,8 +59,10 @@ 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. */ + the injected icon-font element never painted. Pixel-verified against a native single-select's + `` in the SAME screenshot: identical 10x6 ink extent, + 20 ink pixels, mean luminance 251.4 vs 251.2. Compare the SHAPE (ink bbox + coverage), not the + darkest pixel -- darkest-pixel alone matched while the region actually held the clear x. */ .ts-wrapper::after { content: ""; position: absolute; @@ -126,6 +128,24 @@ .ts-wrapper .clear-button:hover { color: #475569 !important; } .ts-wrapper .clear-button:focus-visible { outline: 2px solid #6366f1; outline-offset: 2px; border-radius: 0.25rem; } /* it is role=button tabindex=0, so it needs a visible focus ring */ .ts-dropdown .option[data-value=""] { display: none; } +/* In a filter bar the control sits in a row of native `py-2` selects and date inputs, which are 38px, + not the 42px form-input height -- so a bare .ts-control stood 4px taller than every field beside it + (bottoms aligned by `items-end`, tops 4px out). `ts-filter` on the carries the attribute, but ships no + styling for it, so an empty-state picker looked live (a disabled ancestor fieldset makes it inert + without changing how it reads). */ +.ts-wrapper.disabled .ts-control { background-color: #f1f5f9 !important; color: #64748b; } +.ts-wrapper.disabled::after { opacity: 0.5; } /* 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 { @@ -227,3 +247,15 @@ dialog[data-modal-target="dialog"] { @media (max-width: 640px) { dialog[data-modal-target="dialog"] { top: 18vh; } } + +/* ------------------------------------------------------------------ + Trix toolbar. Trix ships `.trix-button-row { flex-wrap: nowrap; overflow-x: auto }`, + a scrollable region that is not itself focusable -- axe's + scrollable-region-focusable (serious) flags it, and on narrow screens the + buttons hide behind a scrollbar-less horizontal scroll. Let the row wrap + instead: no scroll container, so every button stays reachable. +------------------------------------------------------------------ */ +trix-toolbar .trix-button-row { + flex-wrap: wrap; + overflow-x: visible; +} diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 6b14da7b6a..fabe306273 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -150,7 +150,10 @@ def not_authorized end format.any do session[:user_return_to] = nil - flash[:notice] = message + # :alert, not :notice. An authorization failure is not a success, and shared/_flashes maps + # :notice to the green success variant -- so this rendered a permission error as a success, + # and success messages now auto-dismiss, which would have quietly thrown it away. + flash[:alert] = message redirect_to(root_url) end end diff --git a/app/controllers/banners_controller.rb b/app/controllers/banners_controller.rb index 9c97dbe431..84ccbdb96b 100644 --- a/app/controllers/banners_controller.rb +++ b/app/controllers/banners_controller.rb @@ -36,7 +36,7 @@ def create @banner.save! end - redirect_to banners_path + redirect_to banners_path, **banner_created_flash rescue render :new, status: :unprocessable_content end @@ -49,7 +49,7 @@ def update @banner.update!(banner_params) end - redirect_to banners_path + redirect_to banners_path, **banner_created_flash(verb: "updated") rescue render :edit, status: :unprocessable_content end @@ -71,6 +71,14 @@ def banner_params BannerParameters.new(params, current_user, browser_time_zone) end + def banner_created_flash(verb: "created") + if @banner.active? + {notice: "Banner #{verb} and is now showing at the top of every page."} + else + {alert: "Banner #{verb}, but it is not active, so no one will see it yet. Use Activate to show it."} + end + end + def deactivate_alternate_active_banner if banner_params[:active].to_i == 1 alternate_active_banner = current_organization.banners.where(active: true).where.not(id: @banner.id).first diff --git a/app/controllers/casa_cases_controller.rb b/app/controllers/casa_cases_controller.rb index 589b7c71e8..7e6e3813b7 100644 --- a/app/controllers/casa_cases_controller.rb +++ b/app/controllers/casa_cases_controller.rb @@ -219,7 +219,7 @@ def set_casa_case @casa_case = current_organization.casa_cases.friendly.find(params[:id]) rescue ActiveRecord::RecordNotFound respond_to do |format| - format.html { redirect_to casa_cases_path, notice: "Sorry, you are not authorized to perform this action." } + format.html { redirect_to casa_cases_path, alert: "Sorry, you are not authorized to perform this action." } format.json { render json: {error: "Sorry, you are not authorized to perform this action."}, status: :not_found } end end diff --git a/app/controllers/court_dates_controller.rb b/app/controllers/court_dates_controller.rb index 871952f9d3..96d0ccc16e 100644 --- a/app/controllers/court_dates_controller.rb +++ b/app/controllers/court_dates_controller.rb @@ -73,7 +73,7 @@ def set_casa_case def not_authorized_redirect respond_to do |format| - format.html { redirect_to casa_cases_path, notice: "Sorry, you are not authorized to perform this action." } + format.html { redirect_to casa_cases_path, alert: "Sorry, you are not authorized to perform this action." } format.json { render json: {error: "Sorry, you are not authorized to perform this action."}, status: :unauthorized } format.any { head :not_found } end diff --git a/app/controllers/learning_hours_controller.rb b/app/controllers/learning_hours_controller.rb index 13e475afc8..d33beb341f 100644 --- a/app/controllers/learning_hours_controller.rb +++ b/app/controllers/learning_hours_controller.rb @@ -5,8 +5,9 @@ class LearningHoursController < ApplicationController def index authorize LearningHour + set_period rows = LearningHoursDashboardRowsService - .new(current_user, policy_scope(LearningHour)) + .new(current_user, policy_scope(LearningHour), @period) .perform if current_user.volunteer? @@ -78,6 +79,29 @@ def destroy private + # The roster column is bounded by a date range the user can change. It defaults to the calendar + # year to date, which is what the column header always claimed ("Time completed this year") even + # though nothing filtered on occurred_at, so the totals were all-time. + # Learning hours cannot occur before 1989-01-01 (LearningHour validates that) or in the future, so + # clamp to that window: a hand-edited or mistyped param would otherwise put a nonsense year in the + # column header ("since February 2, 0730" -- Date.parse happily accepts it). + PERIOD_FLOOR = Date.new(1989, 1, 1) + + def set_period + @period_from = parse_date(params[:from]) || Date.current.beginning_of_year + @period_to = parse_date(params[:to]) || Date.current + @period_from, @period_to = @period_to, @period_from if @period_from > @period_to + @period_from = @period_from.clamp(PERIOD_FLOOR, Date.current) + @period_to = @period_to.clamp(PERIOD_FLOOR, Date.current) + @period = @period_from..@period_to + end + + def parse_date(value) + Date.parse(value.to_s) + rescue Date::Error + nil + end + def set_learning_hour @learning_hour = LearningHour.find(params[:id]) rescue ActiveRecord::RecordNotFound diff --git a/app/controllers/mileage_rates_controller.rb b/app/controllers/mileage_rates_controller.rb index f584b16ad2..58b0c6ad79 100644 --- a/app/controllers/mileage_rates_controller.rb +++ b/app/controllers/mileage_rates_controller.rb @@ -11,13 +11,13 @@ def index end def new - authorize CasaAdmin @mileage_rate = current_organization.mileage_rates.build + authorize @mileage_rate end def create - authorize CasaAdmin @mileage_rate = MileageRate.new(mileage_rate_params.merge(casa_org: current_organization)) + authorize @mileage_rate if @mileage_rate.save redirect_to mileage_rates_path else @@ -26,11 +26,11 @@ def create end def edit - authorize CasaAdmin + authorize @mileage_rate end def update - authorize CasaAdmin + authorize @mileage_rate if @mileage_rate.update(mileage_rate_params) redirect_to mileage_rates_path @@ -45,7 +45,9 @@ def mileage_rate_params params.require(:mileage_rate).permit(:effective_date, :amount, :is_active) end + # Scoped to the org: an unscoped find let an admin reach another org's rate, and a 404 is the + # right answer there rather than leaving it to the policy alone. def set_mileage_rate - @mileage_rate = MileageRate.find(params[:id]) + @mileage_rate = current_organization.mileage_rates.find(params[:id]) end end diff --git a/app/controllers/reimbursements_controller.rb b/app/controllers/reimbursements_controller.rb index 3f6832ccae..b5fe287483 100644 --- a/app/controllers/reimbursements_controller.rb +++ b/app/controllers/reimbursements_controller.rb @@ -38,7 +38,11 @@ def change_complete_status private def apply_filters_to_query(query) - query = query.where(creator_id: params[:volunteers]) if params[:volunteers].present? + # "all" is the filter bar's explicit no-filter value. The volunteer picker is a searchable select + # and the theme hides empty-valued options in its menu, so the "All volunteers" row cannot carry + # value="" -- it would be missing from the menu the user opens to clear the filter. + volunteer = params[:volunteers].presence + query = query.where(creator_id: volunteer) if volunteer && volunteer != "all" apply_occurred_at_filters(query) end diff --git a/app/decorators/contact_type_decorator.rb b/app/decorators/contact_type_decorator.rb index 82315e75b2..ebebadcd27 100644 --- a/app/decorators/contact_type_decorator.rb +++ b/app/decorators/contact_type_decorator.rb @@ -3,21 +3,24 @@ class ContactTypeDecorator < Draper::Decorator delegate_all def hash_for_multi_select_with_cases(casa_case_ids) - if casa_case_ids.nil? - casa_case_ids = [] - end - - {value: object.id, text: object.name, group: object.contact_type_group.name, subtext: last_time_used_with_cases(casa_case_ids)} - end - - def last_time_used_with_cases(casa_case_ids) - last_contact = last_contact_with_cases(casa_case_ids) - - last_contact&.occurred_at.blank? ? "never" : "#{time_ago_in_words(last_contact.occurred_at)} ago" + casa_case_ids = [] if casa_case_ids.nil? + + { + value: object.id, + text: object.name, + group: object.contact_type_group.name, + # Same recency hint the checkbox form uses, so a type that has never been logged shows no + # subtext instead of a bare "never" beside every option. `.to_s`, not the bare nil: the option + # template substitutes this through TomSelect's escape(), which turns nil into the literal + # string "null". + subtext: last_logged_hint_with_cases(casa_case_ids).to_s + } end - # Labeled recency hint for the contact-type checkboxes. Returns nil when this type has never - # been logged for the case(s) so the form can omit the line rather than show a bare "never". + # Labeled recency hint. Returns nil when this type has never been logged for the case(s) so the + # caller can omit the line rather than show a bare "never". Used by both the contact-type + # checkboxes and the multi-select options -- they had diverged, which is how "never" survived on + # casa_cases#edit after being removed elsewhere. def last_logged_hint_with_cases(casa_case_ids) last_contact = last_contact_with_cases(casa_case_ids) return if last_contact&.occurred_at.blank? diff --git a/app/helpers/metrics_helper.rb b/app/helpers/metrics_helper.rb index b4f9e7f5fe..62a421363a 100644 --- a/app/helpers/metrics_helper.rb +++ b/app/helpers/metrics_helper.rb @@ -80,7 +80,7 @@ def metric_data_table(labels, series, caption:, foot: nil, footnote: nil) 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"), + tag.div(table, class: "mt-2.5 overflow-x-auto", tabindex: 0), (footnote ? tag.p(footnote, class: "mt-2 text-[11px] text-slate-500") : "".html_safe) ]) end @@ -102,7 +102,7 @@ def metric_heatmap(grid, max) 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.div(class: "overflow-x-auto", tabindex: 0) 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"), diff --git a/app/javascript/__tests__/case_emancipations.test.js b/app/javascript/__tests__/case_emancipations.test.js index 9c5b9d9d9f..e46ac1d19c 100644 --- a/app/javascript/__tests__/case_emancipations.test.js +++ b/app/javascript/__tests__/case_emancipations.test.js @@ -17,11 +17,11 @@ beforeEach(() => {
-
+

+ -

+
{
-
+

+ -

+
this.hide(), this.delayValue) + } + + cancel () { + if (this.timeout) { + clearTimeout(this.timeout) + this.timeout = null + } + } + + // data-action hooks: hold the message while the user is reading or tabbing through it. + pause () { + this.cancel() + } + + resume () { + this.cancel() + this.start() + } + + hide () { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + this.element.remove() + return + } + + // Inline styles rather than a utility class: a class added from JS only works while Tailwind + // still emits it, and if it were ever dropped from the build the element would silently never + // fade *or* be removed. The removal is on a timer for the same reason -- transitionend can be + // missed, and the message must go either way. + this.element.style.transition = `opacity ${FADE_MS}ms` + this.element.style.opacity = '0' + setTimeout(() => this.element.remove(), FADE_MS + 50) + } +} diff --git a/app/javascript/controllers/auto_submit_controller.js b/app/javascript/controllers/auto_submit_controller.js index fb64329686..777c499433 100644 --- a/app/javascript/controllers/auto_submit_controller.js +++ b/app/javascript/controllers/auto_submit_controller.js @@ -1,9 +1,54 @@ 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. +const DEBOUNCE_MS = 350 +// Turbo Drive is disabled app-wide (application.js: `Turbo.session.drive = false`), so submitting +// this form is a real page load: in-memory state does not survive it, and the caret has to be parked +// somewhere that does. Verified -- a marker set on `window` before the submit is gone afterwards. +const FOCUS_KEY = 'auto-submit:focus' + +// Submits the form when a control changes (the table filter selects), and searches as the user types +// in a search field. export default class extends Controller { + connect () { + const parked = window.sessionStorage.getItem(FOCUS_KEY) + if (!parked) return + window.sessionStorage.removeItem(FOCUS_KEY) + + let position + try { + position = JSON.parse(parked) + } catch { + return + } + const field = this.element.querySelector(`[name="${position.name}"]`) + if (!field) return + + // Deferred a frame so the restore lands after the browser has finished settling the new page. + window.requestAnimationFrame(() => { + field.focus() + // Put the caret back, or every submit bounces it to the start of the box mid-word. + if (field.setSelectionRange) field.setSelectionRange(position.start, position.end) + }) + } + submit () { this.element.requestSubmit() } + + // A text input fires `change` only on blur or Enter, so on a change-only filter bar typing looked + // like it did nothing -- and the still-unsubmitted text then applied itself the moment the user + // touched any other filter, which reads as the search box "keeping" letters it should not have. + // Debounced: submit once the user pauses, not per keystroke. + search (event) { + const field = event.target + window.sessionStorage.setItem(FOCUS_KEY, JSON.stringify({ + name: field.name, start: field.selectionStart, end: field.selectionEnd + })) + clearTimeout(this.timer) + this.timer = setTimeout(() => this.submit(), DEBOUNCE_MS) + } + + disconnect () { + clearTimeout(this.timer) + } } diff --git a/app/javascript/controllers/copy_court_orders_controller.js b/app/javascript/controllers/copy_court_orders_controller.js index 7337e36fbe..270feb403a 100644 --- a/app/javascript/controllers/copy_court_orders_controller.js +++ b/app/javascript/controllers/copy_court_orders_controller.js @@ -1,22 +1,19 @@ import { Controller } from '@hotwired/stimulus' -// Copy all court orders from a sibling case into the current one. The Copy button is always -// enabled; clicking it with no case selected shows an inline error, otherwise 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. +// Copy all court orders from a sibling case into the current one. "Copy from another case" opens a +// design-system (the `modal` controller centers it) holding the case picker; confirming with +// nothing chosen shows an inline error inside the dialog, otherwise it 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 (it binds `button.copy-court-button` by class, which +// this button deliberately does not carry). export default class extends Controller { - static targets = ['select', 'dialog', 'caseNumber', 'error'] + static targets = ['select', 'dialog', 'error'] static values = { casaCaseId: String } + // The case picker lives inside the dialog now, so opening cannot validate anything -- there is + // nothing chosen yet. Validation moved to #confirm. open () { - if (this.selectTarget.value === '') { - if (this.hasErrorTarget) this.errorTarget.classList.remove('hidden') - this.selectTarget.focus() - return - } this.clearError() - this.caseNumberTarget.textContent = this.selectTarget.value this.dialogTarget.showModal() } @@ -25,6 +22,12 @@ export default class extends Controller { } async confirm () { + if (this.selectTarget.value === '') { + if (this.hasErrorTarget) this.errorTarget.classList.remove('hidden') + this.selectTarget.focus() + return + } + const token = document.querySelector('meta[name="csrf-token"]')?.content const response = await fetch(`/casa_cases/${this.casaCaseIdValue}/copy_court_orders`, { method: 'PATCH', diff --git a/app/javascript/controllers/court_order_form_controller.js b/app/javascript/controllers/court_order_form_controller.js index 44aa3485a4..6e07867608 100644 --- a/app/javascript/controllers/court_order_form_controller.js +++ b/app/javascript/controllers/court_order_form_controller.js @@ -33,10 +33,20 @@ export default class extends NestedForm { add (e) { super.add(e) const selectedValue = $(this.selectedCourtOrderTarget).val() + // The last entry, not `:last-of-type`: the rows share a parent with the insertion target div, so + // `div:last-of-type` is that target (which has no textarea) rather than the row just added. + const entries = document.querySelectorAll('#court-orders-list-container .court-order-entry') + const textarea = entries.length ? entries[entries.length - 1].querySelector('textarea.court-order-text-entry') : null - if (selectedValue !== '') { - const $textarea = $('#court-orders-list-container .court-order-entry:last textarea.court-order-text-entry') - $textarea.val(selectedValue) + if (selectedValue !== '' && textarea) textarea.value = selectedValue + + // Move focus into the row that was just added. The row appends to the end of the list, which puts + // it directly above the Add control -- correct for an "add another" list -- but the button keeps + // focus otherwise, so a keyboard user has to go looking for the field they just created. + if (textarea) { + textarea.focus() + // Caret after any prefilled standard-order text rather than before it. + textarea.setSelectionRange(textarea.value.length, textarea.value.length) } } } diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 9f6b7c19a1..e9e97d8f52 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -10,6 +10,9 @@ application.register("add-to-calendar", AddToCalendarController) import AlertController from "./alert_controller" application.register("alert", AlertController) +import AutoDismissController from "./auto_dismiss_controller" +application.register("auto-dismiss", AutoDismissController) + import AutoSubmitController from "./auto_submit_controller" application.register("auto-submit", AutoSubmitController) diff --git a/app/javascript/controllers/multiple_select_controller.js b/app/javascript/controllers/multiple_select_controller.js index a711acf08e..9962176c71 100644 --- a/app/javascript/controllers/multiple_select_controller.js +++ b/app/javascript/controllers/multiple_select_controller.js @@ -124,7 +124,17 @@ export default class extends Controller { } }, onDropdownOpen, - onDropdownClose + onDropdownClose, + // Clear the query once an item is picked, and re-score the remaining options against an empty + // query so the next search starts clean. Without this TomSelect leaves the typed letters sitting + // in the control beside the new chip -- reported as "the letters the user types stay even after + // they have made a selection". The grouped path below already did this; this one did not, which + // is why it affected every plain multiselect (contact types, case groups, all four report + // filters) and none of the single-selects. + onItemAdd: function () { + this.setTextboxValue('') + this.refreshOptions() + } } // A blank-load filter shows a placeholder ("Select or search supervisors", ...) until an item is // picked; hidePlaceholder clears the prompt once a chip exists (industry standard -- a lingering diff --git a/app/models/learning_hour.rb b/app/models/learning_hour.rb index c127490ecd..0eb6fb2151 100644 --- a/app/models/learning_hour.rb +++ b/app/models/learning_hour.rb @@ -16,17 +16,24 @@ class LearningHour < ApplicationRecord } validates :learning_hour_topic, presence: true, if: :user_org_learning_topic_enable? - scope :supervisor_volunteers_learning_hours, ->(supervisor_id) { + # `occurred_in` is optional so the Pundit scopes keep meaning "everything this user may see". + # The roster passes a range; without one these totals are all-time, which is what the roster + # column used to show while its header said "this year". + scope :occurred_in, ->(range) { range ? where(occurred_at: range) : all } + + scope :supervisor_volunteers_learning_hours, ->(supervisor_id, range = nil) { joins(user: :supervisor_volunteer) .where(supervisor_volunteers: {supervisor_id: supervisor_id}) + .occurred_in(range) .select("users.id as user_id, users.display_name, SUM(learning_hours.duration_minutes + learning_hours.duration_hours * 60) AS total_time_spent") .group("users.display_name, users.id") .order("users.display_name") } - scope :all_volunteers_learning_hours, ->(casa_admin_org_id) { + scope :all_volunteers_learning_hours, ->(casa_admin_org_id, range = nil) { joins(:user) .where(users: {casa_org_id: casa_admin_org_id}) + .occurred_in(range) .select("users.id as user_id, users.display_name, SUM(learning_hours.duration_minutes + learning_hours.duration_hours * 60) AS total_time_spent") .group("users.display_name, users.id") .order("users.display_name") diff --git a/app/policies/mileage_rate_policy.rb b/app/policies/mileage_rate_policy.rb new file mode 100644 index 0000000000..8e8a6da0c8 --- /dev/null +++ b/app/policies/mileage_rate_policy.rb @@ -0,0 +1,13 @@ +class MileageRatePolicy < ApplicationPolicy + # A mileage rate belongs_to :casa_org, so every action can -- and must -- check the record's org. + # The controller used to `authorize CasaAdmin`, passing the *class*: that skipped the org check + # entirely (any admin could edit any org's rate) and raised NoMethodError in `same_org?`, because + # CasaAdminPolicy#edit? is the one method there that reaches record.casa_org. + def new? + is_admin_same_org? + end + + alias_method :create?, :new? + alias_method :edit?, :new? + alias_method :update?, :new? +end diff --git a/app/services/admin_dashboard.rb b/app/services/admin_dashboard.rb index 6b9bdcfc53..20947689b9 100644 --- a/app/services/admin_dashboard.rb +++ b/app/services/admin_dashboard.rb @@ -29,6 +29,17 @@ def needs_attention unassigned_cases.first(ATTENTION_LIMIT) end + # Next upcoming court date per case, for the needs-attention table: an unassigned case with court + # imminent is the one to staff first. ONE grouped query for the whole set, keeping this class's + # no-per-case-queries contract (CasaCase#next_court_date would issue one query per row). + def next_court_dates + @next_court_dates ||= CourtDate + .where(casa_case_id: needs_attention.map(&:id)) + .where(date: Date.current..) + .group(:casa_case_id) + .minimum(:date) + end + def empty? active_cases.empty? && active_volunteers.zero? end diff --git a/app/services/learning_hours_dashboard_rows_service.rb b/app/services/learning_hours_dashboard_rows_service.rb index 0fe229c43d..d1caf69759 100644 --- a/app/services/learning_hours_dashboard_rows_service.rb +++ b/app/services/learning_hours_dashboard_rows_service.rb @@ -1,7 +1,10 @@ class LearningHoursDashboardRowsService - def initialize(user, learning_hours_scope) + # `range` (a Date range or nil) bounds the per-volunteer totals on the supervisor/admin roster. + # nil means all time. A volunteer's own list is not aggregated, so the range does not apply there. + def initialize(user, learning_hours_scope, range = nil) @user = user @learning_hours_scope = learning_hours_scope + @range = range end def perform @@ -11,7 +14,7 @@ def perform when Supervisor supervisor_rows when CasaAdmin - LearningHour.all_volunteers_learning_hours(@user.casa_org_id) + LearningHour.all_volunteers_learning_hours(@user.casa_org_id, @range) else raise "unrecognized role #{@user.type}" end @@ -22,7 +25,7 @@ def perform def supervisor_rows totals_by_user_id = LearningHour - .supervisor_volunteers_learning_hours(@user.id) + .supervisor_volunteers_learning_hours(@user.id, @range) .index_by { |row| row.user_id } @user.volunteers.map do |volunteer| diff --git a/app/views/all_casa_admins/casa_orgs/show.html.erb b/app/views/all_casa_admins/casa_orgs/show.html.erb index f62d67e042..fcf393e041 100644 --- a/app/views/all_casa_admins/casa_orgs/show.html.erb +++ b/app/views/all_casa_admins/casa_orgs/show.html.erb @@ -18,8 +18,8 @@
- - +
+ @@ -27,7 +27,7 @@ - + <% selected_organization.casa_admins.each do |admin| %> diff --git a/app/views/all_casa_admins/dashboard/show.html.erb b/app/views/all_casa_admins/dashboard/show.html.erb index f989bd58bc..418d201ee5 100644 --- a/app/views/all_casa_admins/dashboard/show.html.erb +++ b/app/views/all_casa_admins/dashboard/show.html.erb @@ -21,16 +21,16 @@

CASA Organizations

-
Name EmailActions
<%= admin.display_name %>
- - +
+ + - + <% @organizations.each do |organization| %> diff --git a/app/views/all_casa_admins/sessions/new.html.erb b/app/views/all_casa_admins/sessions/new.html.erb index 730e6ec3e1..fa5d69aba0 100644 --- a/app/views/all_casa_admins/sessions/new.html.erb +++ b/app/views/all_casa_admins/sessions/new.html.erb @@ -7,7 +7,7 @@ <% submit = "#{button_classes(:primary)} w-full" %>
-

All CASA log in

+

All CASA log in

Sign in to the all-CASA admin console.

diff --git a/app/views/analytics/index.html.erb b/app/views/analytics/index.html.erb index c5d9533d55..6484eee8ff 100644 --- a/app/views/analytics/index.html.erb +++ b/app/views/analytics/index.html.erb @@ -15,7 +15,7 @@
<%= k[:contacts_this_month] %>
Contacts this month
<% d = k[:contacts_delta] %> -
"> +
"> <% if d > 0 %>+<%= d %> vs last month<% elsif d < 0 %><%= d %> vs last month<% else %>No change vs last month<% end %>
diff --git a/app/views/banners/_form.html.erb b/app/views/banners/_form.html.erb index 9c1428d59f..a5c576a994 100644 --- a/app/views/banners/_form.html.erb +++ b/app/views/banners/_form.html.erb @@ -32,7 +32,7 @@
<%= form.label :content, class: label_class do %>Content <%= req %><% end %> - <%= form.rich_text_area :content, class: "trix-content rounded-lg border border-slate-300 shadow-sm" %> + <%= form.rich_text_area :content, class: "trix-content rounded-lg border border-slate-300 shadow-sm", aria: {label: "Content"} %>
<%= button_tag type: "submit", class: button_classes(:primary) do %> diff --git a/app/views/banners/index.html.erb b/app/views/banners/index.html.erb index 7146e52b66..234aed47de 100644 --- a/app/views/banners/index.html.erb +++ b/app/views/banners/index.html.erb @@ -6,6 +6,7 @@ <% card = "rounded-2xl border border-slate-200 bg-white shadow-sm" %> <% th = "px-4 py-3 text-left text-xs font-semibold text-slate-600 align-top" %> <% td = "px-4 py-3 text-sm text-slate-700 align-top" %> +<% pill = "inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium" %> <%= render layout: "casa_org/settings_frame", locals: {active: "banners"} do %>
@@ -21,9 +22,9 @@ <% if @banners.any? %>
-
Name Created at User count Case contact count
<%= link_to organization.name, all_casa_admins_casa_org_path(organization), class: "font-medium text-brand-600 hover:text-brand-700" %>
- - +
+ + @@ -32,16 +33,30 @@ - + <% @banners.each do |banner| %> - +
Name Active? ExpirationActions
<%= banner.name %><%= banner.active? ? "Yes" : "No" %> + <% if banner.active? %> + Active + <% else %> + Inactive + <% end %> + <%= banner_expiration_time_in_words(banner) %> <%= formatted_name(banner.user.display_name) %> <%= time_ago_in_words(banner.updated_at) %> ago
+ <%# Only one banner can be active, and activating this one deactivates the other + (BannersController#deactivate_alternate_active_banner), so no confirm step. %> + <% unless banner.active? %> + <%= form_with url: banner_path(banner), method: :patch, class: "contents" do %> + <%= hidden_field_tag "banner[active]", 1, id: nil %> + <%= button_tag type: "submit", class: ghost_class do %> Activate<% end %> + <% end %> + <% end %> <%= link_to edit_banner_path(banner), class: ghost_class do %> Edit <% end %> diff --git a/app/views/bulk_court_dates/new.html.erb b/app/views/bulk_court_dates/new.html.erb index 922a114da8..988cc380d5 100644 --- a/app/views/bulk_court_dates/new.html.erb +++ b/app/views/bulk_court_dates/new.html.erb @@ -13,7 +13,7 @@ <%= form_with(model: @court_date, url: bulk_court_dates_path, method: :post, local: true, data: {controller: "court-order-form", nested_form_wrapper_selector_value: ".nested-form-wrapper"}, class: "space-y-6") do |form| %> <%= render "shared/form_errors", resource: @court_date %> -
Create a court date for all cases in a group.
+

Create a court date for all cases in a group.

<%= form.label :case_group_id, "Case group", class: label_class %>
diff --git a/app/views/casa_cases/_court_order_fields.html.erb b/app/views/casa_cases/_court_order_fields.html.erb index 7d3493c208..4a0aaeb945 100644 --- a/app/views/casa_cases/_court_order_fields.html.erb +++ b/app/views/casa_cases/_court_order_fields.html.erb @@ -1,21 +1,41 @@ <%# 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). %> -
+<% label_class = "mb-1 block text-xs font-medium text-slate-500" %> +
-
- <%= f.text_area :text, rows: 2, "aria-label": "Court order text", placeholder: "Describe the court order", +
+ <%= f.label :text, "Court order", class: label_class %> + <%= f.text_area :text, rows: 2, 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"} %> - + + <%# Status + Delete on the line below. The label sits ABOVE the flex line, not inside it, so the + line contains only the select and the button and `items-center` centres Delete on the *control*. + With the label inside the line, any vertical alignment is measured against label+select, so the + button can never sit centred on the field (bottom-aligning it, as this did, only made the two + bottom edges agree). %> +
+ <%= f.label :implementation_status, "Implementation status", class: label_class %> +
+ <%# relative wraps only the select, so the chevron's top-1/2 centres on the control. %> +
+ <%= f.select :implementation_status, court_order_select_options, {include_blank: "Select a 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 index 68a4d7f443..c246bd6d11 100644 --- a/app/views/casa_cases/_court_orders.html.erb +++ b/app/views/casa_cases/_court_orders.html.erb @@ -3,43 +3,44 @@ 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.

- - - -
+<%# One header row: heading + the occasional "copy from another case" action. That action used to be + a labelled select and a Copy button inside a grey bordered panel in the card body -- a nested + surface inside the card (card-in-card) and a second input cluster competing with the real one + (Court order type + Add). The picker now lives in its own Dialog, so the card body is just the + order rows and the add row. %> +
+
+

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", data: {action: "copy-court-orders#open"}, class: button_classes(:secondary) %> -
- +
+ <%= button_tag type: "button", id: "copy-court-button", data: {action: "copy-court-orders#open"}, class: "#{button_classes(:secondary)} shrink-0" do %> + Copy from another case + <% end %>
- <%= render Dialog::HeaderComponent.new(title: "Copy court orders?", icon: "bi bi-files", variant: :info) %> + <%= 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.

+ + <%# Searchable, not a plain select: an org can have hundreds of cases and scrolling a native + dropdown to find one is unusable. Minimal class only -- the tom-select theme owns the + border/padding on .ts-control, and TomSelect copies these classes onto .ts-wrapper, so a + bordered input class here double-borders. NOT dropdownParent: body -- the menu would + render outside the dialog's top layer and be painted behind it. %> + + +

The orders are added to this case. Existing orders are kept.

<% end %> <%= render Dialog::FooterComponent.new do %> @@ -49,15 +50,28 @@
<% end %> +
-
+ + +
+ <%# No per-entry box and no space-y: each entry carries a border-t (first:border-t-0), so the + entries read as a divided list rather than a stack of nested cards. %> +
<%= form.fields_for :case_court_orders do |ff| %> <%= render "casa_cases/court_order_fields", f: ff %> <% end %>
-
+ <%# No rule above the add row: entries are already separated by a hairline of the same weight, so a + second one here read as another entry rather than a section break. Space does the separating + (GOV.UK "add another" puts the control below the list with clear space, not another rule). %> +
<%= label_tag :selectedCourtOrder, "Court order type", class: "mb-1.5 block text-sm font-medium text-slate-700" %>
diff --git a/app/views/casa_cases/_filter.html.erb b/app/views/casa_cases/_filter.html.erb index 15b896d197..cf54c9b6f9 100644 --- a/app/views/casa_cases/_filter.html.erb +++ b/app/views/casa_cases/_filter.html.erb @@ -1,6 +1,7 @@ -<%# 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?. %> +<%# Casa case filter bar (casadesign, bespoke). Server-side controls that submit via the + auto-submit controller: selects on `change`, the search field on debounced `input` (search as you + type). Turbo Drive is OFF app-wide, so each submit is a real page load; the controller restores the + caret from sessionStorage. 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" %> @@ -10,7 +11,7 @@ <%= 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" %> + <%= search_field_tag :search, params[:search], data: {action: "input->auto-submit#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 %> diff --git a/app/views/casa_cases/_placements.html.erb b/app/views/casa_cases/_placements.html.erb index 2cb3617b89..e6db065310 100644 --- a/app/views/casa_cases/_placements.html.erb +++ b/app/views/casa_cases/_placements.html.erb @@ -8,6 +8,6 @@ <% else %>

Unknown

<% end %> -

<%= link_to "See all placements", casa_case_placements_path(casa_case), class: "text-sm font-medium text-brand-600 hover:text-brand-700" %>

+

<%= link_to "See all placements", casa_case_placements_path(casa_case), class: "text-sm font-medium #{record_link_class}" %>

<% end %> diff --git a/app/views/casa_cases/_volunteer_assignment.html.erb b/app/views/casa_cases/_volunteer_assignment.html.erb index 21472ed850..61d4e15c64 100644 --- a/app/views/casa_cases/_volunteer_assignment.html.erb +++ b/app/views/casa_cases/_volunteer_assignment.html.erb @@ -20,7 +20,9 @@
<%= link_to formatted_name(volunteer.display_name), edit_volunteer_path(volunteer, from_case_id: @casa_case.id), class: "text-sm font-medium #{name_link_class}", data: {test: "volunteer-name"} %> -

<%= volunteer.email %>

+ <%# text-sm, not text-xs: an email address is content the user reads and transcribes, not + chrome. 12px is reserved for the status pill and stacked field labels. %> +

<%= volunteer.email %>

<% if assignment.active? %> @@ -37,20 +39,31 @@ <% end %>
-
+ <%# Canonical fact-list tokens (design.md "Fact / detail list", as on the case-details card): + weight on the muted label, colour on the value -- not font-medium slate-700 on both, which + gave the card a third ink and flattened label against value. "Unassigned" only renders + once there is one: an active assignment has no unassigned date, so the pair used to show + as a label and a hanging colon with nothing after it. An inline pair keeps ONE size for + label and value (text-sm) -- 12px/14px on a shared baseline reads as ragged, and the date + is content. The 12px label is for the stacked variant, where it sits above its value. %> +
-
Assigned:
-
<%= I18n.l(assignment.created_at, format: :full, default: nil) %>
-
-
-
Unassigned:
-
<% unless assignment.active? %><%= I18n.l(assignment.updated_at, format: :full, default: nil) %><% end %>
+
Assigned:
+
<%= I18n.l(assignment.created_at, format: :full, default: nil) %>
+ <% unless assignment.active? %> +
+
Unassigned:
+
<%= I18n.l(assignment.updated_at, format: :full, default: nil) %>
+
+ <% end %>
<%= form_with model: assignment, url: reimbursement_case_assignment_path(assignment), method: :patch do |rf| %> -