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/Gemfile b/Gemfile index 8cef6a7763..86d2abe810 100644 --- a/Gemfile +++ b/Gemfile @@ -97,6 +97,7 @@ group :development do end group :test do + gem "axe-core-rspec" # Accessibility (axe-core) assertions for system specs gem "capybara" # Integration testing framework gem "capybara-screenshot" # Automatic screenshot on test failure gem "database_cleaner-active_record" # Database cleaning strategies for tests diff --git a/Gemfile.lock b/Gemfile.lock index ffbfcc4f3f..24251dbe42 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -88,6 +88,10 @@ GEM railties (>= 7.2) warden awesome_print (1.9.2) + axe-core-api (4.12.0) + dumb_delegator + axe-core-rspec (4.12.0) + axe-core-api (= 4.12.0) azure-storage-blob (2.0.3) azure-storage-common (~> 2.0) nokogiri (~> 1, >= 1.10.8) @@ -188,6 +192,7 @@ GEM request_store (>= 1.0) ruby2_keywords drb (2.2.3) + dumb_delegator (1.1.0) email_spec (2.3.1) htmlentities (~> 4.3) launchy (>= 2.1, < 4.0) @@ -707,6 +712,7 @@ DEPENDENCIES amazing_print annotaterb authtrail + axe-core-rspec azure-storage-blob blueprinter brakeman diff --git a/Procfile.dev b/Procfile.dev index 9164cb4634..e9fcf00ccc 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,3 +1,3 @@ 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/application.scss b/app/assets/stylesheets/application.scss deleted file mode 100644 index 76c756054f..0000000000 --- a/app/assets/stylesheets/application.scss +++ /dev/null @@ -1,47 +0,0 @@ -// links to stylesheets inside node_modules -@use "bootstrap/scss/bootstrap.scss" with ( - $primary: #00447c -); -@use "bootstrap-datepicker/dist/css/bootstrap-datepicker"; -@use "datatables.net-dt/css/jquery.dataTables"; -@use "@fortawesome/fontawesome-free/scss/fontawesome"; -@use "@fortawesome/fontawesome-free/scss/solid"; -@use "select2/dist/css/select2"; -@use "tom-select/dist/css/tom-select.bootstrap5.css"; - -// custom css for the app -@use "base/breakpoints"; - -@use "shared/notifier"; -@use "shared/dashboard"; -@use "shared/data_tables_overide"; -@use "shared/flashes"; -@use "shared/footer"; -@use "shared/form"; -@use "shared/layout"; -@use "shared/navbar"; -@use "shared/noscript"; -@use "shared/select2_additional_styles"; -@use "shared/sidebar"; -@use "shared/tomselect_additional_styles.scss"; -@use "shared/typography"; -@use "shared/utilities"; -@use "shared/validated_form_component"; -@use "shared/truncated_text_component"; - -@use "pages/all_casa_admin_dashboard"; -@use "pages/casa_cases"; -@use "pages/casa_org"; -@use "pages/case_contacts"; -@use "pages/case_contacts_form"; -@use "pages/court_dates"; -@use "pages/emancipation"; -@use "pages/login"; -@use "pages/password"; -@use "pages/patch_notes"; -@use "pages/reimbursements"; -@use "pages/supervisors"; -@use "pages/volunteers"; - -@use "trix/dist/trix.css"; -@use "actiontext"; diff --git a/app/assets/stylesheets/base/breakpoints.scss b/app/assets/stylesheets/base/breakpoints.scss deleted file mode 100644 index 180daab2ff..0000000000 --- a/app/assets/stylesheets/base/breakpoints.scss +++ /dev/null @@ -1,6 +0,0 @@ -$small: 640px; -$medium: 1024px; -$large: 1200px; - -// If you adjust this breakpoint, you also need to adjust the breakpoint value in app/javascript/controllers/sidebar-controller.js -$mobile: 768px; diff --git a/app/assets/stylesheets/base/variables.scss b/app/assets/stylesheets/base/variables.scss deleted file mode 100644 index f17900e95a..0000000000 --- a/app/assets/stylesheets/base/variables.scss +++ /dev/null @@ -1,26 +0,0 @@ -$primary: #00447c; -$red: #dc3545; - -// ======== Sidebar colors -$sidebar-inactive: #9AA4CA; -$sidebar-active: #365CF5; -$sidebar-dark: #1A2142; - -:root { - --casa-red: #D50100; - --casa-primary-blue: #4A6CF7; - - --gunmetal: #262D3F; - --dark-gunmetal: #212529; - --dark: #262d3f; - --dark-electric-blue: #5D657B; - --gray: #5d657b; - --dark-gray: #AAAAAA; - --gray-80: #CCCCCC; - --silver: #D9D9D9; - - --inactive: #9AA4CA; - - --input-placeholder-text: var(--gray); - --input-border-color: var(--gray-80); -} diff --git a/app/assets/stylesheets/pages/all_casa_admin_dashboard.scss b/app/assets/stylesheets/pages/all_casa_admin_dashboard.scss deleted file mode 100644 index 44d39c7adb..0000000000 --- a/app/assets/stylesheets/pages/all_casa_admin_dashboard.scss +++ /dev/null @@ -1,41 +0,0 @@ -.dashboard, .all_casa_admins-dashboard { - .container { - @media (max-width: 1200px) { - max-width: 95%; - } - } - - .dashboard-table-header { - h1 { - display: inline-block; - margin-right: 15px; - vertical-align: middle; - } - - a { - display: inline-block; - } - } - - .cases { - background: lightgrey; - padding: 10px; - } - - .dropdown { - margin-left: 8px; - } - - .button { - border: 1px black solid; - padding: 10px; - background-color: #00447c; - } - - .button > a { - color: white; - &:visited { - color: white; - } - } -} diff --git a/app/assets/stylesheets/pages/casa_cases.scss b/app/assets/stylesheets/pages/casa_cases.scss deleted file mode 100644 index 1daaf9254b..0000000000 --- a/app/assets/stylesheets/pages/casa_cases.scss +++ /dev/null @@ -1,231 +0,0 @@ -@use "../base/breakpoints.scss" as screen-sizes; - -body.casa_cases { - .form-header { - h1 { - display: inline-block; - margin-right: 15px; - vertical-align: middle; - } - - a { - display: inline-block; - } - } - - .a2cldr { - width: 190px; - } - - .a2cldr .a2cldr-btn { - width: 190px; - text-align: center; - } - - .a2cldr-list { - position: absolute; - width: 100%; - } - .cal-btn { - display: inline-block; - } - - .court-date-row { - display: flex; - align-items: center; - gap: 10px; - - .court-date-link { - flex: 1; - } - - .cal-btn { - flex-shrink: 0; - - .a2cldr { - width: 120px !important; - - .a2cldr-btn { - width: 120px !important; - font-size: 12px; - padding: 4px 8px; - text-align: center; - } - } - } - } - - #cc-check { - p { - display: flex; - align-items: center; - } - - #with_cc { - margin-right: 5px; - } - } - - #reminder_button { - margin-left: 30px; - margin-bottom: 0; - } - - .case-contact-list { - margin-bottom: 20px; - } - - .datetime-year-month select.date-input { - width: 130px; - padding-bottom: 15px; - } - - .court-orders { - textarea { - display: block; - margin-bottom: 5px; - } - - textarea, .add-court-order { - width: 500px; - } - - .implementation-status { - align-self: flex-start; - min-height: 38px; - } - - .remove-court-order-button { - align-self: flex-start; - } - } - - .court-order-entry { - display: flex; - gap: 8px; - align-items: center; - } - - select { - padding: 5px; - } - - .emancipation-btn { - padding: 5px 10px; - font-size: 10px; - line-height: 16px; - vertical-align: middle; - } -} - -@media only screen and (max-width: screen-sizes.$small) { - body.casa_cases { - .court-orders { - textarea, .add-court-orders { - width: 100%; - } - } - - .court-order-entry { - display: grid; - grid-template-columns: minmax(100px, 100%) 1fr; - gap: 2px; - - .implementation-status { - grid-row: 2; - } - - margin: 1em 1em 2em 1em; - } - } -} - -body.casa_cases-show { - .past-court-dates.add-container { - display: block; - } -} - -#generate-docx-report-modal { - .dates-container { - display: flex; - } - - .docx-report__modal-body { - display: flex; - flex-direction: column; - row-gap: 1rem; - } - - .modal-footer { - [data-bs-dismiss="modal"] { - padding-block: 11px; - } - } -} - -.generate-report-button { - display: flex; - flex-direction: row; - width: 100%; - border: solid 1px var(--silver); - padding: 1.2rem; - column-gap: 1.2rem; - - &:hover { - border-color: black; - } - - svg { - min-inline-size: 40px; - width: 40px; - height: auto; - } - - div { - text-align: left; - h3 { - margin-block-end: .25rem; - } - p { - } - } -} - -.card-text { - ul { - display: flex; - flex-direction: column; - gap: .2rem; - } -} - -#case-selection:required, .select2.select2-container { - background: transparent; - border: 1px solid #e5e5e5; - border-radius: 10px; - padding: 8px; - appearance: none; - -webkit-appearance: none; - -moz-appearance: none; -} - -// removes select2 dropdown default border -span.select2-selection.select2-selection--single { - border: none; -} - -// removes select2 dropdown default arrow -.select2-selection__arrow { - display: none; -} - -.select-wrapper { - display: flex; - flex-direction: column; - gap: 8px; -} - -span[role=combobox] { - padding-top: 2px; -} diff --git a/app/assets/stylesheets/pages/casa_org.scss b/app/assets/stylesheets/pages/casa_org.scss deleted file mode 100644 index 103c5c3526..0000000000 --- a/app/assets/stylesheets/pages/casa_org.scss +++ /dev/null @@ -1,4 +0,0 @@ -#casa_org_logo{ - height: 2.7rem; - overflow: hidden; -} diff --git a/app/assets/stylesheets/pages/case_contacts.scss b/app/assets/stylesheets/pages/case_contacts.scss deleted file mode 100644 index 43ef3bc6b7..0000000000 --- a/app/assets/stylesheets/pages/case_contacts.scss +++ /dev/null @@ -1,181 +0,0 @@ -.slide-container { - padding: 0rem 1rem; -} - -.slider { - display: inline; -} - -.slider-row { - margin: 0px; -} - -.dataTables_scrollHeadInner { - width: 100% !important; -} - -.followup-button { - min-width: max-content; -} - -.flex-container { - position: relative; - display: flex; - flex-direction: column; - min-width: 0; - word-wrap: break-word; -} - -.field-card { - background-color: #fff; - background-clip: border-box; - border: 1px solid rgba(0,0,0,0.125); - border-radius: .25rem; - padding: 1rem; - box-shadow: 0 4px 12px 5px rgba(0, 0, 0, 0.08); -} - -.pr-6 { - padding-right: 1.5rem !important; -} - -.red-letter { - color: #d50100; - font-weight: 900; - display: inline; -} - -.label-font-weight { - font-weight: 900; -} - -.cc-field { - height: calc(1.5em + .75rem + 2px); - padding: .375rem .75rem; - font-size: 1rem; - line-height: 1.5; - color: #495057; - background-color: #fff; - background-clip: padding-box; - border: 1px solid #ced4da; - border-radius: .25rem; - transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out; -} - -.expense-container { - width: 100%; - display: flex; - flex-direction: row; - align-items: center; - gap: .5rem; - justify-content: flex-end; -} - -.expense-container { - & > *:last-child { - width: 80%; - }; - .field_with_errors { - input { - width: 100%; - } - } - } - -.other-expenses li { - margin-bottom: .2rem; -} - -.other-expense-amount { - width: 20%; - margin-left: .4rem; - color: #5D657B !important; -} - -.add-expense-link { - margin-top: -.5rem; - margin-bottom: .5rem; - display: block; - padding-left: .8rem; - text-decoration: underline; -} - -#add-another-expense { - cursor: pointer; -} - -.dollar-sign { - margin: 0px 10px 0 0; - display: flex; - align-items: center; - } - -.dollar-sign .other-expense-amount { - position: relative; - padding-left: 32px; -} - -.dollar-sign input::placeholder { - color: #5D657B; -} - -.dollar-sign:before { - position: absolute; - top: 17px; - content: "$"; - left: 28px; - z-index: 100; -} - -.pr-7 { - padding-right: 1.5rem !important; - padding-left: 2.5rem !important; -} - -.pr-9 { - padding-left: 3.1rem !important; -} - -.wide_button { - width: 100%; - height: 3rem; - font-weight: 900; -} - -.cc-italic { - font-style: italic; -} - -.expand-toggle { - display: inline-block; - cursor: pointer; - background: none; - border: 0; - padding: 0; - transition: transform 0.3s; - - &.expanded { - transform: rotate(180deg); - } -} - -.expanded-content { - padding: 0.75rem 1rem; - - .expanded-topic { - margin-bottom: 0.75rem; - - strong { - display: block; - } - - p { - margin: 0.25rem 0 0; - } - } -} - -.expanded-empty { - padding: 0.75rem 1rem; - margin: 0; -} diff --git a/app/assets/stylesheets/pages/case_contacts_form.scss b/app/assets/stylesheets/pages/case_contacts_form.scss deleted file mode 100644 index 5f0121391d..0000000000 --- a/app/assets/stylesheets/pages/case_contacts_form.scss +++ /dev/null @@ -1,151 +0,0 @@ -@use "../base/breakpoints.scss" as screen-sizes; - -#case-contact-form { - color: var(--gunmetal); - - & section { - background: var(--white, #FFFFFF); - border: 1px solid var(--form-element-stroke, #E5E5E5); - border-radius: 10px; - box-shadow: 0px 0px 5px 0px #1A252F0D; - margin-bottom: 30px; - padding: 30px 20px; - } - - & h2 { - font-size: 28px; - margin-bottom: 24px; - } - - & h3 { - font-size: 16px; - } - - & legend { - font-size: 16px; - font-weight: 400; - line-height: 19px; - color: var(--dark-electric-blue); - } - - & label { - font-size: 16px; - font-weight: 500; - line-height: 19px; - } - - & input, - textarea, - select { - border: 1px solid var(--input-border-color); - - &::placeholder { - color: var(--input-placeholder-text); - opacity: 1; - } - } - - & input[type="text"], - input[type="date"], - input[type="number"], - textarea, - select { - padding: 16px; - } - - & .form-check { - margin-left: 0px; - padding-left: 0px; - - & input { - width: 20px; - height: 20px; - margin-left: 0px; - margin-right: 3px; - float: none; - - &:checked { - background-color: var(--casa-primary-blue); - border-color: var(--casa-primary-blue); - } - - &[type="checkbox"] + label { - padding-top: 5px; - font-weight: 300; - } - - &[type="radio"] + label { - padding-top: 1px; - font-size: 16px; - font-weight: 300; - line-height: 25px; - color: var(--dark-electric-blue) - } - } - - label { - font-size: 16px; - font-weight: 600; - } - } - - & button { - --bs-link-color: var(--casa-primary-blue); - - &.btn-link { - text-decoration: none; - padding: 0px; - } - } - - & .row { - --bs-gutter-x: .5rem; - } - - & #contact-form-types { - --group-width: 250px; - - columns: var(--group-width); - - & fieldset { - display: inline-block; - width: var(--group-width); - margin-bottom: .25rem; - - & .form-check { - & label { - display: inline; - color: var(--dark); - font-weight: 400; - color: var(--dark-gunmetal); - } - - & small { - display: block; - margin-left: 28px; - font-style: italic; - font-weight: 300; - color: var(--dark-electric-blue); - } - } - } - } - - & .input-group-text { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } - - & #contact-form-action-buttons { - display: flex; - justify-content: flex-end; - gap: 10px; - - @media only screen and (max-width: screen-sizes.$mobile) { - & > a, & > button { - flex-grow: 1; - } - } - } -} - diff --git a/app/assets/stylesheets/pages/case_court_reports.scss b/app/assets/stylesheets/pages/case_court_reports.scss deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/app/assets/stylesheets/pages/court_dates.scss b/app/assets/stylesheets/pages/court_dates.scss deleted file mode 100644 index 4d0299361c..0000000000 --- a/app/assets/stylesheets/pages/court_dates.scss +++ /dev/null @@ -1,22 +0,0 @@ -body.court_dates { - .court-orders { - textarea { - display: block; - margin-bottom: 5px; - } - - textarea, .add-court-order { - width: 500px; - } - } - - .court-order-entry { - display: flex; - gap: 8px; - align-items: center; - } - - select { - padding: 5px; - } -} diff --git a/app/assets/stylesheets/pages/emancipation.scss b/app/assets/stylesheets/pages/emancipation.scss deleted file mode 100644 index 7064864df2..0000000000 --- a/app/assets/stylesheets/pages/emancipation.scss +++ /dev/null @@ -1,82 +0,0 @@ -.category-options { - margin-left: 2em; - - input[type="checkbox"], input[type="radio"] { - pointer-events: none - } -} - -.check-item.disabled { - cursor: default; - - label { - color: #757575 - } -} - -.check-item { - label { - width: 80%; - vertical-align: middle; - } -} - -.emancipation-category { - border-bottom: 2px solid #bfe3ff; - margin: 0; - padding: 0 .5em; - display: flex; - justify-content: space-between; - align-items: center; - - input[type="checkbox"] { - pointer-events: none - } - - label { - margin: 0; - padding: .5em; - vertical-align: middle; - } - - span { - color: #00447c; - font-weight: bold; - font-size: 18pt - } -} - -.emancipation-category:hover { - background-color: #f5f5f5; - cursor: pointer; - - input[type="checkbox"], label{ - cursor: pointer - } -} - -.emancipation-category.disabled { - cursor: default; - - span, label{ - color: #757575 - } -} - -.emancipation-category.disabled:hover{ - background-color: unset -} - -.no-select { - -webkit-touch-callout: none; /* iOS Safari */ - -webkit-user-select: none; /* Safari */ - -khtml-user-select: none; /* Konqueror HTML */ - -moz-user-select: none; /* Old versions of Firefox */ - -ms-user-select: none; /* Internet Explorer/Edge */ - user-select: none; /* Non-prefixed version, currently - supported by Chrome, Edge, Opera and Firefox */ -} - -.emacipation-category-input-label-pair { - display: flex; -} diff --git a/app/assets/stylesheets/pages/feature_flags.scss b/app/assets/stylesheets/pages/feature_flags.scss deleted file mode 100644 index cc0fd1fef4..0000000000 --- a/app/assets/stylesheets/pages/feature_flags.scss +++ /dev/null @@ -1,9 +0,0 @@ -#feature-flags { - padding-top: 1em; - h1 { - padding-bottom: 1em; - } - .flag-name { - padding-right: 2em; - } -} \ No newline at end of file diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss deleted file mode 100644 index 0191146c8d..0000000000 --- a/app/assets/stylesheets/pages/login.scss +++ /dev/null @@ -1,17 +0,0 @@ -body.devise-sessions-new, body.users-sessions-new, body.devise-passwords-new { - .container { - max-width: none; - } - - .login-email-link { - font-size: small; - } - - aside { - background-position: right; - background-image: url("login.jpg"); - min-height: 700px; - background-repeat: no-repeat; - background-size: cover; - } -} diff --git a/app/assets/stylesheets/pages/password.scss b/app/assets/stylesheets/pages/password.scss deleted file mode 100644 index d9df2fba24..0000000000 --- a/app/assets/stylesheets/pages/password.scss +++ /dev/null @@ -1,54 +0,0 @@ -@use "../base/variables.scss" as globals; - -.password-box { - width: 80%; - margin: 10% auto; - text-align: center; - border-radius: 25px; - border: 2px solid globals.$primary; - - form#edit_user.edit_user div.field { - margin: 0 auto; - width: 90%; - max-width: 90%; - flex: none; - } - - input#user_password { - margin: 10px 0 10px; - width: 100%; - } - - input#user_password_confirmation { - margin: 10px 0 10px; - width: 100%; - } - - form#edit_user.edit_user div.actions input { - color: #fff; - background-color: #002f56; - border-color: #002849; - margin: 10px; - } -} - -body.devise-invitations.devise-invitations-edit footer { - width: 100%; -} - -body.devise-passwords.devise-passwords-edit div.password-box { - div.row { - margin: 0 0; - - div.col-sm-6 { - flex: none; - margin: 0 auto; - max-width: 90%; - padding: 0; - } - } - - a.btn.btn-info { - margin-bottom: 10px; - } -} diff --git a/app/assets/stylesheets/pages/patch_notes.scss b/app/assets/stylesheets/pages/patch_notes.scss deleted file mode 100644 index 60d112b3d2..0000000000 --- a/app/assets/stylesheets/pages/patch_notes.scss +++ /dev/null @@ -1,53 +0,0 @@ -#patch-notes { - display: flex; - - #patch-note-list { - width: 50%; - - h1 { - text-align: left; - } - - .patch-note-list-item { - & .card-body>* { - margin: 0.25em 0; - } - - &.new { - background-color: #ffecb3; - } - - textarea { - width: 100%; - } - - .label-and-select { - display: flex; - - label { - padding-right: 0.5rem; - } - - select { - flex-grow: 1 - } - } - - .patch-note-button-controls { - display: flex; - - button { - flex-grow: 1; - - &:nth-child(2) { - margin-left: 0.5em - }; - - &.button-cancel { - flex-grow: 3 - } - } - } - }; - }; -} diff --git a/app/assets/stylesheets/pages/reimbursements.scss b/app/assets/stylesheets/pages/reimbursements.scss deleted file mode 100644 index f913149528..0000000000 --- a/app/assets/stylesheets/pages/reimbursements.scss +++ /dev/null @@ -1,9 +0,0 @@ -.reimbursements-index { - .select2-container { - .select2-selection { - max-height: 60px; - overflow-y: auto; - overflow-x: hidden; - } - } -} diff --git a/app/assets/stylesheets/pages/supervisors.scss b/app/assets/stylesheets/pages/supervisors.scss deleted file mode 100644 index f631eba28c..0000000000 --- a/app/assets/stylesheets/pages/supervisors.scss +++ /dev/null @@ -1,84 +0,0 @@ -$indictator_negative_color: #f44336; -$indicator_positive_color: #4caf50; - -.supervisor_case_contact_stats { - display: flex; - - div { - &:first-child { - display: flex; - margin-right: 1em; - flex-grow: 1; - } - - span { - border-radius: 0.25em; - padding-top: .25em; - text-align: center; - - &.attempted-contact { - background-color: $indicator_positive_color; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - - & + span.no-attempted-contact { - border-top-left-radius: 0; - border-bottom-left-radius: 0; - } - - & + span.attempted-contact-end { - background-color: $indicator_positive_color; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - width: 0.25em; - } - } - - &.no-attempted-contact { - background-color: $indictator_negative_color; - } - - &.no-volunteers { - background-color: #ccc; - } - - &:last-child { - margin-left: 0.5em; - background-color: #cfd8dc; - border-radius: 100vw; - padding: 0.2em 1em; - } - } - } - - &.legend { - align-items: center; - margin-bottom: 0.25rem; - margin-top: 1em; - - span { - &:last-child { - padding: .5em; - } - - padding: .5em 1em; - flex-grow: 1; - } - } -} - -.supervisor_table_row { - display: flex; - - td:nth-of-type(1), th:nth-of-type(1) { - flex: 3; - } - - td:nth-of-type(2), th:nth-of-type(2) { - flex: 3; - } - - td:nth-of-type(3), th:nth-of-type(3) { - flex: 1; - } -} diff --git a/app/assets/stylesheets/pages/volunteers.scss b/app/assets/stylesheets/pages/volunteers.scss deleted file mode 100644 index 4f5c8dd8c4..0000000000 --- a/app/assets/stylesheets/pages/volunteers.scss +++ /dev/null @@ -1,35 +0,0 @@ -@use "../base/breakpoints.scss" as screen-sizes; - -body.volunteers { - @media only screen and (max-width: screen-sizes.$mobile) { - table#volunteers { - tbody tr { - padding-bottom: 2.5rem; - border-bottom: none; - - td { - border-left: none; - border-right: none; - } - td:last-child { - border-bottom: none; - } - } - } - } -} -table#volunteers.dataTable.hover tbody tr.selected > * { - background-color: rgba(54, 92, 245, 0.1); - box-shadow: none; - color: inherit; -} -table#volunteers.dataTable.hover > tbody > tr.selected:hover > * { - background-color: rgba(54, 92, 245, 0.15); - box-shadow: none !important; - color: inherit; -} - -.form-check-input { - border: 1px solid #757575; -} - diff --git a/app/assets/stylesheets/shared/dashboard.scss b/app/assets/stylesheets/shared/dashboard.scss deleted file mode 100644 index fc6ff4896c..0000000000 --- a/app/assets/stylesheets/shared/dashboard.scss +++ /dev/null @@ -1,35 +0,0 @@ -.dashboard-table-header { - h1,h2,h3,h4,h5,h6 { - display: inline-block; - margin-right: 15px; - vertical-align: middle; - } - - a.btn { - display: inline-block; - } -} - -.dropdown-menu { - li { - input[type="checkbox"] { - transform: scale(1.5); - margin: 0 8px; - } - &:not(:last-child) { - margin-bottom: 7px; - } - } - padding: 8px; -} - -.supervisor-options { - min-width: 15rem; -} - -.language-icon { - cursor: context-menu; - font-size: 1.5rem; - display: flex; - justify-content: center; -} diff --git a/app/assets/stylesheets/shared/data_tables_overide.scss b/app/assets/stylesheets/shared/data_tables_overide.scss deleted file mode 100644 index abef82dab0..0000000000 --- a/app/assets/stylesheets/shared/data_tables_overide.scss +++ /dev/null @@ -1,11 +0,0 @@ -/** - Needed to display the prev, next, number, and ellipsis buttons on the same line. This causes issues on mobile with - lots of pages, but there is no current way to change the number of pages in a view or in JS based on window size. - */ -.dataTables_paginate { - white-space: nowrap !important; - - span { - display: inline-block !important; - } -} diff --git a/app/assets/stylesheets/shared/flashes.scss b/app/assets/stylesheets/shared/flashes.scss deleted file mode 100644 index dce6aede63..0000000000 --- a/app/assets/stylesheets/shared/flashes.scss +++ /dev/null @@ -1,10 +0,0 @@ -.notice { - text-align: center; - padding: 8px; - margin: 12px 0; - list-style-position: inside; -} - -.alert-dismissible .close { - padding: 0.5rem 0.75rem; -} diff --git a/app/assets/stylesheets/shared/footer.scss b/app/assets/stylesheets/shared/footer.scss deleted file mode 100644 index e3c5669073..0000000000 --- a/app/assets/stylesheets/shared/footer.scss +++ /dev/null @@ -1,95 +0,0 @@ -//@use "../base/variables" as globals; -//@use "../base/breakpoints.scss" as screen-sizes; -// -//footer { -// padding: 2rem 0; -// text-align: center; -// background-color: globals.$primary; -// color: #fff; -// -// .default-footer { -// a.rfglink { -// color: #fff; -// } -// -// .rfglogo { -// max-height: 3em; -// } -// } -// .terms-conditions-link { -// color:#fff; -// } -// -// .footer-logos { -// .rfglink { -// .rfglogo { -// max-height: 6em; -// } -// } -// } -// -// ul { -// list-style: none; -// padding-left: 0; -// margin-top: 2rem; -// } -// -// li { -// display: inline-block; -// -// a, a:hover { -// color: white; -// } -// } -// -// li:not(:first-child):before { -// content: " | "; -// } -// -// .address { -// margin-bottom: 2rem; -// } -// -// .copyright { -// font-size: 18px; -// font-weight: 700; -// } -//} -// -//.footer.container-fluid { -// min-height: calc(100vh - 45px); -//} -// -//footer.logged-in { -// position: relative; -// height: 40px; -// left: 0px; -// bottom: 0px; -// background: #F8F8F8; -// box-shadow: 0px -2px 12px rgba(0, 0, 0, 0.25); -// padding: 2rem; -// padding-top: 1rem; -// padding-left: 2%; -// margin-left: 320px; -// width: calc(100% - 320px); -// -// span { -// font-family: 'Montserrat'; -// font-style: normal; -// font-weight: 400; -// font-size: 14px; -// line-height: 140%; -// color: #2E2E2E; -// } -// -// a { -// white-space: nowrap; -// } -// -// @media only screen and (max-width: screen-sizes.$mobile) { -// width: 100%; -// margin-left: 0; -// padding-bottom: 1rem; -// height: 100px; -// } -//} diff --git a/app/assets/stylesheets/shared/form.scss b/app/assets/stylesheets/shared/form.scss deleted file mode 100644 index 35f3aae004..0000000000 --- a/app/assets/stylesheets/shared/form.scss +++ /dev/null @@ -1,35 +0,0 @@ -@use "../base/variables.scss" as globals; - -form { - .field_with_errors { - label { - @extend .text-danger !optional; - @extend .font-weight-bold !optional; - } - - input { - border: 1px solid globals.$red; - } - - } - - .read-more { - display: inline; - } - - .alert.alert-danger { - ul { - margin-bottom: 0; - } - } - - #with_cc { - margin-left: 20px; - } -} - -#cc-check { - margin-left: 30px; - display: initial; -} - diff --git a/app/assets/stylesheets/shared/header.scss b/app/assets/stylesheets/shared/header.scss deleted file mode 100644 index b4902edd78..0000000000 --- a/app/assets/stylesheets/shared/header.scss +++ /dev/null @@ -1,23 +0,0 @@ -@use "../base/breakpoints.scss" as screen-sizes; - -.header { - .header-left { - .menu-toggle-btn { - display: none; - - .main-btn { - border-radius: 4px !important; - } - } - } -} - -@media only screen and (max-width: screen-sizes.$mobile) { - .header { - .header-left { - .menu-toggle-btn { - display: block; - } - } - } -} diff --git a/app/assets/stylesheets/shared/layout.scss b/app/assets/stylesheets/shared/layout.scss deleted file mode 100644 index 24eab988a2..0000000000 --- a/app/assets/stylesheets/shared/layout.scss +++ /dev/null @@ -1,112 +0,0 @@ -@use "../base/variables.scss" as globals; -@use "../base/breakpoints.scss" as screen-sizes; - -body { - background-color: #f4f7fa; - min-width: 320px; -} - -.content { - margin-left: 320px; - padding: 2%; - position: relative; - overflow: auto; -} - -.casa-case-scroll { - width: 13rem; - height: auto; - overflow-y: auto; -} - -// This may effect other pages than the case contact form!!! -h5 label { - font-weight: 500; -} - - -.unbold { - font-weight: 100; -} - -.fa-car { - color: globals.$primary; -} - -div.row div.col-sm-12.form-header h1 { - display: inline-block; - margin-right: 15px; - vertical-align: middle; -} - -div.row.volunteer-filters { - margin-top: 2rem; -} - -.card.card-container { - box-shadow: 0 4px 12px 5px rgba(0, 0, 0, 0.08); - margin-bottom: 1.5rem; -} - -.casa-case-button { - margin: 0 10px 10px 0; -} - -.card-title { - &__hint { - font-size: 11px; - font-style: italic; - } -} - -/* ASSUMPTION: default screen size is desktop view (> 1024px) */ - -/* As 'mobile-label' class is mobile only, not display them on desktop view */ - -@media only screen and (min-width: screen-sizes.$mobile) { - table.table { - span.mobile-label { - display: none; - } - } -} - -#dropdownMenuButton { - margin-bottom: 0.5em; -} - -@media only screen and (max-width: screen-sizes.$mobile) { - .login-header { - font-size: 0.75em !important; - } - .content { - padding: 3rem 0; - margin-left: 0; - } - .card-body { - padding: 0.5rem; - } - table.table { - thead { - display: none; - } - tr { - display: table; - width: 100%; - border-bottom: 1px solid #efefef; - > * { - display: block; - } - } - th, - td { - border-top: none; - font-size: 1.25rem; - span.mobile-label { - color: #757575; - display: block; - font-size: 0.9rem; - } - } - } -} diff --git a/app/assets/stylesheets/shared/navbar.scss b/app/assets/stylesheets/shared/navbar.scss deleted file mode 100644 index 5f0b51ee49..0000000000 --- a/app/assets/stylesheets/shared/navbar.scss +++ /dev/null @@ -1,33 +0,0 @@ -@use "../base/breakpoints.scss" as screen-sizes; - -.navbar { - color: #fff; - background: #00447c; - box-shadow: 0 0 4px 4px rgba(0,0,0,0.28); - height: 80px; - justify-content: space-between; - padding: 16px; - width: 100%; - z-index: 3; -} - -.navbar strong { - font-size: 2rem; -} - -.navbar.mobile { - display: none; -} - -.mobile-icon { - top: -2px; - width: 40px; -} - -@media only screen and (max-width: screen-sizes.$mobile) { - .navbar.mobile { - display: flex; - position: sticky; - top: 0; - } -} diff --git a/app/assets/stylesheets/shared/notifier.scss b/app/assets/stylesheets/shared/notifier.scss deleted file mode 100644 index d70c895610..0000000000 --- a/app/assets/stylesheets/shared/notifier.scss +++ /dev/null @@ -1,98 +0,0 @@ -@keyframes spin { - 0% { - transform: rotate(0deg) - } - 100% { - transform: rotate(360deg) - } -} - -#notifications { - align-items: end; - bottom: 0; - display: flex; - flex-direction: column; - font-weight: 900; - max-height: 100vh; - max-width: 40%; - overflow-y: auto; - pointer-events: none; - position: fixed; - right: 2em; - z-index: 100; - - &>* { - pointer-events: auto; - } - - div { - border-radius: .25em; - margin-bottom: .5em; - margin-left: auto; - margin-right: 0; - padding: .5em; - - span { - display: inline - } - } - - .danger-notification { - background-color: #b71c1c; - color: white - } - - .messages { - margin-bottom: 0; - padding: 0; - pointer-events: none; - - &>* { - pointer-events: auto; - } - - div { - display: flex; - width: fit-content; - } - } - - .success-notification { - background-color: #28a745; - color: white - } - - .warning-notification { - background-color: #ffc107; - } - - #async-waiting-indicator { - background-color: #007bff; - color: white; - - .load-spinner { - animation: spin 1.5s linear infinite; - border: .25em solid #f3f3f3; - border-top: .25em solid #3498db; - border-radius: 50%; - display: inline-block; - height: 1em; - margin: 0; - padding: 0; - width: 1em - } - } - - #toggle-minimize-notifications { - background-color: #333; - border: 0; - border-radius: 10px 10px 0 0; - color: white; - padding: 0.25em 1em; - width: fit-content; - - span { - display: inline; - } - } -} diff --git a/app/assets/stylesheets/shared/select2_additional_styles.scss b/app/assets/stylesheets/shared/select2_additional_styles.scss deleted file mode 100644 index 8c7ed119e4..0000000000 --- a/app/assets/stylesheets/shared/select2_additional_styles.scss +++ /dev/null @@ -1,5 +0,0 @@ -.select2 { - input { - min-width: 250px; - } -} diff --git a/app/assets/stylesheets/shared/sidebar.scss b/app/assets/stylesheets/shared/sidebar.scss deleted file mode 100644 index b7a044a55d..0000000000 --- a/app/assets/stylesheets/shared/sidebar.scss +++ /dev/null @@ -1,213 +0,0 @@ -@use "../base/variables.scss" as globals; -@use "../base/breakpoints.scss" as screen-sizes; - -#wrapper { - overflow-x: hidden; - } - -.sidebar-wrapper { - background: rgba(0,0,0,0.25); - display: flex; - flex-direction: column; - max-height: 100vh; - position: fixed; - width: 320px; - overflow: auto; -} - -.sidebar-wrapper .sidebar-heading { - position: sticky; - top: 0; - z-index: 10; - background: globals.$primary; - border-bottom: 1px solid #103862; - font-size: 1.2rem; - padding-bottom: 16px; - padding: 0.875rem 1.25rem; -} - -.sidebar-wrapper .list-group { - width: 100%; - .list-group-item { - background: none; - color: #fff; - padding: 1.25rem 1.75rem; - text-decoration: none; - - &:hover { - text-decoration: none; - } - } -} - -#page-content-wrapper { - width: 100%; -} - -.sidebar-container { - background: globals.$primary; - display: flex; - flex-direction: column; - justify-content: space-between; - height: 100vh; - overflow-y: auto; -} - -.logo-div { - height: 80px; -} - -// Stops weird text wrapping when opening and closing sidebar -.nav-item { - width: 250px; -} - -.nav-item > a { - color: globals.$sidebar-inactive !important; - height: 44px; - - &:hover { - color: globals.$sidebar-active !important; - - span { - color: globals.$sidebar-dark !important; - } - } -} - -.nav-item.active > a { - color: globals.$sidebar-dark !important; -} - -.btn-sidebar { - color: #fff !important; - background-color: globals.$sidebar-active !important; -} - -.sidebar-wrapper .sidebar-menu { - margin-top: 24px; - .list-group-item { - border: none; - border-left: 6px solid transparent; - padding: 1.25rem 1.75rem; - a { - color: #fff; - text-decoration: none; - } - &:hover { - border-left: 6px solid #fff; - } - &.active { - background: #103862; - border-left: 6px solid #fff; - font-weight: bold; - } - } -} - -.sidebar-wrapper .sidebar-footer { - .list-group.account-details { - padding-bottom: 1rem; - .list-group-item { - border: none; - padding: 1rem 2rem; - > * { - font-size: 0.9em; - } - .label { - color: #729DCB; - display: block; - - } - .value, a { - color: #fff; - } - } - } - - .list-group.footer-nav { - background: #103862; - padding: 1rem 0 5rem; - width: 100%; - a.list-group-item { - background: none !important; - border: none; - color: #fff; - font-size: 0.9em; - padding: 1rem 1.75rem; - text-decoration: none; - - &:hover { - text-decoration: underline; - } - } - } -} - -.group-actions { - &:hover::before { - opacity: 1 !important; - visibility: visible !important; - } - &::before { - opacity: 0 !important; - visibility: hidden !important; - } -} -// Media Queries -@media only screen and (max-width: screen-sizes.$mobile) { - .sidebar-wrapper { - display: none; - width: 100%; - &.sidebar-open { - display: inherit; - z-index: 1; - } - } - - .sidebar-container { - height: calc(100vh - 72px); - width: 320px; - } - - .sidebar-heading { - display: none; - } - - #wrapper.toggled .sidebar-wrapper { - margin-left: -15rem; - } - - .footer-nav #mobile-space { - margin-top: 72px; - } -} - -// These overrides are for larger than mobile screens -@media only screen and (min-width: screen-sizes.$mobile) { - .sidebar-nav-wrapper { - -webkit-transform: translateX(0px) !important; - -moz-transform: translateX(0px) !important; - -ms-transform: translateX(0px) !important; - -o-transform: translateX(0px) !important; - transform: translateX(0px) !important; - - &.active { - width: 66px; - - -webkit-transform: translateX(0px) !important; - -moz-transform: translateX(0px) !important; - -ms-transform: translateX(0px) !important; - -o-transform: translateX(0px) !important; - transform: translateX(0px) !important; - } - } - - .main-wrapper { - margin-left: 250px !important; - - &.active { - margin-left: 66px !important; - } - } -} diff --git a/app/assets/stylesheets/shared/tomselect_additional_styles.scss b/app/assets/stylesheets/shared/tomselect_additional_styles.scss deleted file mode 100644 index 81817fd772..0000000000 --- a/app/assets/stylesheets/shared/tomselect_additional_styles.scss +++ /dev/null @@ -1,3 +0,0 @@ -.ts-dropdown [data-selectable] .highlight { - display: inline-block; -} \ No newline at end of file diff --git a/app/assets/stylesheets/shared/truncated_text_component.scss b/app/assets/stylesheets/shared/truncated_text_component.scss deleted file mode 100644 index fd537a2c3e..0000000000 --- a/app/assets/stylesheets/shared/truncated_text_component.scss +++ /dev/null @@ -1,8 +0,0 @@ -.truncation-container { - display: flex; - - a { - min-width: fit-content; - margin-inline-start: auto; - } -} diff --git a/app/assets/stylesheets/shared/typography.scss b/app/assets/stylesheets/shared/typography.scss deleted file mode 100644 index 7a5ec90bb8..0000000000 --- a/app/assets/stylesheets/shared/typography.scss +++ /dev/null @@ -1,37 +0,0 @@ -// TODO check in css so tests can run without internet -@use "../base/variables.scss" as globals; -@import url(https://fonts.googleapis.com/css?family=Montserrat:400,700); - -body { - font-family: "Montserrat"; -} - -option { - font-family: Helvetica, Arial, sans-serif; -} - -.content-1 { - font-family: Inter; - font-size: 16px; - font-weight: 500; - line-height: 19px; - color: var(--dark); -} - -.content-2 { - font-family: Inter; - font-size: 14px; - font-weight: 500; - line-height: 22px; - color: var(--gray) -} - -.content-3 { - font-family: Inter; - font-size: 14px; - font-weight: 400; -} - -.pre-line { - white-space: pre-line; -} diff --git a/app/assets/stylesheets/shared/utilities.scss b/app/assets/stylesheets/shared/utilities.scss deleted file mode 100644 index 896b6ff9ee..0000000000 --- a/app/assets/stylesheets/shared/utilities.scss +++ /dev/null @@ -1,46 +0,0 @@ -@use "../base/variables.scss" as globals; - -.pull-left { - float: left; -} - -.pull-right { - float: right; -} - -.vertically-center { - display: flex; - align-items: center; -} - -.add-container { - display: flex; - justify-content: flex-start; - align-items: center; - gap: 10px; - - .add-button { - color: white; - border: none; - background-color: #{globals.$primary}; - - padding: 0.25em 1.5em; - border-radius: 10px; - font-size: 1.25em; - - :hover { - cursor: pointer; - } - } -} - -.hidden { - display: none; -} - -.line-clamp-1 { - overflow: hidden; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 1; -} diff --git a/app/assets/stylesheets/shared/validated_form_component.scss b/app/assets/stylesheets/shared/validated_form_component.scss deleted file mode 100644 index 0221951573..0000000000 --- a/app/assets/stylesheets/shared/validated_form_component.scss +++ /dev/null @@ -1,10 +0,0 @@ -.warning-required-checkbox { - background-color: #ffc107; - padding: 0.5em; - - input[type="checkbox"] { - width: 18px; - height: 18px; - margin-right: 0.5em; - } -} \ No newline at end of file diff --git a/app/assets/stylesheets/tailwind.css b/app/assets/stylesheets/tailwind.css new file mode 100644 index 0000000000..1ad8d6b09c --- /dev/null +++ b/app/assets/stylesheets/tailwind.css @@ -0,0 +1,155 @@ +@import "tailwindcss"; +@import "tom-select/dist/css/tom-select.css"; +@import "trix/dist/trix.css"; + +/* Self-hosted fonts + icons (vendored under app/assets/stylesheets/vendor/, + binaries in public/vendor/) -- replaces the Google Fonts + jsdelivr CDN links. */ +@import "./vendor/figtree.css"; +@import "./vendor/bootstrap-icons.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 the retired Bootstrap bundle. +------------------------------------------------------------------ */ +.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/assets/stylesheets/vendor/bootstrap-icons.css b/app/assets/stylesheets/vendor/bootstrap-icons.css new file mode 100644 index 0000000000..dcb34b5047 --- /dev/null +++ b/app/assets/stylesheets/vendor/bootstrap-icons.css @@ -0,0 +1,2078 @@ +/*! + * Bootstrap Icons v1.11.3 (https://icons.getbootstrap.com/) + * Copyright 2019-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/icons/blob/main/LICENSE) + */ + +@font-face { + font-display: block; + font-family: "bootstrap-icons"; + src: url("/vendor/bootstrap-icons/bootstrap-icons.woff2") format("woff2"), +url("/vendor/bootstrap-icons/bootstrap-icons.woff") format("woff"); +} + +.bi::before, +[class^="bi-"]::before, +[class*=" bi-"]::before { + display: inline-block; + font-family: bootstrap-icons !important; + font-style: normal; + font-weight: normal !important; + font-variant: normal; + text-transform: none; + line-height: 1; + vertical-align: -.125em; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.bi-123::before { content: "\f67f"; } +.bi-alarm-fill::before { content: "\f101"; } +.bi-alarm::before { content: "\f102"; } +.bi-align-bottom::before { content: "\f103"; } +.bi-align-center::before { content: "\f104"; } +.bi-align-end::before { content: "\f105"; } +.bi-align-middle::before { content: "\f106"; } +.bi-align-start::before { content: "\f107"; } +.bi-align-top::before { content: "\f108"; } +.bi-alt::before { content: "\f109"; } +.bi-app-indicator::before { content: "\f10a"; } +.bi-app::before { content: "\f10b"; } +.bi-archive-fill::before { content: "\f10c"; } +.bi-archive::before { content: "\f10d"; } +.bi-arrow-90deg-down::before { content: "\f10e"; } +.bi-arrow-90deg-left::before { content: "\f10f"; } +.bi-arrow-90deg-right::before { content: "\f110"; } +.bi-arrow-90deg-up::before { content: "\f111"; } +.bi-arrow-bar-down::before { content: "\f112"; } +.bi-arrow-bar-left::before { content: "\f113"; } +.bi-arrow-bar-right::before { content: "\f114"; } +.bi-arrow-bar-up::before { content: "\f115"; } +.bi-arrow-clockwise::before { content: "\f116"; } +.bi-arrow-counterclockwise::before { content: "\f117"; } +.bi-arrow-down-circle-fill::before { content: "\f118"; } +.bi-arrow-down-circle::before { content: "\f119"; } +.bi-arrow-down-left-circle-fill::before { content: "\f11a"; } +.bi-arrow-down-left-circle::before { content: "\f11b"; } +.bi-arrow-down-left-square-fill::before { content: "\f11c"; } +.bi-arrow-down-left-square::before { content: "\f11d"; } +.bi-arrow-down-left::before { content: "\f11e"; } +.bi-arrow-down-right-circle-fill::before { content: "\f11f"; } +.bi-arrow-down-right-circle::before { content: "\f120"; } +.bi-arrow-down-right-square-fill::before { content: "\f121"; } +.bi-arrow-down-right-square::before { content: "\f122"; } +.bi-arrow-down-right::before { content: "\f123"; } +.bi-arrow-down-short::before { content: "\f124"; } +.bi-arrow-down-square-fill::before { content: "\f125"; } +.bi-arrow-down-square::before { content: "\f126"; } +.bi-arrow-down-up::before { content: "\f127"; } +.bi-arrow-down::before { content: "\f128"; } +.bi-arrow-left-circle-fill::before { content: "\f129"; } +.bi-arrow-left-circle::before { content: "\f12a"; } +.bi-arrow-left-right::before { content: "\f12b"; } +.bi-arrow-left-short::before { content: "\f12c"; } +.bi-arrow-left-square-fill::before { content: "\f12d"; } +.bi-arrow-left-square::before { content: "\f12e"; } +.bi-arrow-left::before { content: "\f12f"; } +.bi-arrow-repeat::before { content: "\f130"; } +.bi-arrow-return-left::before { content: "\f131"; } +.bi-arrow-return-right::before { content: "\f132"; } +.bi-arrow-right-circle-fill::before { content: "\f133"; } +.bi-arrow-right-circle::before { content: "\f134"; } +.bi-arrow-right-short::before { content: "\f135"; } +.bi-arrow-right-square-fill::before { content: "\f136"; } +.bi-arrow-right-square::before { content: "\f137"; } +.bi-arrow-right::before { content: "\f138"; } +.bi-arrow-up-circle-fill::before { content: "\f139"; } +.bi-arrow-up-circle::before { content: "\f13a"; } +.bi-arrow-up-left-circle-fill::before { content: "\f13b"; } +.bi-arrow-up-left-circle::before { content: "\f13c"; } +.bi-arrow-up-left-square-fill::before { content: "\f13d"; } +.bi-arrow-up-left-square::before { content: "\f13e"; } +.bi-arrow-up-left::before { content: "\f13f"; } +.bi-arrow-up-right-circle-fill::before { content: "\f140"; } +.bi-arrow-up-right-circle::before { content: "\f141"; } +.bi-arrow-up-right-square-fill::before { content: "\f142"; } +.bi-arrow-up-right-square::before { content: "\f143"; } +.bi-arrow-up-right::before { content: "\f144"; } +.bi-arrow-up-short::before { content: "\f145"; } +.bi-arrow-up-square-fill::before { content: "\f146"; } +.bi-arrow-up-square::before { content: "\f147"; } +.bi-arrow-up::before { content: "\f148"; } +.bi-arrows-angle-contract::before { content: "\f149"; } +.bi-arrows-angle-expand::before { content: "\f14a"; } +.bi-arrows-collapse::before { content: "\f14b"; } +.bi-arrows-expand::before { content: "\f14c"; } +.bi-arrows-fullscreen::before { content: "\f14d"; } +.bi-arrows-move::before { content: "\f14e"; } +.bi-aspect-ratio-fill::before { content: "\f14f"; } +.bi-aspect-ratio::before { content: "\f150"; } +.bi-asterisk::before { content: "\f151"; } +.bi-at::before { content: "\f152"; } +.bi-award-fill::before { content: "\f153"; } +.bi-award::before { content: "\f154"; } +.bi-back::before { content: "\f155"; } +.bi-backspace-fill::before { content: "\f156"; } +.bi-backspace-reverse-fill::before { content: "\f157"; } +.bi-backspace-reverse::before { content: "\f158"; } +.bi-backspace::before { content: "\f159"; } +.bi-badge-3d-fill::before { content: "\f15a"; } +.bi-badge-3d::before { content: "\f15b"; } +.bi-badge-4k-fill::before { content: "\f15c"; } +.bi-badge-4k::before { content: "\f15d"; } +.bi-badge-8k-fill::before { content: "\f15e"; } +.bi-badge-8k::before { content: "\f15f"; } +.bi-badge-ad-fill::before { content: "\f160"; } +.bi-badge-ad::before { content: "\f161"; } +.bi-badge-ar-fill::before { content: "\f162"; } +.bi-badge-ar::before { content: "\f163"; } +.bi-badge-cc-fill::before { content: "\f164"; } +.bi-badge-cc::before { content: "\f165"; } +.bi-badge-hd-fill::before { content: "\f166"; } +.bi-badge-hd::before { content: "\f167"; } +.bi-badge-tm-fill::before { content: "\f168"; } +.bi-badge-tm::before { content: "\f169"; } +.bi-badge-vo-fill::before { content: "\f16a"; } +.bi-badge-vo::before { content: "\f16b"; } +.bi-badge-vr-fill::before { content: "\f16c"; } +.bi-badge-vr::before { content: "\f16d"; } +.bi-badge-wc-fill::before { content: "\f16e"; } +.bi-badge-wc::before { content: "\f16f"; } +.bi-bag-check-fill::before { content: "\f170"; } +.bi-bag-check::before { content: "\f171"; } +.bi-bag-dash-fill::before { content: "\f172"; } +.bi-bag-dash::before { content: "\f173"; } +.bi-bag-fill::before { content: "\f174"; } +.bi-bag-plus-fill::before { content: "\f175"; } +.bi-bag-plus::before { content: "\f176"; } +.bi-bag-x-fill::before { content: "\f177"; } +.bi-bag-x::before { content: "\f178"; } +.bi-bag::before { content: "\f179"; } +.bi-bar-chart-fill::before { content: "\f17a"; } +.bi-bar-chart-line-fill::before { content: "\f17b"; } +.bi-bar-chart-line::before { content: "\f17c"; } +.bi-bar-chart-steps::before { content: "\f17d"; } +.bi-bar-chart::before { content: "\f17e"; } +.bi-basket-fill::before { content: "\f17f"; } +.bi-basket::before { content: "\f180"; } +.bi-basket2-fill::before { content: "\f181"; } +.bi-basket2::before { content: "\f182"; } +.bi-basket3-fill::before { content: "\f183"; } +.bi-basket3::before { content: "\f184"; } +.bi-battery-charging::before { content: "\f185"; } +.bi-battery-full::before { content: "\f186"; } +.bi-battery-half::before { content: "\f187"; } +.bi-battery::before { content: "\f188"; } +.bi-bell-fill::before { content: "\f189"; } +.bi-bell::before { content: "\f18a"; } +.bi-bezier::before { content: "\f18b"; } +.bi-bezier2::before { content: "\f18c"; } +.bi-bicycle::before { content: "\f18d"; } +.bi-binoculars-fill::before { content: "\f18e"; } +.bi-binoculars::before { content: "\f18f"; } +.bi-blockquote-left::before { content: "\f190"; } +.bi-blockquote-right::before { content: "\f191"; } +.bi-book-fill::before { content: "\f192"; } +.bi-book-half::before { content: "\f193"; } +.bi-book::before { content: "\f194"; } +.bi-bookmark-check-fill::before { content: "\f195"; } +.bi-bookmark-check::before { content: "\f196"; } +.bi-bookmark-dash-fill::before { content: "\f197"; } +.bi-bookmark-dash::before { content: "\f198"; } +.bi-bookmark-fill::before { content: "\f199"; } +.bi-bookmark-heart-fill::before { content: "\f19a"; } +.bi-bookmark-heart::before { content: "\f19b"; } +.bi-bookmark-plus-fill::before { content: "\f19c"; } +.bi-bookmark-plus::before { content: "\f19d"; } +.bi-bookmark-star-fill::before { content: "\f19e"; } +.bi-bookmark-star::before { content: "\f19f"; } +.bi-bookmark-x-fill::before { content: "\f1a0"; } +.bi-bookmark-x::before { content: "\f1a1"; } +.bi-bookmark::before { content: "\f1a2"; } +.bi-bookmarks-fill::before { content: "\f1a3"; } +.bi-bookmarks::before { content: "\f1a4"; } +.bi-bookshelf::before { content: "\f1a5"; } +.bi-bootstrap-fill::before { content: "\f1a6"; } +.bi-bootstrap-reboot::before { content: "\f1a7"; } +.bi-bootstrap::before { content: "\f1a8"; } +.bi-border-all::before { content: "\f1a9"; } +.bi-border-bottom::before { content: "\f1aa"; } +.bi-border-center::before { content: "\f1ab"; } +.bi-border-inner::before { content: "\f1ac"; } +.bi-border-left::before { content: "\f1ad"; } +.bi-border-middle::before { content: "\f1ae"; } +.bi-border-outer::before { content: "\f1af"; } +.bi-border-right::before { content: "\f1b0"; } +.bi-border-style::before { content: "\f1b1"; } +.bi-border-top::before { content: "\f1b2"; } +.bi-border-width::before { content: "\f1b3"; } +.bi-border::before { content: "\f1b4"; } +.bi-bounding-box-circles::before { content: "\f1b5"; } +.bi-bounding-box::before { content: "\f1b6"; } +.bi-box-arrow-down-left::before { content: "\f1b7"; } +.bi-box-arrow-down-right::before { content: "\f1b8"; } +.bi-box-arrow-down::before { content: "\f1b9"; } +.bi-box-arrow-in-down-left::before { content: "\f1ba"; } +.bi-box-arrow-in-down-right::before { content: "\f1bb"; } +.bi-box-arrow-in-down::before { content: "\f1bc"; } +.bi-box-arrow-in-left::before { content: "\f1bd"; } +.bi-box-arrow-in-right::before { content: "\f1be"; } +.bi-box-arrow-in-up-left::before { content: "\f1bf"; } +.bi-box-arrow-in-up-right::before { content: "\f1c0"; } +.bi-box-arrow-in-up::before { content: "\f1c1"; } +.bi-box-arrow-left::before { content: "\f1c2"; } +.bi-box-arrow-right::before { content: "\f1c3"; } +.bi-box-arrow-up-left::before { content: "\f1c4"; } +.bi-box-arrow-up-right::before { content: "\f1c5"; } +.bi-box-arrow-up::before { content: "\f1c6"; } +.bi-box-seam::before { content: "\f1c7"; } +.bi-box::before { content: "\f1c8"; } +.bi-braces::before { content: "\f1c9"; } +.bi-bricks::before { content: "\f1ca"; } +.bi-briefcase-fill::before { content: "\f1cb"; } +.bi-briefcase::before { content: "\f1cc"; } +.bi-brightness-alt-high-fill::before { content: "\f1cd"; } +.bi-brightness-alt-high::before { content: "\f1ce"; } +.bi-brightness-alt-low-fill::before { content: "\f1cf"; } +.bi-brightness-alt-low::before { content: "\f1d0"; } +.bi-brightness-high-fill::before { content: "\f1d1"; } +.bi-brightness-high::before { content: "\f1d2"; } +.bi-brightness-low-fill::before { content: "\f1d3"; } +.bi-brightness-low::before { content: "\f1d4"; } +.bi-broadcast-pin::before { content: "\f1d5"; } +.bi-broadcast::before { content: "\f1d6"; } +.bi-brush-fill::before { content: "\f1d7"; } +.bi-brush::before { content: "\f1d8"; } +.bi-bucket-fill::before { content: "\f1d9"; } +.bi-bucket::before { content: "\f1da"; } +.bi-bug-fill::before { content: "\f1db"; } +.bi-bug::before { content: "\f1dc"; } +.bi-building::before { content: "\f1dd"; } +.bi-bullseye::before { content: "\f1de"; } +.bi-calculator-fill::before { content: "\f1df"; } +.bi-calculator::before { content: "\f1e0"; } +.bi-calendar-check-fill::before { content: "\f1e1"; } +.bi-calendar-check::before { content: "\f1e2"; } +.bi-calendar-date-fill::before { content: "\f1e3"; } +.bi-calendar-date::before { content: "\f1e4"; } +.bi-calendar-day-fill::before { content: "\f1e5"; } +.bi-calendar-day::before { content: "\f1e6"; } +.bi-calendar-event-fill::before { content: "\f1e7"; } +.bi-calendar-event::before { content: "\f1e8"; } +.bi-calendar-fill::before { content: "\f1e9"; } +.bi-calendar-minus-fill::before { content: "\f1ea"; } +.bi-calendar-minus::before { content: "\f1eb"; } +.bi-calendar-month-fill::before { content: "\f1ec"; } +.bi-calendar-month::before { content: "\f1ed"; } +.bi-calendar-plus-fill::before { content: "\f1ee"; } +.bi-calendar-plus::before { content: "\f1ef"; } +.bi-calendar-range-fill::before { content: "\f1f0"; } +.bi-calendar-range::before { content: "\f1f1"; } +.bi-calendar-week-fill::before { content: "\f1f2"; } +.bi-calendar-week::before { content: "\f1f3"; } +.bi-calendar-x-fill::before { content: "\f1f4"; } +.bi-calendar-x::before { content: "\f1f5"; } +.bi-calendar::before { content: "\f1f6"; } +.bi-calendar2-check-fill::before { content: "\f1f7"; } +.bi-calendar2-check::before { content: "\f1f8"; } +.bi-calendar2-date-fill::before { content: "\f1f9"; } +.bi-calendar2-date::before { content: "\f1fa"; } +.bi-calendar2-day-fill::before { content: "\f1fb"; } +.bi-calendar2-day::before { content: "\f1fc"; } +.bi-calendar2-event-fill::before { content: "\f1fd"; } +.bi-calendar2-event::before { content: "\f1fe"; } +.bi-calendar2-fill::before { content: "\f1ff"; } +.bi-calendar2-minus-fill::before { content: "\f200"; } +.bi-calendar2-minus::before { content: "\f201"; } +.bi-calendar2-month-fill::before { content: "\f202"; } +.bi-calendar2-month::before { content: "\f203"; } +.bi-calendar2-plus-fill::before { content: "\f204"; } +.bi-calendar2-plus::before { content: "\f205"; } +.bi-calendar2-range-fill::before { content: "\f206"; } +.bi-calendar2-range::before { content: "\f207"; } +.bi-calendar2-week-fill::before { content: "\f208"; } +.bi-calendar2-week::before { content: "\f209"; } +.bi-calendar2-x-fill::before { content: "\f20a"; } +.bi-calendar2-x::before { content: "\f20b"; } +.bi-calendar2::before { content: "\f20c"; } +.bi-calendar3-event-fill::before { content: "\f20d"; } +.bi-calendar3-event::before { content: "\f20e"; } +.bi-calendar3-fill::before { content: "\f20f"; } +.bi-calendar3-range-fill::before { content: "\f210"; } +.bi-calendar3-range::before { content: "\f211"; } +.bi-calendar3-week-fill::before { content: "\f212"; } +.bi-calendar3-week::before { content: "\f213"; } +.bi-calendar3::before { content: "\f214"; } +.bi-calendar4-event::before { content: "\f215"; } +.bi-calendar4-range::before { content: "\f216"; } +.bi-calendar4-week::before { content: "\f217"; } +.bi-calendar4::before { content: "\f218"; } +.bi-camera-fill::before { content: "\f219"; } +.bi-camera-reels-fill::before { content: "\f21a"; } +.bi-camera-reels::before { content: "\f21b"; } +.bi-camera-video-fill::before { content: "\f21c"; } +.bi-camera-video-off-fill::before { content: "\f21d"; } +.bi-camera-video-off::before { content: "\f21e"; } +.bi-camera-video::before { content: "\f21f"; } +.bi-camera::before { content: "\f220"; } +.bi-camera2::before { content: "\f221"; } +.bi-capslock-fill::before { content: "\f222"; } +.bi-capslock::before { content: "\f223"; } +.bi-card-checklist::before { content: "\f224"; } +.bi-card-heading::before { content: "\f225"; } +.bi-card-image::before { content: "\f226"; } +.bi-card-list::before { content: "\f227"; } +.bi-card-text::before { content: "\f228"; } +.bi-caret-down-fill::before { content: "\f229"; } +.bi-caret-down-square-fill::before { content: "\f22a"; } +.bi-caret-down-square::before { content: "\f22b"; } +.bi-caret-down::before { content: "\f22c"; } +.bi-caret-left-fill::before { content: "\f22d"; } +.bi-caret-left-square-fill::before { content: "\f22e"; } +.bi-caret-left-square::before { content: "\f22f"; } +.bi-caret-left::before { content: "\f230"; } +.bi-caret-right-fill::before { content: "\f231"; } +.bi-caret-right-square-fill::before { content: "\f232"; } +.bi-caret-right-square::before { content: "\f233"; } +.bi-caret-right::before { content: "\f234"; } +.bi-caret-up-fill::before { content: "\f235"; } +.bi-caret-up-square-fill::before { content: "\f236"; } +.bi-caret-up-square::before { content: "\f237"; } +.bi-caret-up::before { content: "\f238"; } +.bi-cart-check-fill::before { content: "\f239"; } +.bi-cart-check::before { content: "\f23a"; } +.bi-cart-dash-fill::before { content: "\f23b"; } +.bi-cart-dash::before { content: "\f23c"; } +.bi-cart-fill::before { content: "\f23d"; } +.bi-cart-plus-fill::before { content: "\f23e"; } +.bi-cart-plus::before { content: "\f23f"; } +.bi-cart-x-fill::before { content: "\f240"; } +.bi-cart-x::before { content: "\f241"; } +.bi-cart::before { content: "\f242"; } +.bi-cart2::before { content: "\f243"; } +.bi-cart3::before { content: "\f244"; } +.bi-cart4::before { content: "\f245"; } +.bi-cash-stack::before { content: "\f246"; } +.bi-cash::before { content: "\f247"; } +.bi-cast::before { content: "\f248"; } +.bi-chat-dots-fill::before { content: "\f249"; } +.bi-chat-dots::before { content: "\f24a"; } +.bi-chat-fill::before { content: "\f24b"; } +.bi-chat-left-dots-fill::before { content: "\f24c"; } +.bi-chat-left-dots::before { content: "\f24d"; } +.bi-chat-left-fill::before { content: "\f24e"; } +.bi-chat-left-quote-fill::before { content: "\f24f"; } +.bi-chat-left-quote::before { content: "\f250"; } +.bi-chat-left-text-fill::before { content: "\f251"; } +.bi-chat-left-text::before { content: "\f252"; } +.bi-chat-left::before { content: "\f253"; } +.bi-chat-quote-fill::before { content: "\f254"; } +.bi-chat-quote::before { content: "\f255"; } +.bi-chat-right-dots-fill::before { content: "\f256"; } +.bi-chat-right-dots::before { content: "\f257"; } +.bi-chat-right-fill::before { content: "\f258"; } +.bi-chat-right-quote-fill::before { content: "\f259"; } +.bi-chat-right-quote::before { content: "\f25a"; } +.bi-chat-right-text-fill::before { content: "\f25b"; } +.bi-chat-right-text::before { content: "\f25c"; } +.bi-chat-right::before { content: "\f25d"; } +.bi-chat-square-dots-fill::before { content: "\f25e"; } +.bi-chat-square-dots::before { content: "\f25f"; } +.bi-chat-square-fill::before { content: "\f260"; } +.bi-chat-square-quote-fill::before { content: "\f261"; } +.bi-chat-square-quote::before { content: "\f262"; } +.bi-chat-square-text-fill::before { content: "\f263"; } +.bi-chat-square-text::before { content: "\f264"; } +.bi-chat-square::before { content: "\f265"; } +.bi-chat-text-fill::before { content: "\f266"; } +.bi-chat-text::before { content: "\f267"; } +.bi-chat::before { content: "\f268"; } +.bi-check-all::before { content: "\f269"; } +.bi-check-circle-fill::before { content: "\f26a"; } +.bi-check-circle::before { content: "\f26b"; } +.bi-check-square-fill::before { content: "\f26c"; } +.bi-check-square::before { content: "\f26d"; } +.bi-check::before { content: "\f26e"; } +.bi-check2-all::before { content: "\f26f"; } +.bi-check2-circle::before { content: "\f270"; } +.bi-check2-square::before { content: "\f271"; } +.bi-check2::before { content: "\f272"; } +.bi-chevron-bar-contract::before { content: "\f273"; } +.bi-chevron-bar-down::before { content: "\f274"; } +.bi-chevron-bar-expand::before { content: "\f275"; } +.bi-chevron-bar-left::before { content: "\f276"; } +.bi-chevron-bar-right::before { content: "\f277"; } +.bi-chevron-bar-up::before { content: "\f278"; } +.bi-chevron-compact-down::before { content: "\f279"; } +.bi-chevron-compact-left::before { content: "\f27a"; } +.bi-chevron-compact-right::before { content: "\f27b"; } +.bi-chevron-compact-up::before { content: "\f27c"; } +.bi-chevron-contract::before { content: "\f27d"; } +.bi-chevron-double-down::before { content: "\f27e"; } +.bi-chevron-double-left::before { content: "\f27f"; } +.bi-chevron-double-right::before { content: "\f280"; } +.bi-chevron-double-up::before { content: "\f281"; } +.bi-chevron-down::before { content: "\f282"; } +.bi-chevron-expand::before { content: "\f283"; } +.bi-chevron-left::before { content: "\f284"; } +.bi-chevron-right::before { content: "\f285"; } +.bi-chevron-up::before { content: "\f286"; } +.bi-circle-fill::before { content: "\f287"; } +.bi-circle-half::before { content: "\f288"; } +.bi-circle-square::before { content: "\f289"; } +.bi-circle::before { content: "\f28a"; } +.bi-clipboard-check::before { content: "\f28b"; } +.bi-clipboard-data::before { content: "\f28c"; } +.bi-clipboard-minus::before { content: "\f28d"; } +.bi-clipboard-plus::before { content: "\f28e"; } +.bi-clipboard-x::before { content: "\f28f"; } +.bi-clipboard::before { content: "\f290"; } +.bi-clock-fill::before { content: "\f291"; } +.bi-clock-history::before { content: "\f292"; } +.bi-clock::before { content: "\f293"; } +.bi-cloud-arrow-down-fill::before { content: "\f294"; } +.bi-cloud-arrow-down::before { content: "\f295"; } +.bi-cloud-arrow-up-fill::before { content: "\f296"; } +.bi-cloud-arrow-up::before { content: "\f297"; } +.bi-cloud-check-fill::before { content: "\f298"; } +.bi-cloud-check::before { content: "\f299"; } +.bi-cloud-download-fill::before { content: "\f29a"; } +.bi-cloud-download::before { content: "\f29b"; } +.bi-cloud-drizzle-fill::before { content: "\f29c"; } +.bi-cloud-drizzle::before { content: "\f29d"; } +.bi-cloud-fill::before { content: "\f29e"; } +.bi-cloud-fog-fill::before { content: "\f29f"; } +.bi-cloud-fog::before { content: "\f2a0"; } +.bi-cloud-fog2-fill::before { content: "\f2a1"; } +.bi-cloud-fog2::before { content: "\f2a2"; } +.bi-cloud-hail-fill::before { content: "\f2a3"; } +.bi-cloud-hail::before { content: "\f2a4"; } +.bi-cloud-haze-fill::before { content: "\f2a6"; } +.bi-cloud-haze::before { content: "\f2a7"; } +.bi-cloud-haze2-fill::before { content: "\f2a8"; } +.bi-cloud-lightning-fill::before { content: "\f2a9"; } +.bi-cloud-lightning-rain-fill::before { content: "\f2aa"; } +.bi-cloud-lightning-rain::before { content: "\f2ab"; } +.bi-cloud-lightning::before { content: "\f2ac"; } +.bi-cloud-minus-fill::before { content: "\f2ad"; } +.bi-cloud-minus::before { content: "\f2ae"; } +.bi-cloud-moon-fill::before { content: "\f2af"; } +.bi-cloud-moon::before { content: "\f2b0"; } +.bi-cloud-plus-fill::before { content: "\f2b1"; } +.bi-cloud-plus::before { content: "\f2b2"; } +.bi-cloud-rain-fill::before { content: "\f2b3"; } +.bi-cloud-rain-heavy-fill::before { content: "\f2b4"; } +.bi-cloud-rain-heavy::before { content: "\f2b5"; } +.bi-cloud-rain::before { content: "\f2b6"; } +.bi-cloud-slash-fill::before { content: "\f2b7"; } +.bi-cloud-slash::before { content: "\f2b8"; } +.bi-cloud-sleet-fill::before { content: "\f2b9"; } +.bi-cloud-sleet::before { content: "\f2ba"; } +.bi-cloud-snow-fill::before { content: "\f2bb"; } +.bi-cloud-snow::before { content: "\f2bc"; } +.bi-cloud-sun-fill::before { content: "\f2bd"; } +.bi-cloud-sun::before { content: "\f2be"; } +.bi-cloud-upload-fill::before { content: "\f2bf"; } +.bi-cloud-upload::before { content: "\f2c0"; } +.bi-cloud::before { content: "\f2c1"; } +.bi-clouds-fill::before { content: "\f2c2"; } +.bi-clouds::before { content: "\f2c3"; } +.bi-cloudy-fill::before { content: "\f2c4"; } +.bi-cloudy::before { content: "\f2c5"; } +.bi-code-slash::before { content: "\f2c6"; } +.bi-code-square::before { content: "\f2c7"; } +.bi-code::before { content: "\f2c8"; } +.bi-collection-fill::before { content: "\f2c9"; } +.bi-collection-play-fill::before { content: "\f2ca"; } +.bi-collection-play::before { content: "\f2cb"; } +.bi-collection::before { content: "\f2cc"; } +.bi-columns-gap::before { content: "\f2cd"; } +.bi-columns::before { content: "\f2ce"; } +.bi-command::before { content: "\f2cf"; } +.bi-compass-fill::before { content: "\f2d0"; } +.bi-compass::before { content: "\f2d1"; } +.bi-cone-striped::before { content: "\f2d2"; } +.bi-cone::before { content: "\f2d3"; } +.bi-controller::before { content: "\f2d4"; } +.bi-cpu-fill::before { content: "\f2d5"; } +.bi-cpu::before { content: "\f2d6"; } +.bi-credit-card-2-back-fill::before { content: "\f2d7"; } +.bi-credit-card-2-back::before { content: "\f2d8"; } +.bi-credit-card-2-front-fill::before { content: "\f2d9"; } +.bi-credit-card-2-front::before { content: "\f2da"; } +.bi-credit-card-fill::before { content: "\f2db"; } +.bi-credit-card::before { content: "\f2dc"; } +.bi-crop::before { content: "\f2dd"; } +.bi-cup-fill::before { content: "\f2de"; } +.bi-cup-straw::before { content: "\f2df"; } +.bi-cup::before { content: "\f2e0"; } +.bi-cursor-fill::before { content: "\f2e1"; } +.bi-cursor-text::before { content: "\f2e2"; } +.bi-cursor::before { content: "\f2e3"; } +.bi-dash-circle-dotted::before { content: "\f2e4"; } +.bi-dash-circle-fill::before { content: "\f2e5"; } +.bi-dash-circle::before { content: "\f2e6"; } +.bi-dash-square-dotted::before { content: "\f2e7"; } +.bi-dash-square-fill::before { content: "\f2e8"; } +.bi-dash-square::before { content: "\f2e9"; } +.bi-dash::before { content: "\f2ea"; } +.bi-diagram-2-fill::before { content: "\f2eb"; } +.bi-diagram-2::before { content: "\f2ec"; } +.bi-diagram-3-fill::before { content: "\f2ed"; } +.bi-diagram-3::before { content: "\f2ee"; } +.bi-diamond-fill::before { content: "\f2ef"; } +.bi-diamond-half::before { content: "\f2f0"; } +.bi-diamond::before { content: "\f2f1"; } +.bi-dice-1-fill::before { content: "\f2f2"; } +.bi-dice-1::before { content: "\f2f3"; } +.bi-dice-2-fill::before { content: "\f2f4"; } +.bi-dice-2::before { content: "\f2f5"; } +.bi-dice-3-fill::before { content: "\f2f6"; } +.bi-dice-3::before { content: "\f2f7"; } +.bi-dice-4-fill::before { content: "\f2f8"; } +.bi-dice-4::before { content: "\f2f9"; } +.bi-dice-5-fill::before { content: "\f2fa"; } +.bi-dice-5::before { content: "\f2fb"; } +.bi-dice-6-fill::before { content: "\f2fc"; } +.bi-dice-6::before { content: "\f2fd"; } +.bi-disc-fill::before { content: "\f2fe"; } +.bi-disc::before { content: "\f2ff"; } +.bi-discord::before { content: "\f300"; } +.bi-display-fill::before { content: "\f301"; } +.bi-display::before { content: "\f302"; } +.bi-distribute-horizontal::before { content: "\f303"; } +.bi-distribute-vertical::before { content: "\f304"; } +.bi-door-closed-fill::before { content: "\f305"; } +.bi-door-closed::before { content: "\f306"; } +.bi-door-open-fill::before { content: "\f307"; } +.bi-door-open::before { content: "\f308"; } +.bi-dot::before { content: "\f309"; } +.bi-download::before { content: "\f30a"; } +.bi-droplet-fill::before { content: "\f30b"; } +.bi-droplet-half::before { content: "\f30c"; } +.bi-droplet::before { content: "\f30d"; } +.bi-earbuds::before { content: "\f30e"; } +.bi-easel-fill::before { content: "\f30f"; } +.bi-easel::before { content: "\f310"; } +.bi-egg-fill::before { content: "\f311"; } +.bi-egg-fried::before { content: "\f312"; } +.bi-egg::before { content: "\f313"; } +.bi-eject-fill::before { content: "\f314"; } +.bi-eject::before { content: "\f315"; } +.bi-emoji-angry-fill::before { content: "\f316"; } +.bi-emoji-angry::before { content: "\f317"; } +.bi-emoji-dizzy-fill::before { content: "\f318"; } +.bi-emoji-dizzy::before { content: "\f319"; } +.bi-emoji-expressionless-fill::before { content: "\f31a"; } +.bi-emoji-expressionless::before { content: "\f31b"; } +.bi-emoji-frown-fill::before { content: "\f31c"; } +.bi-emoji-frown::before { content: "\f31d"; } +.bi-emoji-heart-eyes-fill::before { content: "\f31e"; } +.bi-emoji-heart-eyes::before { content: "\f31f"; } +.bi-emoji-laughing-fill::before { content: "\f320"; } +.bi-emoji-laughing::before { content: "\f321"; } +.bi-emoji-neutral-fill::before { content: "\f322"; } +.bi-emoji-neutral::before { content: "\f323"; } +.bi-emoji-smile-fill::before { content: "\f324"; } +.bi-emoji-smile-upside-down-fill::before { content: "\f325"; } +.bi-emoji-smile-upside-down::before { content: "\f326"; } +.bi-emoji-smile::before { content: "\f327"; } +.bi-emoji-sunglasses-fill::before { content: "\f328"; } +.bi-emoji-sunglasses::before { content: "\f329"; } +.bi-emoji-wink-fill::before { content: "\f32a"; } +.bi-emoji-wink::before { content: "\f32b"; } +.bi-envelope-fill::before { content: "\f32c"; } +.bi-envelope-open-fill::before { content: "\f32d"; } +.bi-envelope-open::before { content: "\f32e"; } +.bi-envelope::before { content: "\f32f"; } +.bi-eraser-fill::before { content: "\f330"; } +.bi-eraser::before { content: "\f331"; } +.bi-exclamation-circle-fill::before { content: "\f332"; } +.bi-exclamation-circle::before { content: "\f333"; } +.bi-exclamation-diamond-fill::before { content: "\f334"; } +.bi-exclamation-diamond::before { content: "\f335"; } +.bi-exclamation-octagon-fill::before { content: "\f336"; } +.bi-exclamation-octagon::before { content: "\f337"; } +.bi-exclamation-square-fill::before { content: "\f338"; } +.bi-exclamation-square::before { content: "\f339"; } +.bi-exclamation-triangle-fill::before { content: "\f33a"; } +.bi-exclamation-triangle::before { content: "\f33b"; } +.bi-exclamation::before { content: "\f33c"; } +.bi-exclude::before { content: "\f33d"; } +.bi-eye-fill::before { content: "\f33e"; } +.bi-eye-slash-fill::before { content: "\f33f"; } +.bi-eye-slash::before { content: "\f340"; } +.bi-eye::before { content: "\f341"; } +.bi-eyedropper::before { content: "\f342"; } +.bi-eyeglasses::before { content: "\f343"; } +.bi-facebook::before { content: "\f344"; } +.bi-file-arrow-down-fill::before { content: "\f345"; } +.bi-file-arrow-down::before { content: "\f346"; } +.bi-file-arrow-up-fill::before { content: "\f347"; } +.bi-file-arrow-up::before { content: "\f348"; } +.bi-file-bar-graph-fill::before { content: "\f349"; } +.bi-file-bar-graph::before { content: "\f34a"; } +.bi-file-binary-fill::before { content: "\f34b"; } +.bi-file-binary::before { content: "\f34c"; } +.bi-file-break-fill::before { content: "\f34d"; } +.bi-file-break::before { content: "\f34e"; } +.bi-file-check-fill::before { content: "\f34f"; } +.bi-file-check::before { content: "\f350"; } +.bi-file-code-fill::before { content: "\f351"; } +.bi-file-code::before { content: "\f352"; } +.bi-file-diff-fill::before { content: "\f353"; } +.bi-file-diff::before { content: "\f354"; } +.bi-file-earmark-arrow-down-fill::before { content: "\f355"; } +.bi-file-earmark-arrow-down::before { content: "\f356"; } +.bi-file-earmark-arrow-up-fill::before { content: "\f357"; } +.bi-file-earmark-arrow-up::before { content: "\f358"; } +.bi-file-earmark-bar-graph-fill::before { content: "\f359"; } +.bi-file-earmark-bar-graph::before { content: "\f35a"; } +.bi-file-earmark-binary-fill::before { content: "\f35b"; } +.bi-file-earmark-binary::before { content: "\f35c"; } +.bi-file-earmark-break-fill::before { content: "\f35d"; } +.bi-file-earmark-break::before { content: "\f35e"; } +.bi-file-earmark-check-fill::before { content: "\f35f"; } +.bi-file-earmark-check::before { content: "\f360"; } +.bi-file-earmark-code-fill::before { content: "\f361"; } +.bi-file-earmark-code::before { content: "\f362"; } +.bi-file-earmark-diff-fill::before { content: "\f363"; } +.bi-file-earmark-diff::before { content: "\f364"; } +.bi-file-earmark-easel-fill::before { content: "\f365"; } +.bi-file-earmark-easel::before { content: "\f366"; } +.bi-file-earmark-excel-fill::before { content: "\f367"; } +.bi-file-earmark-excel::before { content: "\f368"; } +.bi-file-earmark-fill::before { content: "\f369"; } +.bi-file-earmark-font-fill::before { content: "\f36a"; } +.bi-file-earmark-font::before { content: "\f36b"; } +.bi-file-earmark-image-fill::before { content: "\f36c"; } +.bi-file-earmark-image::before { content: "\f36d"; } +.bi-file-earmark-lock-fill::before { content: "\f36e"; } +.bi-file-earmark-lock::before { content: "\f36f"; } +.bi-file-earmark-lock2-fill::before { content: "\f370"; } +.bi-file-earmark-lock2::before { content: "\f371"; } +.bi-file-earmark-medical-fill::before { content: "\f372"; } +.bi-file-earmark-medical::before { content: "\f373"; } +.bi-file-earmark-minus-fill::before { content: "\f374"; } +.bi-file-earmark-minus::before { content: "\f375"; } +.bi-file-earmark-music-fill::before { content: "\f376"; } +.bi-file-earmark-music::before { content: "\f377"; } +.bi-file-earmark-person-fill::before { content: "\f378"; } +.bi-file-earmark-person::before { content: "\f379"; } +.bi-file-earmark-play-fill::before { content: "\f37a"; } +.bi-file-earmark-play::before { content: "\f37b"; } +.bi-file-earmark-plus-fill::before { content: "\f37c"; } +.bi-file-earmark-plus::before { content: "\f37d"; } +.bi-file-earmark-post-fill::before { content: "\f37e"; } +.bi-file-earmark-post::before { content: "\f37f"; } +.bi-file-earmark-ppt-fill::before { content: "\f380"; } +.bi-file-earmark-ppt::before { content: "\f381"; } +.bi-file-earmark-richtext-fill::before { content: "\f382"; } +.bi-file-earmark-richtext::before { content: "\f383"; } +.bi-file-earmark-ruled-fill::before { content: "\f384"; } +.bi-file-earmark-ruled::before { content: "\f385"; } +.bi-file-earmark-slides-fill::before { content: "\f386"; } +.bi-file-earmark-slides::before { content: "\f387"; } +.bi-file-earmark-spreadsheet-fill::before { content: "\f388"; } +.bi-file-earmark-spreadsheet::before { content: "\f389"; } +.bi-file-earmark-text-fill::before { content: "\f38a"; } +.bi-file-earmark-text::before { content: "\f38b"; } +.bi-file-earmark-word-fill::before { content: "\f38c"; } +.bi-file-earmark-word::before { content: "\f38d"; } +.bi-file-earmark-x-fill::before { content: "\f38e"; } +.bi-file-earmark-x::before { content: "\f38f"; } +.bi-file-earmark-zip-fill::before { content: "\f390"; } +.bi-file-earmark-zip::before { content: "\f391"; } +.bi-file-earmark::before { content: "\f392"; } +.bi-file-easel-fill::before { content: "\f393"; } +.bi-file-easel::before { content: "\f394"; } +.bi-file-excel-fill::before { content: "\f395"; } +.bi-file-excel::before { content: "\f396"; } +.bi-file-fill::before { content: "\f397"; } +.bi-file-font-fill::before { content: "\f398"; } +.bi-file-font::before { content: "\f399"; } +.bi-file-image-fill::before { content: "\f39a"; } +.bi-file-image::before { content: "\f39b"; } +.bi-file-lock-fill::before { content: "\f39c"; } +.bi-file-lock::before { content: "\f39d"; } +.bi-file-lock2-fill::before { content: "\f39e"; } +.bi-file-lock2::before { content: "\f39f"; } +.bi-file-medical-fill::before { content: "\f3a0"; } +.bi-file-medical::before { content: "\f3a1"; } +.bi-file-minus-fill::before { content: "\f3a2"; } +.bi-file-minus::before { content: "\f3a3"; } +.bi-file-music-fill::before { content: "\f3a4"; } +.bi-file-music::before { content: "\f3a5"; } +.bi-file-person-fill::before { content: "\f3a6"; } +.bi-file-person::before { content: "\f3a7"; } +.bi-file-play-fill::before { content: "\f3a8"; } +.bi-file-play::before { content: "\f3a9"; } +.bi-file-plus-fill::before { content: "\f3aa"; } +.bi-file-plus::before { content: "\f3ab"; } +.bi-file-post-fill::before { content: "\f3ac"; } +.bi-file-post::before { content: "\f3ad"; } +.bi-file-ppt-fill::before { content: "\f3ae"; } +.bi-file-ppt::before { content: "\f3af"; } +.bi-file-richtext-fill::before { content: "\f3b0"; } +.bi-file-richtext::before { content: "\f3b1"; } +.bi-file-ruled-fill::before { content: "\f3b2"; } +.bi-file-ruled::before { content: "\f3b3"; } +.bi-file-slides-fill::before { content: "\f3b4"; } +.bi-file-slides::before { content: "\f3b5"; } +.bi-file-spreadsheet-fill::before { content: "\f3b6"; } +.bi-file-spreadsheet::before { content: "\f3b7"; } +.bi-file-text-fill::before { content: "\f3b8"; } +.bi-file-text::before { content: "\f3b9"; } +.bi-file-word-fill::before { content: "\f3ba"; } +.bi-file-word::before { content: "\f3bb"; } +.bi-file-x-fill::before { content: "\f3bc"; } +.bi-file-x::before { content: "\f3bd"; } +.bi-file-zip-fill::before { content: "\f3be"; } +.bi-file-zip::before { content: "\f3bf"; } +.bi-file::before { content: "\f3c0"; } +.bi-files-alt::before { content: "\f3c1"; } +.bi-files::before { content: "\f3c2"; } +.bi-film::before { content: "\f3c3"; } +.bi-filter-circle-fill::before { content: "\f3c4"; } +.bi-filter-circle::before { content: "\f3c5"; } +.bi-filter-left::before { content: "\f3c6"; } +.bi-filter-right::before { content: "\f3c7"; } +.bi-filter-square-fill::before { content: "\f3c8"; } +.bi-filter-square::before { content: "\f3c9"; } +.bi-filter::before { content: "\f3ca"; } +.bi-flag-fill::before { content: "\f3cb"; } +.bi-flag::before { content: "\f3cc"; } +.bi-flower1::before { content: "\f3cd"; } +.bi-flower2::before { content: "\f3ce"; } +.bi-flower3::before { content: "\f3cf"; } +.bi-folder-check::before { content: "\f3d0"; } +.bi-folder-fill::before { content: "\f3d1"; } +.bi-folder-minus::before { content: "\f3d2"; } +.bi-folder-plus::before { content: "\f3d3"; } +.bi-folder-symlink-fill::before { content: "\f3d4"; } +.bi-folder-symlink::before { content: "\f3d5"; } +.bi-folder-x::before { content: "\f3d6"; } +.bi-folder::before { content: "\f3d7"; } +.bi-folder2-open::before { content: "\f3d8"; } +.bi-folder2::before { content: "\f3d9"; } +.bi-fonts::before { content: "\f3da"; } +.bi-forward-fill::before { content: "\f3db"; } +.bi-forward::before { content: "\f3dc"; } +.bi-front::before { content: "\f3dd"; } +.bi-fullscreen-exit::before { content: "\f3de"; } +.bi-fullscreen::before { content: "\f3df"; } +.bi-funnel-fill::before { content: "\f3e0"; } +.bi-funnel::before { content: "\f3e1"; } +.bi-gear-fill::before { content: "\f3e2"; } +.bi-gear-wide-connected::before { content: "\f3e3"; } +.bi-gear-wide::before { content: "\f3e4"; } +.bi-gear::before { content: "\f3e5"; } +.bi-gem::before { content: "\f3e6"; } +.bi-geo-alt-fill::before { content: "\f3e7"; } +.bi-geo-alt::before { content: "\f3e8"; } +.bi-geo-fill::before { content: "\f3e9"; } +.bi-geo::before { content: "\f3ea"; } +.bi-gift-fill::before { content: "\f3eb"; } +.bi-gift::before { content: "\f3ec"; } +.bi-github::before { content: "\f3ed"; } +.bi-globe::before { content: "\f3ee"; } +.bi-globe2::before { content: "\f3ef"; } +.bi-google::before { content: "\f3f0"; } +.bi-graph-down::before { content: "\f3f1"; } +.bi-graph-up::before { content: "\f3f2"; } +.bi-grid-1x2-fill::before { content: "\f3f3"; } +.bi-grid-1x2::before { content: "\f3f4"; } +.bi-grid-3x2-gap-fill::before { content: "\f3f5"; } +.bi-grid-3x2-gap::before { content: "\f3f6"; } +.bi-grid-3x2::before { content: "\f3f7"; } +.bi-grid-3x3-gap-fill::before { content: "\f3f8"; } +.bi-grid-3x3-gap::before { content: "\f3f9"; } +.bi-grid-3x3::before { content: "\f3fa"; } +.bi-grid-fill::before { content: "\f3fb"; } +.bi-grid::before { content: "\f3fc"; } +.bi-grip-horizontal::before { content: "\f3fd"; } +.bi-grip-vertical::before { content: "\f3fe"; } +.bi-hammer::before { content: "\f3ff"; } +.bi-hand-index-fill::before { content: "\f400"; } +.bi-hand-index-thumb-fill::before { content: "\f401"; } +.bi-hand-index-thumb::before { content: "\f402"; } +.bi-hand-index::before { content: "\f403"; } +.bi-hand-thumbs-down-fill::before { content: "\f404"; } +.bi-hand-thumbs-down::before { content: "\f405"; } +.bi-hand-thumbs-up-fill::before { content: "\f406"; } +.bi-hand-thumbs-up::before { content: "\f407"; } +.bi-handbag-fill::before { content: "\f408"; } +.bi-handbag::before { content: "\f409"; } +.bi-hash::before { content: "\f40a"; } +.bi-hdd-fill::before { content: "\f40b"; } +.bi-hdd-network-fill::before { content: "\f40c"; } +.bi-hdd-network::before { content: "\f40d"; } +.bi-hdd-rack-fill::before { content: "\f40e"; } +.bi-hdd-rack::before { content: "\f40f"; } +.bi-hdd-stack-fill::before { content: "\f410"; } +.bi-hdd-stack::before { content: "\f411"; } +.bi-hdd::before { content: "\f412"; } +.bi-headphones::before { content: "\f413"; } +.bi-headset::before { content: "\f414"; } +.bi-heart-fill::before { content: "\f415"; } +.bi-heart-half::before { content: "\f416"; } +.bi-heart::before { content: "\f417"; } +.bi-heptagon-fill::before { content: "\f418"; } +.bi-heptagon-half::before { content: "\f419"; } +.bi-heptagon::before { content: "\f41a"; } +.bi-hexagon-fill::before { content: "\f41b"; } +.bi-hexagon-half::before { content: "\f41c"; } +.bi-hexagon::before { content: "\f41d"; } +.bi-hourglass-bottom::before { content: "\f41e"; } +.bi-hourglass-split::before { content: "\f41f"; } +.bi-hourglass-top::before { content: "\f420"; } +.bi-hourglass::before { content: "\f421"; } +.bi-house-door-fill::before { content: "\f422"; } +.bi-house-door::before { content: "\f423"; } +.bi-house-fill::before { content: "\f424"; } +.bi-house::before { content: "\f425"; } +.bi-hr::before { content: "\f426"; } +.bi-hurricane::before { content: "\f427"; } +.bi-image-alt::before { content: "\f428"; } +.bi-image-fill::before { content: "\f429"; } +.bi-image::before { content: "\f42a"; } +.bi-images::before { content: "\f42b"; } +.bi-inbox-fill::before { content: "\f42c"; } +.bi-inbox::before { content: "\f42d"; } +.bi-inboxes-fill::before { content: "\f42e"; } +.bi-inboxes::before { content: "\f42f"; } +.bi-info-circle-fill::before { content: "\f430"; } +.bi-info-circle::before { content: "\f431"; } +.bi-info-square-fill::before { content: "\f432"; } +.bi-info-square::before { content: "\f433"; } +.bi-info::before { content: "\f434"; } +.bi-input-cursor-text::before { content: "\f435"; } +.bi-input-cursor::before { content: "\f436"; } +.bi-instagram::before { content: "\f437"; } +.bi-intersect::before { content: "\f438"; } +.bi-journal-album::before { content: "\f439"; } +.bi-journal-arrow-down::before { content: "\f43a"; } +.bi-journal-arrow-up::before { content: "\f43b"; } +.bi-journal-bookmark-fill::before { content: "\f43c"; } +.bi-journal-bookmark::before { content: "\f43d"; } +.bi-journal-check::before { content: "\f43e"; } +.bi-journal-code::before { content: "\f43f"; } +.bi-journal-medical::before { content: "\f440"; } +.bi-journal-minus::before { content: "\f441"; } +.bi-journal-plus::before { content: "\f442"; } +.bi-journal-richtext::before { content: "\f443"; } +.bi-journal-text::before { content: "\f444"; } +.bi-journal-x::before { content: "\f445"; } +.bi-journal::before { content: "\f446"; } +.bi-journals::before { content: "\f447"; } +.bi-joystick::before { content: "\f448"; } +.bi-justify-left::before { content: "\f449"; } +.bi-justify-right::before { content: "\f44a"; } +.bi-justify::before { content: "\f44b"; } +.bi-kanban-fill::before { content: "\f44c"; } +.bi-kanban::before { content: "\f44d"; } +.bi-key-fill::before { content: "\f44e"; } +.bi-key::before { content: "\f44f"; } +.bi-keyboard-fill::before { content: "\f450"; } +.bi-keyboard::before { content: "\f451"; } +.bi-ladder::before { content: "\f452"; } +.bi-lamp-fill::before { content: "\f453"; } +.bi-lamp::before { content: "\f454"; } +.bi-laptop-fill::before { content: "\f455"; } +.bi-laptop::before { content: "\f456"; } +.bi-layer-backward::before { content: "\f457"; } +.bi-layer-forward::before { content: "\f458"; } +.bi-layers-fill::before { content: "\f459"; } +.bi-layers-half::before { content: "\f45a"; } +.bi-layers::before { content: "\f45b"; } +.bi-layout-sidebar-inset-reverse::before { content: "\f45c"; } +.bi-layout-sidebar-inset::before { content: "\f45d"; } +.bi-layout-sidebar-reverse::before { content: "\f45e"; } +.bi-layout-sidebar::before { content: "\f45f"; } +.bi-layout-split::before { content: "\f460"; } +.bi-layout-text-sidebar-reverse::before { content: "\f461"; } +.bi-layout-text-sidebar::before { content: "\f462"; } +.bi-layout-text-window-reverse::before { content: "\f463"; } +.bi-layout-text-window::before { content: "\f464"; } +.bi-layout-three-columns::before { content: "\f465"; } +.bi-layout-wtf::before { content: "\f466"; } +.bi-life-preserver::before { content: "\f467"; } +.bi-lightbulb-fill::before { content: "\f468"; } +.bi-lightbulb-off-fill::before { content: "\f469"; } +.bi-lightbulb-off::before { content: "\f46a"; } +.bi-lightbulb::before { content: "\f46b"; } +.bi-lightning-charge-fill::before { content: "\f46c"; } +.bi-lightning-charge::before { content: "\f46d"; } +.bi-lightning-fill::before { content: "\f46e"; } +.bi-lightning::before { content: "\f46f"; } +.bi-link-45deg::before { content: "\f470"; } +.bi-link::before { content: "\f471"; } +.bi-linkedin::before { content: "\f472"; } +.bi-list-check::before { content: "\f473"; } +.bi-list-nested::before { content: "\f474"; } +.bi-list-ol::before { content: "\f475"; } +.bi-list-stars::before { content: "\f476"; } +.bi-list-task::before { content: "\f477"; } +.bi-list-ul::before { content: "\f478"; } +.bi-list::before { content: "\f479"; } +.bi-lock-fill::before { content: "\f47a"; } +.bi-lock::before { content: "\f47b"; } +.bi-mailbox::before { content: "\f47c"; } +.bi-mailbox2::before { content: "\f47d"; } +.bi-map-fill::before { content: "\f47e"; } +.bi-map::before { content: "\f47f"; } +.bi-markdown-fill::before { content: "\f480"; } +.bi-markdown::before { content: "\f481"; } +.bi-mask::before { content: "\f482"; } +.bi-megaphone-fill::before { content: "\f483"; } +.bi-megaphone::before { content: "\f484"; } +.bi-menu-app-fill::before { content: "\f485"; } +.bi-menu-app::before { content: "\f486"; } +.bi-menu-button-fill::before { content: "\f487"; } +.bi-menu-button-wide-fill::before { content: "\f488"; } +.bi-menu-button-wide::before { content: "\f489"; } +.bi-menu-button::before { content: "\f48a"; } +.bi-menu-down::before { content: "\f48b"; } +.bi-menu-up::before { content: "\f48c"; } +.bi-mic-fill::before { content: "\f48d"; } +.bi-mic-mute-fill::before { content: "\f48e"; } +.bi-mic-mute::before { content: "\f48f"; } +.bi-mic::before { content: "\f490"; } +.bi-minecart-loaded::before { content: "\f491"; } +.bi-minecart::before { content: "\f492"; } +.bi-moisture::before { content: "\f493"; } +.bi-moon-fill::before { content: "\f494"; } +.bi-moon-stars-fill::before { content: "\f495"; } +.bi-moon-stars::before { content: "\f496"; } +.bi-moon::before { content: "\f497"; } +.bi-mouse-fill::before { content: "\f498"; } +.bi-mouse::before { content: "\f499"; } +.bi-mouse2-fill::before { content: "\f49a"; } +.bi-mouse2::before { content: "\f49b"; } +.bi-mouse3-fill::before { content: "\f49c"; } +.bi-mouse3::before { content: "\f49d"; } +.bi-music-note-beamed::before { content: "\f49e"; } +.bi-music-note-list::before { content: "\f49f"; } +.bi-music-note::before { content: "\f4a0"; } +.bi-music-player-fill::before { content: "\f4a1"; } +.bi-music-player::before { content: "\f4a2"; } +.bi-newspaper::before { content: "\f4a3"; } +.bi-node-minus-fill::before { content: "\f4a4"; } +.bi-node-minus::before { content: "\f4a5"; } +.bi-node-plus-fill::before { content: "\f4a6"; } +.bi-node-plus::before { content: "\f4a7"; } +.bi-nut-fill::before { content: "\f4a8"; } +.bi-nut::before { content: "\f4a9"; } +.bi-octagon-fill::before { content: "\f4aa"; } +.bi-octagon-half::before { content: "\f4ab"; } +.bi-octagon::before { content: "\f4ac"; } +.bi-option::before { content: "\f4ad"; } +.bi-outlet::before { content: "\f4ae"; } +.bi-paint-bucket::before { content: "\f4af"; } +.bi-palette-fill::before { content: "\f4b0"; } +.bi-palette::before { content: "\f4b1"; } +.bi-palette2::before { content: "\f4b2"; } +.bi-paperclip::before { content: "\f4b3"; } +.bi-paragraph::before { content: "\f4b4"; } +.bi-patch-check-fill::before { content: "\f4b5"; } +.bi-patch-check::before { content: "\f4b6"; } +.bi-patch-exclamation-fill::before { content: "\f4b7"; } +.bi-patch-exclamation::before { content: "\f4b8"; } +.bi-patch-minus-fill::before { content: "\f4b9"; } +.bi-patch-minus::before { content: "\f4ba"; } +.bi-patch-plus-fill::before { content: "\f4bb"; } +.bi-patch-plus::before { content: "\f4bc"; } +.bi-patch-question-fill::before { content: "\f4bd"; } +.bi-patch-question::before { content: "\f4be"; } +.bi-pause-btn-fill::before { content: "\f4bf"; } +.bi-pause-btn::before { content: "\f4c0"; } +.bi-pause-circle-fill::before { content: "\f4c1"; } +.bi-pause-circle::before { content: "\f4c2"; } +.bi-pause-fill::before { content: "\f4c3"; } +.bi-pause::before { content: "\f4c4"; } +.bi-peace-fill::before { content: "\f4c5"; } +.bi-peace::before { content: "\f4c6"; } +.bi-pen-fill::before { content: "\f4c7"; } +.bi-pen::before { content: "\f4c8"; } +.bi-pencil-fill::before { content: "\f4c9"; } +.bi-pencil-square::before { content: "\f4ca"; } +.bi-pencil::before { content: "\f4cb"; } +.bi-pentagon-fill::before { content: "\f4cc"; } +.bi-pentagon-half::before { content: "\f4cd"; } +.bi-pentagon::before { content: "\f4ce"; } +.bi-people-fill::before { content: "\f4cf"; } +.bi-people::before { content: "\f4d0"; } +.bi-percent::before { content: "\f4d1"; } +.bi-person-badge-fill::before { content: "\f4d2"; } +.bi-person-badge::before { content: "\f4d3"; } +.bi-person-bounding-box::before { content: "\f4d4"; } +.bi-person-check-fill::before { content: "\f4d5"; } +.bi-person-check::before { content: "\f4d6"; } +.bi-person-circle::before { content: "\f4d7"; } +.bi-person-dash-fill::before { content: "\f4d8"; } +.bi-person-dash::before { content: "\f4d9"; } +.bi-person-fill::before { content: "\f4da"; } +.bi-person-lines-fill::before { content: "\f4db"; } +.bi-person-plus-fill::before { content: "\f4dc"; } +.bi-person-plus::before { content: "\f4dd"; } +.bi-person-square::before { content: "\f4de"; } +.bi-person-x-fill::before { content: "\f4df"; } +.bi-person-x::before { content: "\f4e0"; } +.bi-person::before { content: "\f4e1"; } +.bi-phone-fill::before { content: "\f4e2"; } +.bi-phone-landscape-fill::before { content: "\f4e3"; } +.bi-phone-landscape::before { content: "\f4e4"; } +.bi-phone-vibrate-fill::before { content: "\f4e5"; } +.bi-phone-vibrate::before { content: "\f4e6"; } +.bi-phone::before { content: "\f4e7"; } +.bi-pie-chart-fill::before { content: "\f4e8"; } +.bi-pie-chart::before { content: "\f4e9"; } +.bi-pin-angle-fill::before { content: "\f4ea"; } +.bi-pin-angle::before { content: "\f4eb"; } +.bi-pin-fill::before { content: "\f4ec"; } +.bi-pin::before { content: "\f4ed"; } +.bi-pip-fill::before { content: "\f4ee"; } +.bi-pip::before { content: "\f4ef"; } +.bi-play-btn-fill::before { content: "\f4f0"; } +.bi-play-btn::before { content: "\f4f1"; } +.bi-play-circle-fill::before { content: "\f4f2"; } +.bi-play-circle::before { content: "\f4f3"; } +.bi-play-fill::before { content: "\f4f4"; } +.bi-play::before { content: "\f4f5"; } +.bi-plug-fill::before { content: "\f4f6"; } +.bi-plug::before { content: "\f4f7"; } +.bi-plus-circle-dotted::before { content: "\f4f8"; } +.bi-plus-circle-fill::before { content: "\f4f9"; } +.bi-plus-circle::before { content: "\f4fa"; } +.bi-plus-square-dotted::before { content: "\f4fb"; } +.bi-plus-square-fill::before { content: "\f4fc"; } +.bi-plus-square::before { content: "\f4fd"; } +.bi-plus::before { content: "\f4fe"; } +.bi-power::before { content: "\f4ff"; } +.bi-printer-fill::before { content: "\f500"; } +.bi-printer::before { content: "\f501"; } +.bi-puzzle-fill::before { content: "\f502"; } +.bi-puzzle::before { content: "\f503"; } +.bi-question-circle-fill::before { content: "\f504"; } +.bi-question-circle::before { content: "\f505"; } +.bi-question-diamond-fill::before { content: "\f506"; } +.bi-question-diamond::before { content: "\f507"; } +.bi-question-octagon-fill::before { content: "\f508"; } +.bi-question-octagon::before { content: "\f509"; } +.bi-question-square-fill::before { content: "\f50a"; } +.bi-question-square::before { content: "\f50b"; } +.bi-question::before { content: "\f50c"; } +.bi-rainbow::before { content: "\f50d"; } +.bi-receipt-cutoff::before { content: "\f50e"; } +.bi-receipt::before { content: "\f50f"; } +.bi-reception-0::before { content: "\f510"; } +.bi-reception-1::before { content: "\f511"; } +.bi-reception-2::before { content: "\f512"; } +.bi-reception-3::before { content: "\f513"; } +.bi-reception-4::before { content: "\f514"; } +.bi-record-btn-fill::before { content: "\f515"; } +.bi-record-btn::before { content: "\f516"; } +.bi-record-circle-fill::before { content: "\f517"; } +.bi-record-circle::before { content: "\f518"; } +.bi-record-fill::before { content: "\f519"; } +.bi-record::before { content: "\f51a"; } +.bi-record2-fill::before { content: "\f51b"; } +.bi-record2::before { content: "\f51c"; } +.bi-reply-all-fill::before { content: "\f51d"; } +.bi-reply-all::before { content: "\f51e"; } +.bi-reply-fill::before { content: "\f51f"; } +.bi-reply::before { content: "\f520"; } +.bi-rss-fill::before { content: "\f521"; } +.bi-rss::before { content: "\f522"; } +.bi-rulers::before { content: "\f523"; } +.bi-save-fill::before { content: "\f524"; } +.bi-save::before { content: "\f525"; } +.bi-save2-fill::before { content: "\f526"; } +.bi-save2::before { content: "\f527"; } +.bi-scissors::before { content: "\f528"; } +.bi-screwdriver::before { content: "\f529"; } +.bi-search::before { content: "\f52a"; } +.bi-segmented-nav::before { content: "\f52b"; } +.bi-server::before { content: "\f52c"; } +.bi-share-fill::before { content: "\f52d"; } +.bi-share::before { content: "\f52e"; } +.bi-shield-check::before { content: "\f52f"; } +.bi-shield-exclamation::before { content: "\f530"; } +.bi-shield-fill-check::before { content: "\f531"; } +.bi-shield-fill-exclamation::before { content: "\f532"; } +.bi-shield-fill-minus::before { content: "\f533"; } +.bi-shield-fill-plus::before { content: "\f534"; } +.bi-shield-fill-x::before { content: "\f535"; } +.bi-shield-fill::before { content: "\f536"; } +.bi-shield-lock-fill::before { content: "\f537"; } +.bi-shield-lock::before { content: "\f538"; } +.bi-shield-minus::before { content: "\f539"; } +.bi-shield-plus::before { content: "\f53a"; } +.bi-shield-shaded::before { content: "\f53b"; } +.bi-shield-slash-fill::before { content: "\f53c"; } +.bi-shield-slash::before { content: "\f53d"; } +.bi-shield-x::before { content: "\f53e"; } +.bi-shield::before { content: "\f53f"; } +.bi-shift-fill::before { content: "\f540"; } +.bi-shift::before { content: "\f541"; } +.bi-shop-window::before { content: "\f542"; } +.bi-shop::before { content: "\f543"; } +.bi-shuffle::before { content: "\f544"; } +.bi-signpost-2-fill::before { content: "\f545"; } +.bi-signpost-2::before { content: "\f546"; } +.bi-signpost-fill::before { content: "\f547"; } +.bi-signpost-split-fill::before { content: "\f548"; } +.bi-signpost-split::before { content: "\f549"; } +.bi-signpost::before { content: "\f54a"; } +.bi-sim-fill::before { content: "\f54b"; } +.bi-sim::before { content: "\f54c"; } +.bi-skip-backward-btn-fill::before { content: "\f54d"; } +.bi-skip-backward-btn::before { content: "\f54e"; } +.bi-skip-backward-circle-fill::before { content: "\f54f"; } +.bi-skip-backward-circle::before { content: "\f550"; } +.bi-skip-backward-fill::before { content: "\f551"; } +.bi-skip-backward::before { content: "\f552"; } +.bi-skip-end-btn-fill::before { content: "\f553"; } +.bi-skip-end-btn::before { content: "\f554"; } +.bi-skip-end-circle-fill::before { content: "\f555"; } +.bi-skip-end-circle::before { content: "\f556"; } +.bi-skip-end-fill::before { content: "\f557"; } +.bi-skip-end::before { content: "\f558"; } +.bi-skip-forward-btn-fill::before { content: "\f559"; } +.bi-skip-forward-btn::before { content: "\f55a"; } +.bi-skip-forward-circle-fill::before { content: "\f55b"; } +.bi-skip-forward-circle::before { content: "\f55c"; } +.bi-skip-forward-fill::before { content: "\f55d"; } +.bi-skip-forward::before { content: "\f55e"; } +.bi-skip-start-btn-fill::before { content: "\f55f"; } +.bi-skip-start-btn::before { content: "\f560"; } +.bi-skip-start-circle-fill::before { content: "\f561"; } +.bi-skip-start-circle::before { content: "\f562"; } +.bi-skip-start-fill::before { content: "\f563"; } +.bi-skip-start::before { content: "\f564"; } +.bi-slack::before { content: "\f565"; } +.bi-slash-circle-fill::before { content: "\f566"; } +.bi-slash-circle::before { content: "\f567"; } +.bi-slash-square-fill::before { content: "\f568"; } +.bi-slash-square::before { content: "\f569"; } +.bi-slash::before { content: "\f56a"; } +.bi-sliders::before { content: "\f56b"; } +.bi-smartwatch::before { content: "\f56c"; } +.bi-snow::before { content: "\f56d"; } +.bi-snow2::before { content: "\f56e"; } +.bi-snow3::before { content: "\f56f"; } +.bi-sort-alpha-down-alt::before { content: "\f570"; } +.bi-sort-alpha-down::before { content: "\f571"; } +.bi-sort-alpha-up-alt::before { content: "\f572"; } +.bi-sort-alpha-up::before { content: "\f573"; } +.bi-sort-down-alt::before { content: "\f574"; } +.bi-sort-down::before { content: "\f575"; } +.bi-sort-numeric-down-alt::before { content: "\f576"; } +.bi-sort-numeric-down::before { content: "\f577"; } +.bi-sort-numeric-up-alt::before { content: "\f578"; } +.bi-sort-numeric-up::before { content: "\f579"; } +.bi-sort-up-alt::before { content: "\f57a"; } +.bi-sort-up::before { content: "\f57b"; } +.bi-soundwave::before { content: "\f57c"; } +.bi-speaker-fill::before { content: "\f57d"; } +.bi-speaker::before { content: "\f57e"; } +.bi-speedometer::before { content: "\f57f"; } +.bi-speedometer2::before { content: "\f580"; } +.bi-spellcheck::before { content: "\f581"; } +.bi-square-fill::before { content: "\f582"; } +.bi-square-half::before { content: "\f583"; } +.bi-square::before { content: "\f584"; } +.bi-stack::before { content: "\f585"; } +.bi-star-fill::before { content: "\f586"; } +.bi-star-half::before { content: "\f587"; } +.bi-star::before { content: "\f588"; } +.bi-stars::before { content: "\f589"; } +.bi-stickies-fill::before { content: "\f58a"; } +.bi-stickies::before { content: "\f58b"; } +.bi-sticky-fill::before { content: "\f58c"; } +.bi-sticky::before { content: "\f58d"; } +.bi-stop-btn-fill::before { content: "\f58e"; } +.bi-stop-btn::before { content: "\f58f"; } +.bi-stop-circle-fill::before { content: "\f590"; } +.bi-stop-circle::before { content: "\f591"; } +.bi-stop-fill::before { content: "\f592"; } +.bi-stop::before { content: "\f593"; } +.bi-stoplights-fill::before { content: "\f594"; } +.bi-stoplights::before { content: "\f595"; } +.bi-stopwatch-fill::before { content: "\f596"; } +.bi-stopwatch::before { content: "\f597"; } +.bi-subtract::before { content: "\f598"; } +.bi-suit-club-fill::before { content: "\f599"; } +.bi-suit-club::before { content: "\f59a"; } +.bi-suit-diamond-fill::before { content: "\f59b"; } +.bi-suit-diamond::before { content: "\f59c"; } +.bi-suit-heart-fill::before { content: "\f59d"; } +.bi-suit-heart::before { content: "\f59e"; } +.bi-suit-spade-fill::before { content: "\f59f"; } +.bi-suit-spade::before { content: "\f5a0"; } +.bi-sun-fill::before { content: "\f5a1"; } +.bi-sun::before { content: "\f5a2"; } +.bi-sunglasses::before { content: "\f5a3"; } +.bi-sunrise-fill::before { content: "\f5a4"; } +.bi-sunrise::before { content: "\f5a5"; } +.bi-sunset-fill::before { content: "\f5a6"; } +.bi-sunset::before { content: "\f5a7"; } +.bi-symmetry-horizontal::before { content: "\f5a8"; } +.bi-symmetry-vertical::before { content: "\f5a9"; } +.bi-table::before { content: "\f5aa"; } +.bi-tablet-fill::before { content: "\f5ab"; } +.bi-tablet-landscape-fill::before { content: "\f5ac"; } +.bi-tablet-landscape::before { content: "\f5ad"; } +.bi-tablet::before { content: "\f5ae"; } +.bi-tag-fill::before { content: "\f5af"; } +.bi-tag::before { content: "\f5b0"; } +.bi-tags-fill::before { content: "\f5b1"; } +.bi-tags::before { content: "\f5b2"; } +.bi-telegram::before { content: "\f5b3"; } +.bi-telephone-fill::before { content: "\f5b4"; } +.bi-telephone-forward-fill::before { content: "\f5b5"; } +.bi-telephone-forward::before { content: "\f5b6"; } +.bi-telephone-inbound-fill::before { content: "\f5b7"; } +.bi-telephone-inbound::before { content: "\f5b8"; } +.bi-telephone-minus-fill::before { content: "\f5b9"; } +.bi-telephone-minus::before { content: "\f5ba"; } +.bi-telephone-outbound-fill::before { content: "\f5bb"; } +.bi-telephone-outbound::before { content: "\f5bc"; } +.bi-telephone-plus-fill::before { content: "\f5bd"; } +.bi-telephone-plus::before { content: "\f5be"; } +.bi-telephone-x-fill::before { content: "\f5bf"; } +.bi-telephone-x::before { content: "\f5c0"; } +.bi-telephone::before { content: "\f5c1"; } +.bi-terminal-fill::before { content: "\f5c2"; } +.bi-terminal::before { content: "\f5c3"; } +.bi-text-center::before { content: "\f5c4"; } +.bi-text-indent-left::before { content: "\f5c5"; } +.bi-text-indent-right::before { content: "\f5c6"; } +.bi-text-left::before { content: "\f5c7"; } +.bi-text-paragraph::before { content: "\f5c8"; } +.bi-text-right::before { content: "\f5c9"; } +.bi-textarea-resize::before { content: "\f5ca"; } +.bi-textarea-t::before { content: "\f5cb"; } +.bi-textarea::before { content: "\f5cc"; } +.bi-thermometer-half::before { content: "\f5cd"; } +.bi-thermometer-high::before { content: "\f5ce"; } +.bi-thermometer-low::before { content: "\f5cf"; } +.bi-thermometer-snow::before { content: "\f5d0"; } +.bi-thermometer-sun::before { content: "\f5d1"; } +.bi-thermometer::before { content: "\f5d2"; } +.bi-three-dots-vertical::before { content: "\f5d3"; } +.bi-three-dots::before { content: "\f5d4"; } +.bi-toggle-off::before { content: "\f5d5"; } +.bi-toggle-on::before { content: "\f5d6"; } +.bi-toggle2-off::before { content: "\f5d7"; } +.bi-toggle2-on::before { content: "\f5d8"; } +.bi-toggles::before { content: "\f5d9"; } +.bi-toggles2::before { content: "\f5da"; } +.bi-tools::before { content: "\f5db"; } +.bi-tornado::before { content: "\f5dc"; } +.bi-trash-fill::before { content: "\f5dd"; } +.bi-trash::before { content: "\f5de"; } +.bi-trash2-fill::before { content: "\f5df"; } +.bi-trash2::before { content: "\f5e0"; } +.bi-tree-fill::before { content: "\f5e1"; } +.bi-tree::before { content: "\f5e2"; } +.bi-triangle-fill::before { content: "\f5e3"; } +.bi-triangle-half::before { content: "\f5e4"; } +.bi-triangle::before { content: "\f5e5"; } +.bi-trophy-fill::before { content: "\f5e6"; } +.bi-trophy::before { content: "\f5e7"; } +.bi-tropical-storm::before { content: "\f5e8"; } +.bi-truck-flatbed::before { content: "\f5e9"; } +.bi-truck::before { content: "\f5ea"; } +.bi-tsunami::before { content: "\f5eb"; } +.bi-tv-fill::before { content: "\f5ec"; } +.bi-tv::before { content: "\f5ed"; } +.bi-twitch::before { content: "\f5ee"; } +.bi-twitter::before { content: "\f5ef"; } +.bi-type-bold::before { content: "\f5f0"; } +.bi-type-h1::before { content: "\f5f1"; } +.bi-type-h2::before { content: "\f5f2"; } +.bi-type-h3::before { content: "\f5f3"; } +.bi-type-italic::before { content: "\f5f4"; } +.bi-type-strikethrough::before { content: "\f5f5"; } +.bi-type-underline::before { content: "\f5f6"; } +.bi-type::before { content: "\f5f7"; } +.bi-ui-checks-grid::before { content: "\f5f8"; } +.bi-ui-checks::before { content: "\f5f9"; } +.bi-ui-radios-grid::before { content: "\f5fa"; } +.bi-ui-radios::before { content: "\f5fb"; } +.bi-umbrella-fill::before { content: "\f5fc"; } +.bi-umbrella::before { content: "\f5fd"; } +.bi-union::before { content: "\f5fe"; } +.bi-unlock-fill::before { content: "\f5ff"; } +.bi-unlock::before { content: "\f600"; } +.bi-upc-scan::before { content: "\f601"; } +.bi-upc::before { content: "\f602"; } +.bi-upload::before { content: "\f603"; } +.bi-vector-pen::before { content: "\f604"; } +.bi-view-list::before { content: "\f605"; } +.bi-view-stacked::before { content: "\f606"; } +.bi-vinyl-fill::before { content: "\f607"; } +.bi-vinyl::before { content: "\f608"; } +.bi-voicemail::before { content: "\f609"; } +.bi-volume-down-fill::before { content: "\f60a"; } +.bi-volume-down::before { content: "\f60b"; } +.bi-volume-mute-fill::before { content: "\f60c"; } +.bi-volume-mute::before { content: "\f60d"; } +.bi-volume-off-fill::before { content: "\f60e"; } +.bi-volume-off::before { content: "\f60f"; } +.bi-volume-up-fill::before { content: "\f610"; } +.bi-volume-up::before { content: "\f611"; } +.bi-vr::before { content: "\f612"; } +.bi-wallet-fill::before { content: "\f613"; } +.bi-wallet::before { content: "\f614"; } +.bi-wallet2::before { content: "\f615"; } +.bi-watch::before { content: "\f616"; } +.bi-water::before { content: "\f617"; } +.bi-whatsapp::before { content: "\f618"; } +.bi-wifi-1::before { content: "\f619"; } +.bi-wifi-2::before { content: "\f61a"; } +.bi-wifi-off::before { content: "\f61b"; } +.bi-wifi::before { content: "\f61c"; } +.bi-wind::before { content: "\f61d"; } +.bi-window-dock::before { content: "\f61e"; } +.bi-window-sidebar::before { content: "\f61f"; } +.bi-window::before { content: "\f620"; } +.bi-wrench::before { content: "\f621"; } +.bi-x-circle-fill::before { content: "\f622"; } +.bi-x-circle::before { content: "\f623"; } +.bi-x-diamond-fill::before { content: "\f624"; } +.bi-x-diamond::before { content: "\f625"; } +.bi-x-octagon-fill::before { content: "\f626"; } +.bi-x-octagon::before { content: "\f627"; } +.bi-x-square-fill::before { content: "\f628"; } +.bi-x-square::before { content: "\f629"; } +.bi-x::before { content: "\f62a"; } +.bi-youtube::before { content: "\f62b"; } +.bi-zoom-in::before { content: "\f62c"; } +.bi-zoom-out::before { content: "\f62d"; } +.bi-bank::before { content: "\f62e"; } +.bi-bank2::before { content: "\f62f"; } +.bi-bell-slash-fill::before { content: "\f630"; } +.bi-bell-slash::before { content: "\f631"; } +.bi-cash-coin::before { content: "\f632"; } +.bi-check-lg::before { content: "\f633"; } +.bi-coin::before { content: "\f634"; } +.bi-currency-bitcoin::before { content: "\f635"; } +.bi-currency-dollar::before { content: "\f636"; } +.bi-currency-euro::before { content: "\f637"; } +.bi-currency-exchange::before { content: "\f638"; } +.bi-currency-pound::before { content: "\f639"; } +.bi-currency-yen::before { content: "\f63a"; } +.bi-dash-lg::before { content: "\f63b"; } +.bi-exclamation-lg::before { content: "\f63c"; } +.bi-file-earmark-pdf-fill::before { content: "\f63d"; } +.bi-file-earmark-pdf::before { content: "\f63e"; } +.bi-file-pdf-fill::before { content: "\f63f"; } +.bi-file-pdf::before { content: "\f640"; } +.bi-gender-ambiguous::before { content: "\f641"; } +.bi-gender-female::before { content: "\f642"; } +.bi-gender-male::before { content: "\f643"; } +.bi-gender-trans::before { content: "\f644"; } +.bi-headset-vr::before { content: "\f645"; } +.bi-info-lg::before { content: "\f646"; } +.bi-mastodon::before { content: "\f647"; } +.bi-messenger::before { content: "\f648"; } +.bi-piggy-bank-fill::before { content: "\f649"; } +.bi-piggy-bank::before { content: "\f64a"; } +.bi-pin-map-fill::before { content: "\f64b"; } +.bi-pin-map::before { content: "\f64c"; } +.bi-plus-lg::before { content: "\f64d"; } +.bi-question-lg::before { content: "\f64e"; } +.bi-recycle::before { content: "\f64f"; } +.bi-reddit::before { content: "\f650"; } +.bi-safe-fill::before { content: "\f651"; } +.bi-safe2-fill::before { content: "\f652"; } +.bi-safe2::before { content: "\f653"; } +.bi-sd-card-fill::before { content: "\f654"; } +.bi-sd-card::before { content: "\f655"; } +.bi-skype::before { content: "\f656"; } +.bi-slash-lg::before { content: "\f657"; } +.bi-translate::before { content: "\f658"; } +.bi-x-lg::before { content: "\f659"; } +.bi-safe::before { content: "\f65a"; } +.bi-apple::before { content: "\f65b"; } +.bi-microsoft::before { content: "\f65d"; } +.bi-windows::before { content: "\f65e"; } +.bi-behance::before { content: "\f65c"; } +.bi-dribbble::before { content: "\f65f"; } +.bi-line::before { content: "\f660"; } +.bi-medium::before { content: "\f661"; } +.bi-paypal::before { content: "\f662"; } +.bi-pinterest::before { content: "\f663"; } +.bi-signal::before { content: "\f664"; } +.bi-snapchat::before { content: "\f665"; } +.bi-spotify::before { content: "\f666"; } +.bi-stack-overflow::before { content: "\f667"; } +.bi-strava::before { content: "\f668"; } +.bi-wordpress::before { content: "\f669"; } +.bi-vimeo::before { content: "\f66a"; } +.bi-activity::before { content: "\f66b"; } +.bi-easel2-fill::before { content: "\f66c"; } +.bi-easel2::before { content: "\f66d"; } +.bi-easel3-fill::before { content: "\f66e"; } +.bi-easel3::before { content: "\f66f"; } +.bi-fan::before { content: "\f670"; } +.bi-fingerprint::before { content: "\f671"; } +.bi-graph-down-arrow::before { content: "\f672"; } +.bi-graph-up-arrow::before { content: "\f673"; } +.bi-hypnotize::before { content: "\f674"; } +.bi-magic::before { content: "\f675"; } +.bi-person-rolodex::before { content: "\f676"; } +.bi-person-video::before { content: "\f677"; } +.bi-person-video2::before { content: "\f678"; } +.bi-person-video3::before { content: "\f679"; } +.bi-person-workspace::before { content: "\f67a"; } +.bi-radioactive::before { content: "\f67b"; } +.bi-webcam-fill::before { content: "\f67c"; } +.bi-webcam::before { content: "\f67d"; } +.bi-yin-yang::before { content: "\f67e"; } +.bi-bandaid-fill::before { content: "\f680"; } +.bi-bandaid::before { content: "\f681"; } +.bi-bluetooth::before { content: "\f682"; } +.bi-body-text::before { content: "\f683"; } +.bi-boombox::before { content: "\f684"; } +.bi-boxes::before { content: "\f685"; } +.bi-dpad-fill::before { content: "\f686"; } +.bi-dpad::before { content: "\f687"; } +.bi-ear-fill::before { content: "\f688"; } +.bi-ear::before { content: "\f689"; } +.bi-envelope-check-fill::before { content: "\f68b"; } +.bi-envelope-check::before { content: "\f68c"; } +.bi-envelope-dash-fill::before { content: "\f68e"; } +.bi-envelope-dash::before { content: "\f68f"; } +.bi-envelope-exclamation-fill::before { content: "\f691"; } +.bi-envelope-exclamation::before { content: "\f692"; } +.bi-envelope-plus-fill::before { content: "\f693"; } +.bi-envelope-plus::before { content: "\f694"; } +.bi-envelope-slash-fill::before { content: "\f696"; } +.bi-envelope-slash::before { content: "\f697"; } +.bi-envelope-x-fill::before { content: "\f699"; } +.bi-envelope-x::before { content: "\f69a"; } +.bi-explicit-fill::before { content: "\f69b"; } +.bi-explicit::before { content: "\f69c"; } +.bi-git::before { content: "\f69d"; } +.bi-infinity::before { content: "\f69e"; } +.bi-list-columns-reverse::before { content: "\f69f"; } +.bi-list-columns::before { content: "\f6a0"; } +.bi-meta::before { content: "\f6a1"; } +.bi-nintendo-switch::before { content: "\f6a4"; } +.bi-pc-display-horizontal::before { content: "\f6a5"; } +.bi-pc-display::before { content: "\f6a6"; } +.bi-pc-horizontal::before { content: "\f6a7"; } +.bi-pc::before { content: "\f6a8"; } +.bi-playstation::before { content: "\f6a9"; } +.bi-plus-slash-minus::before { content: "\f6aa"; } +.bi-projector-fill::before { content: "\f6ab"; } +.bi-projector::before { content: "\f6ac"; } +.bi-qr-code-scan::before { content: "\f6ad"; } +.bi-qr-code::before { content: "\f6ae"; } +.bi-quora::before { content: "\f6af"; } +.bi-quote::before { content: "\f6b0"; } +.bi-robot::before { content: "\f6b1"; } +.bi-send-check-fill::before { content: "\f6b2"; } +.bi-send-check::before { content: "\f6b3"; } +.bi-send-dash-fill::before { content: "\f6b4"; } +.bi-send-dash::before { content: "\f6b5"; } +.bi-send-exclamation-fill::before { content: "\f6b7"; } +.bi-send-exclamation::before { content: "\f6b8"; } +.bi-send-fill::before { content: "\f6b9"; } +.bi-send-plus-fill::before { content: "\f6ba"; } +.bi-send-plus::before { content: "\f6bb"; } +.bi-send-slash-fill::before { content: "\f6bc"; } +.bi-send-slash::before { content: "\f6bd"; } +.bi-send-x-fill::before { content: "\f6be"; } +.bi-send-x::before { content: "\f6bf"; } +.bi-send::before { content: "\f6c0"; } +.bi-steam::before { content: "\f6c1"; } +.bi-terminal-dash::before { content: "\f6c3"; } +.bi-terminal-plus::before { content: "\f6c4"; } +.bi-terminal-split::before { content: "\f6c5"; } +.bi-ticket-detailed-fill::before { content: "\f6c6"; } +.bi-ticket-detailed::before { content: "\f6c7"; } +.bi-ticket-fill::before { content: "\f6c8"; } +.bi-ticket-perforated-fill::before { content: "\f6c9"; } +.bi-ticket-perforated::before { content: "\f6ca"; } +.bi-ticket::before { content: "\f6cb"; } +.bi-tiktok::before { content: "\f6cc"; } +.bi-window-dash::before { content: "\f6cd"; } +.bi-window-desktop::before { content: "\f6ce"; } +.bi-window-fullscreen::before { content: "\f6cf"; } +.bi-window-plus::before { content: "\f6d0"; } +.bi-window-split::before { content: "\f6d1"; } +.bi-window-stack::before { content: "\f6d2"; } +.bi-window-x::before { content: "\f6d3"; } +.bi-xbox::before { content: "\f6d4"; } +.bi-ethernet::before { content: "\f6d5"; } +.bi-hdmi-fill::before { content: "\f6d6"; } +.bi-hdmi::before { content: "\f6d7"; } +.bi-usb-c-fill::before { content: "\f6d8"; } +.bi-usb-c::before { content: "\f6d9"; } +.bi-usb-fill::before { content: "\f6da"; } +.bi-usb-plug-fill::before { content: "\f6db"; } +.bi-usb-plug::before { content: "\f6dc"; } +.bi-usb-symbol::before { content: "\f6dd"; } +.bi-usb::before { content: "\f6de"; } +.bi-boombox-fill::before { content: "\f6df"; } +.bi-displayport::before { content: "\f6e1"; } +.bi-gpu-card::before { content: "\f6e2"; } +.bi-memory::before { content: "\f6e3"; } +.bi-modem-fill::before { content: "\f6e4"; } +.bi-modem::before { content: "\f6e5"; } +.bi-motherboard-fill::before { content: "\f6e6"; } +.bi-motherboard::before { content: "\f6e7"; } +.bi-optical-audio-fill::before { content: "\f6e8"; } +.bi-optical-audio::before { content: "\f6e9"; } +.bi-pci-card::before { content: "\f6ea"; } +.bi-router-fill::before { content: "\f6eb"; } +.bi-router::before { content: "\f6ec"; } +.bi-thunderbolt-fill::before { content: "\f6ef"; } +.bi-thunderbolt::before { content: "\f6f0"; } +.bi-usb-drive-fill::before { content: "\f6f1"; } +.bi-usb-drive::before { content: "\f6f2"; } +.bi-usb-micro-fill::before { content: "\f6f3"; } +.bi-usb-micro::before { content: "\f6f4"; } +.bi-usb-mini-fill::before { content: "\f6f5"; } +.bi-usb-mini::before { content: "\f6f6"; } +.bi-cloud-haze2::before { content: "\f6f7"; } +.bi-device-hdd-fill::before { content: "\f6f8"; } +.bi-device-hdd::before { content: "\f6f9"; } +.bi-device-ssd-fill::before { content: "\f6fa"; } +.bi-device-ssd::before { content: "\f6fb"; } +.bi-displayport-fill::before { content: "\f6fc"; } +.bi-mortarboard-fill::before { content: "\f6fd"; } +.bi-mortarboard::before { content: "\f6fe"; } +.bi-terminal-x::before { content: "\f6ff"; } +.bi-arrow-through-heart-fill::before { content: "\f700"; } +.bi-arrow-through-heart::before { content: "\f701"; } +.bi-badge-sd-fill::before { content: "\f702"; } +.bi-badge-sd::before { content: "\f703"; } +.bi-bag-heart-fill::before { content: "\f704"; } +.bi-bag-heart::before { content: "\f705"; } +.bi-balloon-fill::before { content: "\f706"; } +.bi-balloon-heart-fill::before { content: "\f707"; } +.bi-balloon-heart::before { content: "\f708"; } +.bi-balloon::before { content: "\f709"; } +.bi-box2-fill::before { content: "\f70a"; } +.bi-box2-heart-fill::before { content: "\f70b"; } +.bi-box2-heart::before { content: "\f70c"; } +.bi-box2::before { content: "\f70d"; } +.bi-braces-asterisk::before { content: "\f70e"; } +.bi-calendar-heart-fill::before { content: "\f70f"; } +.bi-calendar-heart::before { content: "\f710"; } +.bi-calendar2-heart-fill::before { content: "\f711"; } +.bi-calendar2-heart::before { content: "\f712"; } +.bi-chat-heart-fill::before { content: "\f713"; } +.bi-chat-heart::before { content: "\f714"; } +.bi-chat-left-heart-fill::before { content: "\f715"; } +.bi-chat-left-heart::before { content: "\f716"; } +.bi-chat-right-heart-fill::before { content: "\f717"; } +.bi-chat-right-heart::before { content: "\f718"; } +.bi-chat-square-heart-fill::before { content: "\f719"; } +.bi-chat-square-heart::before { content: "\f71a"; } +.bi-clipboard-check-fill::before { content: "\f71b"; } +.bi-clipboard-data-fill::before { content: "\f71c"; } +.bi-clipboard-fill::before { content: "\f71d"; } +.bi-clipboard-heart-fill::before { content: "\f71e"; } +.bi-clipboard-heart::before { content: "\f71f"; } +.bi-clipboard-minus-fill::before { content: "\f720"; } +.bi-clipboard-plus-fill::before { content: "\f721"; } +.bi-clipboard-pulse::before { content: "\f722"; } +.bi-clipboard-x-fill::before { content: "\f723"; } +.bi-clipboard2-check-fill::before { content: "\f724"; } +.bi-clipboard2-check::before { content: "\f725"; } +.bi-clipboard2-data-fill::before { content: "\f726"; } +.bi-clipboard2-data::before { content: "\f727"; } +.bi-clipboard2-fill::before { content: "\f728"; } +.bi-clipboard2-heart-fill::before { content: "\f729"; } +.bi-clipboard2-heart::before { content: "\f72a"; } +.bi-clipboard2-minus-fill::before { content: "\f72b"; } +.bi-clipboard2-minus::before { content: "\f72c"; } +.bi-clipboard2-plus-fill::before { content: "\f72d"; } +.bi-clipboard2-plus::before { content: "\f72e"; } +.bi-clipboard2-pulse-fill::before { content: "\f72f"; } +.bi-clipboard2-pulse::before { content: "\f730"; } +.bi-clipboard2-x-fill::before { content: "\f731"; } +.bi-clipboard2-x::before { content: "\f732"; } +.bi-clipboard2::before { content: "\f733"; } +.bi-emoji-kiss-fill::before { content: "\f734"; } +.bi-emoji-kiss::before { content: "\f735"; } +.bi-envelope-heart-fill::before { content: "\f736"; } +.bi-envelope-heart::before { content: "\f737"; } +.bi-envelope-open-heart-fill::before { content: "\f738"; } +.bi-envelope-open-heart::before { content: "\f739"; } +.bi-envelope-paper-fill::before { content: "\f73a"; } +.bi-envelope-paper-heart-fill::before { content: "\f73b"; } +.bi-envelope-paper-heart::before { content: "\f73c"; } +.bi-envelope-paper::before { content: "\f73d"; } +.bi-filetype-aac::before { content: "\f73e"; } +.bi-filetype-ai::before { content: "\f73f"; } +.bi-filetype-bmp::before { content: "\f740"; } +.bi-filetype-cs::before { content: "\f741"; } +.bi-filetype-css::before { content: "\f742"; } +.bi-filetype-csv::before { content: "\f743"; } +.bi-filetype-doc::before { content: "\f744"; } +.bi-filetype-docx::before { content: "\f745"; } +.bi-filetype-exe::before { content: "\f746"; } +.bi-filetype-gif::before { content: "\f747"; } +.bi-filetype-heic::before { content: "\f748"; } +.bi-filetype-html::before { content: "\f749"; } +.bi-filetype-java::before { content: "\f74a"; } +.bi-filetype-jpg::before { content: "\f74b"; } +.bi-filetype-js::before { content: "\f74c"; } +.bi-filetype-jsx::before { content: "\f74d"; } +.bi-filetype-key::before { content: "\f74e"; } +.bi-filetype-m4p::before { content: "\f74f"; } +.bi-filetype-md::before { content: "\f750"; } +.bi-filetype-mdx::before { content: "\f751"; } +.bi-filetype-mov::before { content: "\f752"; } +.bi-filetype-mp3::before { content: "\f753"; } +.bi-filetype-mp4::before { content: "\f754"; } +.bi-filetype-otf::before { content: "\f755"; } +.bi-filetype-pdf::before { content: "\f756"; } +.bi-filetype-php::before { content: "\f757"; } +.bi-filetype-png::before { content: "\f758"; } +.bi-filetype-ppt::before { content: "\f75a"; } +.bi-filetype-psd::before { content: "\f75b"; } +.bi-filetype-py::before { content: "\f75c"; } +.bi-filetype-raw::before { content: "\f75d"; } +.bi-filetype-rb::before { content: "\f75e"; } +.bi-filetype-sass::before { content: "\f75f"; } +.bi-filetype-scss::before { content: "\f760"; } +.bi-filetype-sh::before { content: "\f761"; } +.bi-filetype-svg::before { content: "\f762"; } +.bi-filetype-tiff::before { content: "\f763"; } +.bi-filetype-tsx::before { content: "\f764"; } +.bi-filetype-ttf::before { content: "\f765"; } +.bi-filetype-txt::before { content: "\f766"; } +.bi-filetype-wav::before { content: "\f767"; } +.bi-filetype-woff::before { content: "\f768"; } +.bi-filetype-xls::before { content: "\f76a"; } +.bi-filetype-xml::before { content: "\f76b"; } +.bi-filetype-yml::before { content: "\f76c"; } +.bi-heart-arrow::before { content: "\f76d"; } +.bi-heart-pulse-fill::before { content: "\f76e"; } +.bi-heart-pulse::before { content: "\f76f"; } +.bi-heartbreak-fill::before { content: "\f770"; } +.bi-heartbreak::before { content: "\f771"; } +.bi-hearts::before { content: "\f772"; } +.bi-hospital-fill::before { content: "\f773"; } +.bi-hospital::before { content: "\f774"; } +.bi-house-heart-fill::before { content: "\f775"; } +.bi-house-heart::before { content: "\f776"; } +.bi-incognito::before { content: "\f777"; } +.bi-magnet-fill::before { content: "\f778"; } +.bi-magnet::before { content: "\f779"; } +.bi-person-heart::before { content: "\f77a"; } +.bi-person-hearts::before { content: "\f77b"; } +.bi-phone-flip::before { content: "\f77c"; } +.bi-plugin::before { content: "\f77d"; } +.bi-postage-fill::before { content: "\f77e"; } +.bi-postage-heart-fill::before { content: "\f77f"; } +.bi-postage-heart::before { content: "\f780"; } +.bi-postage::before { content: "\f781"; } +.bi-postcard-fill::before { content: "\f782"; } +.bi-postcard-heart-fill::before { content: "\f783"; } +.bi-postcard-heart::before { content: "\f784"; } +.bi-postcard::before { content: "\f785"; } +.bi-search-heart-fill::before { content: "\f786"; } +.bi-search-heart::before { content: "\f787"; } +.bi-sliders2-vertical::before { content: "\f788"; } +.bi-sliders2::before { content: "\f789"; } +.bi-trash3-fill::before { content: "\f78a"; } +.bi-trash3::before { content: "\f78b"; } +.bi-valentine::before { content: "\f78c"; } +.bi-valentine2::before { content: "\f78d"; } +.bi-wrench-adjustable-circle-fill::before { content: "\f78e"; } +.bi-wrench-adjustable-circle::before { content: "\f78f"; } +.bi-wrench-adjustable::before { content: "\f790"; } +.bi-filetype-json::before { content: "\f791"; } +.bi-filetype-pptx::before { content: "\f792"; } +.bi-filetype-xlsx::before { content: "\f793"; } +.bi-1-circle-fill::before { content: "\f796"; } +.bi-1-circle::before { content: "\f797"; } +.bi-1-square-fill::before { content: "\f798"; } +.bi-1-square::before { content: "\f799"; } +.bi-2-circle-fill::before { content: "\f79c"; } +.bi-2-circle::before { content: "\f79d"; } +.bi-2-square-fill::before { content: "\f79e"; } +.bi-2-square::before { content: "\f79f"; } +.bi-3-circle-fill::before { content: "\f7a2"; } +.bi-3-circle::before { content: "\f7a3"; } +.bi-3-square-fill::before { content: "\f7a4"; } +.bi-3-square::before { content: "\f7a5"; } +.bi-4-circle-fill::before { content: "\f7a8"; } +.bi-4-circle::before { content: "\f7a9"; } +.bi-4-square-fill::before { content: "\f7aa"; } +.bi-4-square::before { content: "\f7ab"; } +.bi-5-circle-fill::before { content: "\f7ae"; } +.bi-5-circle::before { content: "\f7af"; } +.bi-5-square-fill::before { content: "\f7b0"; } +.bi-5-square::before { content: "\f7b1"; } +.bi-6-circle-fill::before { content: "\f7b4"; } +.bi-6-circle::before { content: "\f7b5"; } +.bi-6-square-fill::before { content: "\f7b6"; } +.bi-6-square::before { content: "\f7b7"; } +.bi-7-circle-fill::before { content: "\f7ba"; } +.bi-7-circle::before { content: "\f7bb"; } +.bi-7-square-fill::before { content: "\f7bc"; } +.bi-7-square::before { content: "\f7bd"; } +.bi-8-circle-fill::before { content: "\f7c0"; } +.bi-8-circle::before { content: "\f7c1"; } +.bi-8-square-fill::before { content: "\f7c2"; } +.bi-8-square::before { content: "\f7c3"; } +.bi-9-circle-fill::before { content: "\f7c6"; } +.bi-9-circle::before { content: "\f7c7"; } +.bi-9-square-fill::before { content: "\f7c8"; } +.bi-9-square::before { content: "\f7c9"; } +.bi-airplane-engines-fill::before { content: "\f7ca"; } +.bi-airplane-engines::before { content: "\f7cb"; } +.bi-airplane-fill::before { content: "\f7cc"; } +.bi-airplane::before { content: "\f7cd"; } +.bi-alexa::before { content: "\f7ce"; } +.bi-alipay::before { content: "\f7cf"; } +.bi-android::before { content: "\f7d0"; } +.bi-android2::before { content: "\f7d1"; } +.bi-box-fill::before { content: "\f7d2"; } +.bi-box-seam-fill::before { content: "\f7d3"; } +.bi-browser-chrome::before { content: "\f7d4"; } +.bi-browser-edge::before { content: "\f7d5"; } +.bi-browser-firefox::before { content: "\f7d6"; } +.bi-browser-safari::before { content: "\f7d7"; } +.bi-c-circle-fill::before { content: "\f7da"; } +.bi-c-circle::before { content: "\f7db"; } +.bi-c-square-fill::before { content: "\f7dc"; } +.bi-c-square::before { content: "\f7dd"; } +.bi-capsule-pill::before { content: "\f7de"; } +.bi-capsule::before { content: "\f7df"; } +.bi-car-front-fill::before { content: "\f7e0"; } +.bi-car-front::before { content: "\f7e1"; } +.bi-cassette-fill::before { content: "\f7e2"; } +.bi-cassette::before { content: "\f7e3"; } +.bi-cc-circle-fill::before { content: "\f7e6"; } +.bi-cc-circle::before { content: "\f7e7"; } +.bi-cc-square-fill::before { content: "\f7e8"; } +.bi-cc-square::before { content: "\f7e9"; } +.bi-cup-hot-fill::before { content: "\f7ea"; } +.bi-cup-hot::before { content: "\f7eb"; } +.bi-currency-rupee::before { content: "\f7ec"; } +.bi-dropbox::before { content: "\f7ed"; } +.bi-escape::before { content: "\f7ee"; } +.bi-fast-forward-btn-fill::before { content: "\f7ef"; } +.bi-fast-forward-btn::before { content: "\f7f0"; } +.bi-fast-forward-circle-fill::before { content: "\f7f1"; } +.bi-fast-forward-circle::before { content: "\f7f2"; } +.bi-fast-forward-fill::before { content: "\f7f3"; } +.bi-fast-forward::before { content: "\f7f4"; } +.bi-filetype-sql::before { content: "\f7f5"; } +.bi-fire::before { content: "\f7f6"; } +.bi-google-play::before { content: "\f7f7"; } +.bi-h-circle-fill::before { content: "\f7fa"; } +.bi-h-circle::before { content: "\f7fb"; } +.bi-h-square-fill::before { content: "\f7fc"; } +.bi-h-square::before { content: "\f7fd"; } +.bi-indent::before { content: "\f7fe"; } +.bi-lungs-fill::before { content: "\f7ff"; } +.bi-lungs::before { content: "\f800"; } +.bi-microsoft-teams::before { content: "\f801"; } +.bi-p-circle-fill::before { content: "\f804"; } +.bi-p-circle::before { content: "\f805"; } +.bi-p-square-fill::before { content: "\f806"; } +.bi-p-square::before { content: "\f807"; } +.bi-pass-fill::before { content: "\f808"; } +.bi-pass::before { content: "\f809"; } +.bi-prescription::before { content: "\f80a"; } +.bi-prescription2::before { content: "\f80b"; } +.bi-r-circle-fill::before { content: "\f80e"; } +.bi-r-circle::before { content: "\f80f"; } +.bi-r-square-fill::before { content: "\f810"; } +.bi-r-square::before { content: "\f811"; } +.bi-repeat-1::before { content: "\f812"; } +.bi-repeat::before { content: "\f813"; } +.bi-rewind-btn-fill::before { content: "\f814"; } +.bi-rewind-btn::before { content: "\f815"; } +.bi-rewind-circle-fill::before { content: "\f816"; } +.bi-rewind-circle::before { content: "\f817"; } +.bi-rewind-fill::before { content: "\f818"; } +.bi-rewind::before { content: "\f819"; } +.bi-train-freight-front-fill::before { content: "\f81a"; } +.bi-train-freight-front::before { content: "\f81b"; } +.bi-train-front-fill::before { content: "\f81c"; } +.bi-train-front::before { content: "\f81d"; } +.bi-train-lightrail-front-fill::before { content: "\f81e"; } +.bi-train-lightrail-front::before { content: "\f81f"; } +.bi-truck-front-fill::before { content: "\f820"; } +.bi-truck-front::before { content: "\f821"; } +.bi-ubuntu::before { content: "\f822"; } +.bi-unindent::before { content: "\f823"; } +.bi-unity::before { content: "\f824"; } +.bi-universal-access-circle::before { content: "\f825"; } +.bi-universal-access::before { content: "\f826"; } +.bi-virus::before { content: "\f827"; } +.bi-virus2::before { content: "\f828"; } +.bi-wechat::before { content: "\f829"; } +.bi-yelp::before { content: "\f82a"; } +.bi-sign-stop-fill::before { content: "\f82b"; } +.bi-sign-stop-lights-fill::before { content: "\f82c"; } +.bi-sign-stop-lights::before { content: "\f82d"; } +.bi-sign-stop::before { content: "\f82e"; } +.bi-sign-turn-left-fill::before { content: "\f82f"; } +.bi-sign-turn-left::before { content: "\f830"; } +.bi-sign-turn-right-fill::before { content: "\f831"; } +.bi-sign-turn-right::before { content: "\f832"; } +.bi-sign-turn-slight-left-fill::before { content: "\f833"; } +.bi-sign-turn-slight-left::before { content: "\f834"; } +.bi-sign-turn-slight-right-fill::before { content: "\f835"; } +.bi-sign-turn-slight-right::before { content: "\f836"; } +.bi-sign-yield-fill::before { content: "\f837"; } +.bi-sign-yield::before { content: "\f838"; } +.bi-ev-station-fill::before { content: "\f839"; } +.bi-ev-station::before { content: "\f83a"; } +.bi-fuel-pump-diesel-fill::before { content: "\f83b"; } +.bi-fuel-pump-diesel::before { content: "\f83c"; } +.bi-fuel-pump-fill::before { content: "\f83d"; } +.bi-fuel-pump::before { content: "\f83e"; } +.bi-0-circle-fill::before { content: "\f83f"; } +.bi-0-circle::before { content: "\f840"; } +.bi-0-square-fill::before { content: "\f841"; } +.bi-0-square::before { content: "\f842"; } +.bi-rocket-fill::before { content: "\f843"; } +.bi-rocket-takeoff-fill::before { content: "\f844"; } +.bi-rocket-takeoff::before { content: "\f845"; } +.bi-rocket::before { content: "\f846"; } +.bi-stripe::before { content: "\f847"; } +.bi-subscript::before { content: "\f848"; } +.bi-superscript::before { content: "\f849"; } +.bi-trello::before { content: "\f84a"; } +.bi-envelope-at-fill::before { content: "\f84b"; } +.bi-envelope-at::before { content: "\f84c"; } +.bi-regex::before { content: "\f84d"; } +.bi-text-wrap::before { content: "\f84e"; } +.bi-sign-dead-end-fill::before { content: "\f84f"; } +.bi-sign-dead-end::before { content: "\f850"; } +.bi-sign-do-not-enter-fill::before { content: "\f851"; } +.bi-sign-do-not-enter::before { content: "\f852"; } +.bi-sign-intersection-fill::before { content: "\f853"; } +.bi-sign-intersection-side-fill::before { content: "\f854"; } +.bi-sign-intersection-side::before { content: "\f855"; } +.bi-sign-intersection-t-fill::before { content: "\f856"; } +.bi-sign-intersection-t::before { content: "\f857"; } +.bi-sign-intersection-y-fill::before { content: "\f858"; } +.bi-sign-intersection-y::before { content: "\f859"; } +.bi-sign-intersection::before { content: "\f85a"; } +.bi-sign-merge-left-fill::before { content: "\f85b"; } +.bi-sign-merge-left::before { content: "\f85c"; } +.bi-sign-merge-right-fill::before { content: "\f85d"; } +.bi-sign-merge-right::before { content: "\f85e"; } +.bi-sign-no-left-turn-fill::before { content: "\f85f"; } +.bi-sign-no-left-turn::before { content: "\f860"; } +.bi-sign-no-parking-fill::before { content: "\f861"; } +.bi-sign-no-parking::before { content: "\f862"; } +.bi-sign-no-right-turn-fill::before { content: "\f863"; } +.bi-sign-no-right-turn::before { content: "\f864"; } +.bi-sign-railroad-fill::before { content: "\f865"; } +.bi-sign-railroad::before { content: "\f866"; } +.bi-building-add::before { content: "\f867"; } +.bi-building-check::before { content: "\f868"; } +.bi-building-dash::before { content: "\f869"; } +.bi-building-down::before { content: "\f86a"; } +.bi-building-exclamation::before { content: "\f86b"; } +.bi-building-fill-add::before { content: "\f86c"; } +.bi-building-fill-check::before { content: "\f86d"; } +.bi-building-fill-dash::before { content: "\f86e"; } +.bi-building-fill-down::before { content: "\f86f"; } +.bi-building-fill-exclamation::before { content: "\f870"; } +.bi-building-fill-gear::before { content: "\f871"; } +.bi-building-fill-lock::before { content: "\f872"; } +.bi-building-fill-slash::before { content: "\f873"; } +.bi-building-fill-up::before { content: "\f874"; } +.bi-building-fill-x::before { content: "\f875"; } +.bi-building-fill::before { content: "\f876"; } +.bi-building-gear::before { content: "\f877"; } +.bi-building-lock::before { content: "\f878"; } +.bi-building-slash::before { content: "\f879"; } +.bi-building-up::before { content: "\f87a"; } +.bi-building-x::before { content: "\f87b"; } +.bi-buildings-fill::before { content: "\f87c"; } +.bi-buildings::before { content: "\f87d"; } +.bi-bus-front-fill::before { content: "\f87e"; } +.bi-bus-front::before { content: "\f87f"; } +.bi-ev-front-fill::before { content: "\f880"; } +.bi-ev-front::before { content: "\f881"; } +.bi-globe-americas::before { content: "\f882"; } +.bi-globe-asia-australia::before { content: "\f883"; } +.bi-globe-central-south-asia::before { content: "\f884"; } +.bi-globe-europe-africa::before { content: "\f885"; } +.bi-house-add-fill::before { content: "\f886"; } +.bi-house-add::before { content: "\f887"; } +.bi-house-check-fill::before { content: "\f888"; } +.bi-house-check::before { content: "\f889"; } +.bi-house-dash-fill::before { content: "\f88a"; } +.bi-house-dash::before { content: "\f88b"; } +.bi-house-down-fill::before { content: "\f88c"; } +.bi-house-down::before { content: "\f88d"; } +.bi-house-exclamation-fill::before { content: "\f88e"; } +.bi-house-exclamation::before { content: "\f88f"; } +.bi-house-gear-fill::before { content: "\f890"; } +.bi-house-gear::before { content: "\f891"; } +.bi-house-lock-fill::before { content: "\f892"; } +.bi-house-lock::before { content: "\f893"; } +.bi-house-slash-fill::before { content: "\f894"; } +.bi-house-slash::before { content: "\f895"; } +.bi-house-up-fill::before { content: "\f896"; } +.bi-house-up::before { content: "\f897"; } +.bi-house-x-fill::before { content: "\f898"; } +.bi-house-x::before { content: "\f899"; } +.bi-person-add::before { content: "\f89a"; } +.bi-person-down::before { content: "\f89b"; } +.bi-person-exclamation::before { content: "\f89c"; } +.bi-person-fill-add::before { content: "\f89d"; } +.bi-person-fill-check::before { content: "\f89e"; } +.bi-person-fill-dash::before { content: "\f89f"; } +.bi-person-fill-down::before { content: "\f8a0"; } +.bi-person-fill-exclamation::before { content: "\f8a1"; } +.bi-person-fill-gear::before { content: "\f8a2"; } +.bi-person-fill-lock::before { content: "\f8a3"; } +.bi-person-fill-slash::before { content: "\f8a4"; } +.bi-person-fill-up::before { content: "\f8a5"; } +.bi-person-fill-x::before { content: "\f8a6"; } +.bi-person-gear::before { content: "\f8a7"; } +.bi-person-lock::before { content: "\f8a8"; } +.bi-person-slash::before { content: "\f8a9"; } +.bi-person-up::before { content: "\f8aa"; } +.bi-scooter::before { content: "\f8ab"; } +.bi-taxi-front-fill::before { content: "\f8ac"; } +.bi-taxi-front::before { content: "\f8ad"; } +.bi-amd::before { content: "\f8ae"; } +.bi-database-add::before { content: "\f8af"; } +.bi-database-check::before { content: "\f8b0"; } +.bi-database-dash::before { content: "\f8b1"; } +.bi-database-down::before { content: "\f8b2"; } +.bi-database-exclamation::before { content: "\f8b3"; } +.bi-database-fill-add::before { content: "\f8b4"; } +.bi-database-fill-check::before { content: "\f8b5"; } +.bi-database-fill-dash::before { content: "\f8b6"; } +.bi-database-fill-down::before { content: "\f8b7"; } +.bi-database-fill-exclamation::before { content: "\f8b8"; } +.bi-database-fill-gear::before { content: "\f8b9"; } +.bi-database-fill-lock::before { content: "\f8ba"; } +.bi-database-fill-slash::before { content: "\f8bb"; } +.bi-database-fill-up::before { content: "\f8bc"; } +.bi-database-fill-x::before { content: "\f8bd"; } +.bi-database-fill::before { content: "\f8be"; } +.bi-database-gear::before { content: "\f8bf"; } +.bi-database-lock::before { content: "\f8c0"; } +.bi-database-slash::before { content: "\f8c1"; } +.bi-database-up::before { content: "\f8c2"; } +.bi-database-x::before { content: "\f8c3"; } +.bi-database::before { content: "\f8c4"; } +.bi-houses-fill::before { content: "\f8c5"; } +.bi-houses::before { content: "\f8c6"; } +.bi-nvidia::before { content: "\f8c7"; } +.bi-person-vcard-fill::before { content: "\f8c8"; } +.bi-person-vcard::before { content: "\f8c9"; } +.bi-sina-weibo::before { content: "\f8ca"; } +.bi-tencent-qq::before { content: "\f8cb"; } +.bi-wikipedia::before { content: "\f8cc"; } +.bi-alphabet-uppercase::before { content: "\f2a5"; } +.bi-alphabet::before { content: "\f68a"; } +.bi-amazon::before { content: "\f68d"; } +.bi-arrows-collapse-vertical::before { content: "\f690"; } +.bi-arrows-expand-vertical::before { content: "\f695"; } +.bi-arrows-vertical::before { content: "\f698"; } +.bi-arrows::before { content: "\f6a2"; } +.bi-ban-fill::before { content: "\f6a3"; } +.bi-ban::before { content: "\f6b6"; } +.bi-bing::before { content: "\f6c2"; } +.bi-cake::before { content: "\f6e0"; } +.bi-cake2::before { content: "\f6ed"; } +.bi-cookie::before { content: "\f6ee"; } +.bi-copy::before { content: "\f759"; } +.bi-crosshair::before { content: "\f769"; } +.bi-crosshair2::before { content: "\f794"; } +.bi-emoji-astonished-fill::before { content: "\f795"; } +.bi-emoji-astonished::before { content: "\f79a"; } +.bi-emoji-grimace-fill::before { content: "\f79b"; } +.bi-emoji-grimace::before { content: "\f7a0"; } +.bi-emoji-grin-fill::before { content: "\f7a1"; } +.bi-emoji-grin::before { content: "\f7a6"; } +.bi-emoji-surprise-fill::before { content: "\f7a7"; } +.bi-emoji-surprise::before { content: "\f7ac"; } +.bi-emoji-tear-fill::before { content: "\f7ad"; } +.bi-emoji-tear::before { content: "\f7b2"; } +.bi-envelope-arrow-down-fill::before { content: "\f7b3"; } +.bi-envelope-arrow-down::before { content: "\f7b8"; } +.bi-envelope-arrow-up-fill::before { content: "\f7b9"; } +.bi-envelope-arrow-up::before { content: "\f7be"; } +.bi-feather::before { content: "\f7bf"; } +.bi-feather2::before { content: "\f7c4"; } +.bi-floppy-fill::before { content: "\f7c5"; } +.bi-floppy::before { content: "\f7d8"; } +.bi-floppy2-fill::before { content: "\f7d9"; } +.bi-floppy2::before { content: "\f7e4"; } +.bi-gitlab::before { content: "\f7e5"; } +.bi-highlighter::before { content: "\f7f8"; } +.bi-marker-tip::before { content: "\f802"; } +.bi-nvme-fill::before { content: "\f803"; } +.bi-nvme::before { content: "\f80c"; } +.bi-opencollective::before { content: "\f80d"; } +.bi-pci-card-network::before { content: "\f8cd"; } +.bi-pci-card-sound::before { content: "\f8ce"; } +.bi-radar::before { content: "\f8cf"; } +.bi-send-arrow-down-fill::before { content: "\f8d0"; } +.bi-send-arrow-down::before { content: "\f8d1"; } +.bi-send-arrow-up-fill::before { content: "\f8d2"; } +.bi-send-arrow-up::before { content: "\f8d3"; } +.bi-sim-slash-fill::before { content: "\f8d4"; } +.bi-sim-slash::before { content: "\f8d5"; } +.bi-sourceforge::before { content: "\f8d6"; } +.bi-substack::before { content: "\f8d7"; } +.bi-threads-fill::before { content: "\f8d8"; } +.bi-threads::before { content: "\f8d9"; } +.bi-transparency::before { content: "\f8da"; } +.bi-twitter-x::before { content: "\f8db"; } +.bi-type-h4::before { content: "\f8dc"; } +.bi-type-h5::before { content: "\f8dd"; } +.bi-type-h6::before { content: "\f8de"; } +.bi-backpack-fill::before { content: "\f8df"; } +.bi-backpack::before { content: "\f8e0"; } +.bi-backpack2-fill::before { content: "\f8e1"; } +.bi-backpack2::before { content: "\f8e2"; } +.bi-backpack3-fill::before { content: "\f8e3"; } +.bi-backpack3::before { content: "\f8e4"; } +.bi-backpack4-fill::before { content: "\f8e5"; } +.bi-backpack4::before { content: "\f8e6"; } +.bi-brilliance::before { content: "\f8e7"; } +.bi-cake-fill::before { content: "\f8e8"; } +.bi-cake2-fill::before { content: "\f8e9"; } +.bi-duffle-fill::before { content: "\f8ea"; } +.bi-duffle::before { content: "\f8eb"; } +.bi-exposure::before { content: "\f8ec"; } +.bi-gender-neuter::before { content: "\f8ed"; } +.bi-highlights::before { content: "\f8ee"; } +.bi-luggage-fill::before { content: "\f8ef"; } +.bi-luggage::before { content: "\f8f0"; } +.bi-mailbox-flag::before { content: "\f8f1"; } +.bi-mailbox2-flag::before { content: "\f8f2"; } +.bi-noise-reduction::before { content: "\f8f3"; } +.bi-passport-fill::before { content: "\f8f4"; } +.bi-passport::before { content: "\f8f5"; } +.bi-person-arms-up::before { content: "\f8f6"; } +.bi-person-raised-hand::before { content: "\f8f7"; } +.bi-person-standing-dress::before { content: "\f8f8"; } +.bi-person-standing::before { content: "\f8f9"; } +.bi-person-walking::before { content: "\f8fa"; } +.bi-person-wheelchair::before { content: "\f8fb"; } +.bi-shadows::before { content: "\f8fc"; } +.bi-suitcase-fill::before { content: "\f8fd"; } +.bi-suitcase-lg-fill::before { content: "\f8fe"; } +.bi-suitcase-lg::before { content: "\f8ff"; } +.bi-suitcase::before { content: "\f900"; } +.bi-suitcase2-fill::before { content: "\f901"; } +.bi-suitcase2::before { content: "\f902"; } +.bi-vignette::before { content: "\f903"; } diff --git a/app/assets/stylesheets/vendor/figtree.css b/app/assets/stylesheets/vendor/figtree.css new file mode 100644 index 0000000000..f07c6555fc --- /dev/null +++ b/app/assets/stylesheets/vendor/figtree.css @@ -0,0 +1,84 @@ +/* Figtree -- self-hosted, vendored from @fontsource/figtree (latin + latin-ext, + weights 400-800). Replaces the Google Fonts CDN link; the woff2 files live in + public/vendor/figtree/ and are referenced by absolute URL so the Tailwind CLI + (Lightning CSS) leaves them untouched. Unicode ranges match Google Fonts. */ +@font-face { + font-family: "Figtree"; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url("/vendor/figtree/figtree-latin-400-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} +@font-face { + font-family: "Figtree"; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url("/vendor/figtree/figtree-latin-ext-400-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} +@font-face { + font-family: "Figtree"; + font-style: normal; + font-display: swap; + font-weight: 500; + src: url("/vendor/figtree/figtree-latin-500-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} +@font-face { + font-family: "Figtree"; + font-style: normal; + font-display: swap; + font-weight: 500; + src: url("/vendor/figtree/figtree-latin-ext-500-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} +@font-face { + font-family: "Figtree"; + font-style: normal; + font-display: swap; + font-weight: 600; + src: url("/vendor/figtree/figtree-latin-600-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} +@font-face { + font-family: "Figtree"; + font-style: normal; + font-display: swap; + font-weight: 600; + src: url("/vendor/figtree/figtree-latin-ext-600-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} +@font-face { + font-family: "Figtree"; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url("/vendor/figtree/figtree-latin-700-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} +@font-face { + font-family: "Figtree"; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url("/vendor/figtree/figtree-latin-ext-700-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} +@font-face { + font-family: "Figtree"; + font-style: normal; + font-display: swap; + font-weight: 800; + src: url("/vendor/figtree/figtree-latin-800-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} +@font-face { + font-family: "Figtree"; + font-style: normal; + font-display: swap; + font-weight: 800; + src: url("/vendor/figtree/figtree-latin-ext-800-normal.woff2") format("woff2"); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} 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/hour_minute_duration_component.html.erb b/app/components/form/hour_minute_duration_component.html.erb deleted file mode 100644 index 146096ed98..0000000000 --- a/app/components/form/hour_minute_duration_component.html.erb +++ /dev/null @@ -1,20 +0,0 @@ -
-
- <%= @form.label :duration_hours, "Hour(s)" %> - <%= @form.number_field :duration_hours, - min: 0, - class: "form-control", - style: "background:white", - value: @hour_value, - required: true %> -
-
- <%= @form.label :duration_minutes, "Minute(s)" %> - <%= @form.number_field :duration_minutes, - min: 0, - class: "form-control", - style: "background:white", - value: @minute_value, - required: true %> -
-
diff --git a/app/components/form/hour_minute_duration_component.rb b/app/components/form/hour_minute_duration_component.rb deleted file mode 100644 index 953ccd2403..0000000000 --- a/app/components/form/hour_minute_duration_component.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -class Form::HourMinuteDurationComponent < ViewComponent::Base - def initialize(form:, hour_value:, minute_value:) - @form = form - - if hour_value.is_a?(String) - begin - hour_value = Integer(hour_value) - rescue ArgumentError - raise ArgumentError.new("Could not convert parameter hour_value to an integer") - end - end - - if hour_value.is_a?(Integer) && hour_value < 0 - raise RangeError.new("Parameter hour_value must be positive") - end - - if hour_value.nil? - @hour_value = 0 - elsif hour_value.is_a?(Integer) - @hour_value = hour_value - else - raise TypeError.new("Parameter hour_value must be an integer") - end - - if minute_value.is_a?(String) - begin - minute_value = Integer(minute_value) - rescue ArgumentError - raise ArgumentError.new("Could not convert parameter minute_value to an integer") - end - end - - if minute_value.is_a?(Integer) && minute_value < 0 - raise RangeError.new("Parameter minute_value must be positive") - end - - if minute_value.nil? - @minute_value = 0 - elsif minute_value.is_a?(Integer) - @minute_value = minute_value - else - raise TypeError.new("Parameter minute_value must be an integer") - end - end -end diff --git a/app/components/form/multiple_select_component.html.erb b/app/components/form/multiple_select_component.html.erb index 0720cb874d..0f3dbd560b 100644 --- a/app/components/form/multiple_select_component.html.erb +++ b/app/components/form/multiple_select_component.html.erb @@ -1,4 +1,4 @@ -
<%= @form.select @name, {}, { multiple: true } , { - data: { "multiple-select-target": "select", } , class: "form-control-lg form-select form-select-lg input-group-lg" + data: { "multiple-select-target": "select", } , "aria-label": (@placeholder_term.presence || @name.to_s.humanize).to_s.capitalize, class: "form-control-lg form-select form-select-lg input-group-lg" } %>
diff --git a/app/components/notification_component.html.erb b/app/components/notification_component.html.erb index 9394d3c2f8..aa3b8c86e0 100644 --- a/app/components/notification_component.html.erb +++ b/app/components/notification_component.html.erb @@ -1,21 +1,22 @@ +<% read = notification.read? %> -
-
- <% unless notification.read? %> - - <% end %> -
-
-
-
<%= notification.event.title %>
- <%= time_ago_in_words(notification.created_at) %> ago -
-
- <%= simple_format notification.event.message %> -
+ data-method="post" + data-notification-list-item + <%= "data-read" if read %> + class="group flex items-start gap-3.5 px-4 py-3.5 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500"> + "> + text-sm" aria-hidden="true"> + +
+
+

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

+ <%= time_ago_in_words(notification.created_at) %> ago
+
<%= simple_format notification.event.message %>
+ <% unless read %> + + Unread + <% end %>
diff --git a/app/components/notification_component.rb b/app/components/notification_component.rb index 7641538115..f3606e875d 100644 --- a/app/components/notification_component.rb +++ b/app/components/notification_component.rb @@ -6,8 +6,4 @@ class NotificationComponent < ViewComponent::Base def initialize(notification:) @notification = notification end - - def muted_display - "bg-light text-muted" if notification.read? - end end diff --git a/app/components/sidebar/group_component.html.erb b/app/components/sidebar/group_component.html.erb deleted file mode 100644 index 017193d822..0000000000 --- a/app/components/sidebar/group_component.html.erb +++ /dev/null @@ -1,21 +0,0 @@ - diff --git a/app/components/sidebar/group_component.rb b/app/components/sidebar/group_component.rb deleted file mode 100644 index db75ef20d7..0000000000 --- a/app/components/sidebar/group_component.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -class Sidebar::GroupComponent < ViewComponent::Base - renders_many :links, Sidebar::LinkComponent - - # @param title [String] the title/label for the link - # @param icon [String] the lni icon, pass just the name of the icon (ie. for lni-star --> icon: "star") - # @param render_check [Boolean] whether or not to display the link - def initialize(title:, icon:, render_check: true) - @title = title - @icon = icon - @render_check = render_check - @identifier = title.downcase.tr(" ", "-") - @class = "#{@identifier} collapsed" - end - - # If there are no links or all links fail their render_check, then don't render this group - # @return [Boolean] - def render? - @render_check && !links.empty? && !links.select(&:render?).empty? - end -end diff --git a/app/components/sidebar/link_component.html.erb b/app/components/sidebar/link_component.html.erb deleted file mode 100644 index e1907d1c74..0000000000 --- a/app/components/sidebar/link_component.html.erb +++ /dev/null @@ -1,8 +0,0 @@ -
  • - <%= link_to @path do %> - <% if @icon %> - - <% end %> - <%= @title %> - <% end %> -
  • diff --git a/app/components/sidebar/link_component.rb b/app/components/sidebar/link_component.rb deleted file mode 100644 index d1f0f745b8..0000000000 --- a/app/components/sidebar/link_component.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -class Sidebar::LinkComponent < ViewComponent::Base - include SidebarHelper - - # @param title [String] the title/label for the link - # @param path [String] the path to navigate to - # @param icon [String] the lni icon, pass just the name of the icon (ie. for lni-star --> icon: "star") - # @param nav_item [Boolean] whether or not the link should have the nav-item class - # @param render_check [Boolean] whether or not to display the link - def initialize(title:, path:, icon: nil, nav_item: true, render_check: true) - @title = title - @icon = icon - @path = path - @nav_item = nav_item - @render_check = render_check - end - - # Must be moved to this method in order to use the SidebarHelper - def before_render - @class = @nav_item ? "nav-item #{active_class(@path)}" : "" - end - - # @return [Boolean] - def render? - @render_check - end -end diff --git a/app/components/truncated_text_component.html.erb b/app/components/truncated_text_component.html.erb index 22b5931c81..00015a7600 100644 --- a/app/components/truncated_text_component.html.erb +++ b/app/components/truncated_text_component.html.erb @@ -1,7 +1,7 @@
    <% if label %> - <%= label %>: + <%= label %>: <% end %> <%= text %>
    @@ -10,7 +10,7 @@ href="#" data-truncated-text-target="hideButton" data-action="truncated-text#toggle:prevent" - class="d-none">[hide] + class="hidden">[hide] { @active_nav = "organizations" } def new @casa_admin = CasaAdmin.new diff --git a/app/controllers/all_casa_admins/casa_orgs_controller.rb b/app/controllers/all_casa_admins/casa_orgs_controller.rb index 878bbfcb15..357509ef34 100644 --- a/app/controllers/all_casa_admins/casa_orgs_controller.rb +++ b/app/controllers/all_casa_admins/casa_orgs_controller.rb @@ -1,4 +1,6 @@ class AllCasaAdmins::CasaOrgsController < AllCasaAdminsController + before_action -> { @active_nav = "organizations" } + def show @casa_org = CasaOrg.find(params[:id]) @casa_org_metrics = AllCasaAdmins::CasaOrgMetrics.new(@casa_org).metrics diff --git a/app/controllers/all_casa_admins/dashboard_controller.rb b/app/controllers/all_casa_admins/dashboard_controller.rb index 65070aaafb..ab3f25fd99 100644 --- a/app/controllers/all_casa_admins/dashboard_controller.rb +++ b/app/controllers/all_casa_admins/dashboard_controller.rb @@ -1,4 +1,6 @@ class AllCasaAdmins::DashboardController < AllCasaAdminsController + before_action -> { @active_nav = "organizations" } + def show @organizations = CasaOrg.all end diff --git a/app/controllers/all_casa_admins/invitations_controller.rb b/app/controllers/all_casa_admins/invitations_controller.rb new file mode 100644 index 0000000000..c65591da68 --- /dev/null +++ b/app/controllers/all_casa_admins/invitations_controller.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# All-CASA-admin invitations on the casa_auth shell (was the Bootstrap layouts/devise). +class AllCasaAdmins::InvitationsController < Devise::InvitationsController + layout "casa_auth" +end diff --git a/app/controllers/all_casa_admins/metrics_controller.rb b/app/controllers/all_casa_admins/metrics_controller.rb new file mode 100644 index 0000000000..463a574bf3 --- /dev/null +++ b/app/controllers/all_casa_admins/metrics_controller.rb @@ -0,0 +1,11 @@ +class AllCasaAdmins::MetricsController < AllCasaAdminsController + before_action -> { @active_nav = "metrics" } + + def index + @range = MetricsReport.clamp_range(params[:range]) + report = MetricsReport.new + @case_contacts = report.monthly_case_contacts(@range) + @active_users = report.monthly_active_users(@range) + @contact_heatmap = report.contact_creation_heatmap(@range) + end +end diff --git a/app/controllers/all_casa_admins/passwords_controller.rb b/app/controllers/all_casa_admins/passwords_controller.rb new file mode 100644 index 0000000000..8255e30069 --- /dev/null +++ b/app/controllers/all_casa_admins/passwords_controller.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# All-CASA-admin password reset on the casa_auth shell (was the Bootstrap layouts/devise). +class AllCasaAdmins::PasswordsController < Devise::PasswordsController + layout "casa_auth" +end diff --git a/app/controllers/all_casa_admins/patch_notes_controller.rb b/app/controllers/all_casa_admins/patch_notes_controller.rb index 1dc35295f2..db84053280 100644 --- a/app/controllers/all_casa_admins/patch_notes_controller.rb +++ b/app/controllers/all_casa_admins/patch_notes_controller.rb @@ -1,4 +1,6 @@ class AllCasaAdmins::PatchNotesController < AllCasaAdminsController + before_action -> { @active_nav = "patch_notes" } + # GET /patch_notes or /patch_notes.json def index @patch_note_groups = PatchNoteGroup.all 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/all_casa_admins_controller.rb b/app/controllers/all_casa_admins_controller.rb index 2c544544e6..2de7a779c0 100644 --- a/app/controllers/all_casa_admins_controller.rb +++ b/app/controllers/all_casa_admins_controller.rb @@ -1,6 +1,8 @@ class AllCasaAdminsController < ApplicationController + layout "all_casa_admin" skip_before_action :authenticate_user! before_action :authenticate_all_casa_admin! + before_action -> { @active_nav ||= "profile" } before_action :set_custom_error_heading, only: [:update_password] after_action :reset_custom_error_heading, only: [:update_password] skip_after_action :verify_authorized diff --git a/app/controllers/analytics_controller.rb b/app/controllers/analytics_controller.rb new file mode 100644 index 0000000000..eb2761ff6e --- /dev/null +++ b/app/controllers/analytics_controller.rb @@ -0,0 +1,33 @@ +class AnalyticsController < ApplicationController + after_action :verify_authorized + skip_after_action :verify_policy_scoped # aggregates are org-scoped inside MetricsReport, not via a policy_scope + + def index + authorize :analytics, :index? + @active_nav = "analytics" + @range = MetricsReport.clamp_range(params[:range]) + report = MetricsReport.new(casa_org: current_organization) + @case_contacts = report.monthly_case_contacts(@range) + @active_users = report.monthly_active_users(@range) + @contact_heatmap = report.contact_creation_heatmap(@range) + @kpis = chapter_kpis(report) + render layout: "casa_app" + end + + private + + # Headline chapter numbers for the KPI cards. Reuses AdminDashboard for the org-scoped + # "active volunteers" and "cases needing contact" (the app's canonical 14-day definition, + # batched for org scale) and MetricsReport for the month-over-month contact delta. + def chapter_kpis(report) + dashboard = AdminDashboard.new(current_organization) + this_month = report.contacts_this_month + { + contacts_this_month: this_month, + contacts_delta: this_month - report.contacts_previous_month, + active_volunteers: dashboard.stats[:volunteers], + cases_needing_contact: dashboard.stats[:needs_contact], + followup_days: AdminDashboard::FOLLOWUP_DAYS + } + end +end diff --git a/app/controllers/banners_controller.rb b/app/controllers/banners_controller.rb index 59e7f12768..9c97dbe431 100644 --- a/app/controllers/banners_controller.rb +++ b/app/controllers/banners_controller.rb @@ -1,4 +1,6 @@ class BannersController < ApplicationController + layout "casa_app" + before_action -> { @active_nav = "settings" }, except: %i[dismiss] after_action :verify_authorized, except: %i[dismiss] skip_after_action :verify_policy_scoped # TODO: index should call policy_scope; remove this skip once it does before_action :set_banner, only: %i[edit update destroy dismiss] diff --git a/app/controllers/bulk_court_dates_controller.rb b/app/controllers/bulk_court_dates_controller.rb index cd9848c6df..7541b90fba 100644 --- a/app/controllers/bulk_court_dates_controller.rb +++ b/app/controllers/bulk_court_dates_controller.rb @@ -1,6 +1,8 @@ class BulkCourtDatesController < ApplicationController include CourtDateParams + layout "casa_app" + before_action -> { @active_nav = "cases" } before_action :require_organization! def new diff --git a/app/controllers/casa_admins_controller.rb b/app/controllers/casa_admins_controller.rb index 5cfa6cc309..8467b207e0 100644 --- a/app/controllers/casa_admins_controller.rb +++ b/app/controllers/casa_admins_controller.rb @@ -1,6 +1,8 @@ class CasaAdminsController < ApplicationController include SmsBodyHelper + layout "casa_app" + before_action :set_admin, except: [:index, :new, :create] before_action :require_organization! after_action :verify_authorized 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/casa_org_controller.rb b/app/controllers/casa_org_controller.rb index 010dc1dfa7..523268a469 100644 --- a/app/controllers/casa_org_controller.rb +++ b/app/controllers/casa_org_controller.rb @@ -12,9 +12,11 @@ class CasaOrgController < ApplicationController before_action :require_organization! after_action :verify_authorized before_action :set_active_storage_url_options, only: %i[edit update] + before_action -> { @active_nav = "settings" }, only: %i[edit update] def edit authorize @casa_org + render layout: "casa_app" end def update @@ -30,7 +32,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_org.errors.full_messages, status: :unprocessable_content } end end diff --git a/app/controllers/case_contacts/case_contacts_new_design_controller.rb b/app/controllers/case_contacts/case_contacts_new_design_controller.rb index 1900cf7334..269ad27063 100644 --- a/app/controllers/case_contacts/case_contacts_new_design_controller.rb +++ b/app/controllers/case_contacts/case_contacts_new_design_controller.rb @@ -3,20 +3,42 @@ class CaseContacts::CaseContactsNewDesignController < ApplicationController before_action :check_feature_flag - def index - load_case_contacts - end + # Sortable columns on the casa_app table (server-side ?sort=/?direction=). + SORT_COLUMNS = %w[occurred_at medium_type contact_made].freeze - def datatable + def index authorize CaseContact - case_contacts = policy_scope(current_organization.case_contacts) - datatable = CaseContactDatatable.new(case_contacts, params, current_user) + @active_nav = "contacts" + @current_organization_groups = current_organization_groups + @filterable_cases = current_organization.casa_cases.order(:case_number) + @sort = SORT_COLUMNS.include?(params[:sort]) ? params[:sort] : "occurred_at" + @direction = (params[:direction] == "asc") ? "asc" : "desc" + + scope = filter_case_contacts(policy_scope(current_organization.case_contacts)) + .includes(:casa_case, :contact_types, :contact_topics, :followups, :creator, contact_topic_answers: :contact_topic) + order = Arel.sql("case_contacts.#{@sort} #{@direction} NULLS LAST, case_contacts.id DESC") + @pagy, @case_contacts = pagy(scope.order(order)) - render json: datatable + render layout: "casa_app" end private + # Maps the plain GET filter params to the CaseContact scopes. Contact type is a subquery so + # multi-type contacts are never duplicated by the join. + def filter_case_contacts(scope) + scope = scope.occurred_starting_at(params[:occurred_starting_at]) + scope = scope.occurred_ending_at(params[:occurred_ending_at]) + scope = scope.with_casa_case(params[:casa_case_ids]) if params[:casa_case_ids].present? + if params[:contact_type_ids].present? + scope = scope.where(id: CaseContact.joins(:contact_types).where(contact_types: {id: params[:contact_type_ids]})) + end + scope = scope.contact_medium(params[:contact_medium]) + scope = scope.contact_made(params[:contact_made]) + scope = scope.no_drafts(1) if params[:no_drafts].present? + scope + end + def check_feature_flag unless Flipper.enabled?(:new_case_contact_table) redirect_to case_contacts_path, alert: "This feature is not available." diff --git a/app/controllers/case_contacts/followups_controller.rb b/app/controllers/case_contacts/followups_controller.rb index 9da822b590..64b03c8ede 100644 --- a/app/controllers/case_contacts/followups_controller.rb +++ b/app/controllers/case_contacts/followups_controller.rb @@ -8,7 +8,7 @@ def create FollowupService.create_followup(case_contact, current_user, note) respond_to do |format| - format.html { redirect_to casa_case_path(case_contact.casa_case) } + format.html { redirect_back_or_to casa_case_path(case_contact.casa_case) } format.json { head :no_content } end end @@ -21,7 +21,7 @@ def resolve create_notification respond_to do |format| - format.html { redirect_to casa_case_path(@followup.case_contact.casa_case) } + format.html { redirect_back_or_to casa_case_path(@followup.case_contact.casa_case) } format.json { head :no_content } end end diff --git a/app/controllers/case_contacts/form_controller.rb b/app/controllers/case_contacts/form_controller.rb index 3455cf705c..e216832729 100644 --- a/app/controllers/case_contacts/form_controller.rb +++ b/app/controllers/case_contacts/form_controller.rb @@ -1,8 +1,13 @@ class CaseContacts::FormController < ApplicationController include Wicked::Wizard + # The wizard renders on the casadesign (Tailwind) shell. layout applies to the HTML + # render_wizard/render step paths; the autosave JSON responses skip it. + layout "casa_app" + before_action :require_organization! before_action :set_case_contact, only: [:show, :update] + before_action :set_active_nav, only: [:show, :update] after_action :verify_authorized steps :details @@ -42,6 +47,10 @@ def update private + def set_active_nav + @active_nav = "contacts" + end + def set_case_contact @case_contact = CaseContact .includes(:creator, :contact_topic_answers) diff --git a/app/controllers/case_contacts_controller.rb b/app/controllers/case_contacts_controller.rb index c32204fc1e..f0c3040167 100644 --- a/app/controllers/case_contacts_controller.rb +++ b/app/controllers/case_contacts_controller.rb @@ -9,13 +9,17 @@ class CaseContactsController < ApplicationController after_action :verify_authorized, except: %i[leave] def index + @active_nav = "contacts" load_case_contacts + render :index, layout: "casa_app" unless performed? end def drafts authorize CaseContact + @active_nav = "contacts" @case_contacts = current_organization.case_contacts.not_active + render layout: "casa_app" end def new diff --git a/app/controllers/case_court_reports_controller.rb b/app/controllers/case_court_reports_controller.rb index 247051d94d..2fec5716aa 100644 --- a/app/controllers/case_court_reports_controller.rb +++ b/app/controllers/case_court_reports_controller.rb @@ -7,7 +7,9 @@ class CaseCourtReportsController < ApplicationController def index authorize CaseCourtReport + @active_nav = "court_reports" assigned_cases.select(:id, :case_number, :birth_month_year_youth) + render layout: "casa_app" end def show 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/checklist_items_controller.rb b/app/controllers/checklist_items_controller.rb index ac93da5128..cbf396c367 100644 --- a/app/controllers/checklist_items_controller.rb +++ b/app/controllers/checklist_items_controller.rb @@ -1,4 +1,6 @@ class ChecklistItemsController < ApplicationController + layout "casa_app" + before_action -> { @active_nav = "settings" } before_action :authorize_checklist_item before_action :set_hearing_type before_action :set_checklist_item, except: [:new, :create] diff --git a/app/controllers/contact_topics_controller.rb b/app/controllers/contact_topics_controller.rb index 72f9df6261..9545e76d7d 100644 --- a/app/controllers/contact_topics_controller.rb +++ b/app/controllers/contact_topics_controller.rb @@ -1,4 +1,6 @@ class ContactTopicsController < ApplicationController + layout "casa_app" + before_action -> { @active_nav = "settings" } before_action :set_contact_topic, only: %i[edit update soft_delete] after_action :verify_authorized diff --git a/app/controllers/contact_type_groups_controller.rb b/app/controllers/contact_type_groups_controller.rb index 91f171e07e..19ac9747bf 100644 --- a/app/controllers/contact_type_groups_controller.rb +++ b/app/controllers/contact_type_groups_controller.rb @@ -1,4 +1,6 @@ class ContactTypeGroupsController < ApplicationController + layout "casa_app" + before_action -> { @active_nav = "settings" } before_action :set_contact_type_group, except: [:new, :create] after_action :verify_authorized diff --git a/app/controllers/contact_types_controller.rb b/app/controllers/contact_types_controller.rb index 5a5cca5985..b93861d8bc 100644 --- a/app/controllers/contact_types_controller.rb +++ b/app/controllers/contact_types_controller.rb @@ -1,4 +1,6 @@ class ContactTypesController < ApplicationController + layout "casa_app" + before_action -> { @active_nav = "settings" } before_action :set_contact_type, except: [:new, :create] after_action :verify_authorized diff --git a/app/controllers/court_dates_controller.rb b/app/controllers/court_dates_controller.rb index 518ae709dd..871952f9d3 100644 --- a/app/controllers/court_dates_controller.rb +++ b/app/controllers/court_dates_controller.rb @@ -1,6 +1,8 @@ class CourtDatesController < ApplicationController include CourtDateParams + layout "casa_app" + before_action -> { @active_nav = "cases" } before_action :set_casa_case before_action :set_court_date, only: %i[edit show update destroy] before_action :require_organization! diff --git a/app/controllers/custom_org_links_controller.rb b/app/controllers/custom_org_links_controller.rb index d62794d98f..0b05ad3979 100644 --- a/app/controllers/custom_org_links_controller.rb +++ b/app/controllers/custom_org_links_controller.rb @@ -1,4 +1,6 @@ class CustomOrgLinksController < ApplicationController + layout "casa_app" + before_action -> { @active_nav = "settings" } before_action :set_custom_org_link, only: %i[edit update destroy] after_action :verify_authorized 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/emancipation_checklists_controller.rb b/app/controllers/emancipation_checklists_controller.rb index a004ff1435..ba15c9b01f 100644 --- a/app/controllers/emancipation_checklists_controller.rb +++ b/app/controllers/emancipation_checklists_controller.rb @@ -1,5 +1,7 @@ class EmancipationChecklistsController < ApplicationController include DateHelper + layout "casa_app" + before_action -> { @active_nav = "cases" } before_action :require_organization! after_action :verify_authorized diff --git a/app/controllers/emancipations_controller.rb b/app/controllers/emancipations_controller.rb index 90144e3e52..8c8542065c 100644 --- a/app/controllers/emancipations_controller.rb +++ b/app/controllers/emancipations_controller.rb @@ -1,4 +1,6 @@ class EmancipationsController < ApplicationController + layout "casa_app" + before_action -> { @active_nav = "cases" } before_action :require_organization! after_action :verify_authorized ADD_CATEGORY = "add_category" diff --git a/app/controllers/error_controller.rb b/app/controllers/error_controller.rb index 7aa5294dfa..46b9a4b020 100644 --- a/app/controllers/error_controller.rb +++ b/app/controllers/error_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ErrorController < ApplicationController + layout "error" + skip_before_action :authenticate_user! skip_after_action :verify_authorized skip_after_action :verify_policy_scoped # TODO: index should call policy_scope; remove this skip once it does diff --git a/app/controllers/fund_requests_controller.rb b/app/controllers/fund_requests_controller.rb index 2d213449d1..4050866fb1 100644 --- a/app/controllers/fund_requests_controller.rb +++ b/app/controllers/fund_requests_controller.rb @@ -1,5 +1,8 @@ class FundRequestsController < ApplicationController + layout "casa_app" + before_action :verify_casa_case + before_action -> { @active_nav = "cases" } after_action :verify_authorized def new @@ -13,7 +16,7 @@ def create if @fund_request.save FundRequestMailer.send_request(nil, @fund_request).deliver - redirect_to casa_case_path(@casa_case), notice: "Fund Request was sent for case #{@casa_case.case_number}" + redirect_to casa_case_path(@casa_case), notice: "Fund request was sent for case #{@casa_case.case_number}" else render :new, status: :unprocessable_content end diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb index bbc16f505c..53f2b5be79 100644 --- a/app/controllers/health_controller.rb +++ b/app/controllers/health_controller.rb @@ -3,15 +3,16 @@ class HealthController < ApplicationController skip_before_action :authenticate_user! skip_after_action :verify_authorized - skip_after_action :verify_policy_scoped # TODO: index should call policy_scope; remove this skip once it does + skip_after_action :verify_policy_scoped before_action :verify_token_for_old_object_stats, only: [:old_objects] + # Public ops health check. HTML renders a minimal, self-contained status page; JSON + # returns the latest deploy time (consumed by uptime monitors). Activity charts have + # moved to the authenticated all-CASA "Metrics" console and the per-chapter "Analytics" + # page (see MetricsReport), so this public endpoint exposes no cross-org data. def index respond_to do |format| - format.html do - render :index - end - + format.html { render :index, layout: false } format.json { render json: {latest_deploy_time: Health.instance.latest_deploy_time} } end end @@ -27,62 +28,6 @@ def old_objects content_type: "application/json" end - def case_contacts_creation_times_in_last_week - case_contacts_created_in_last_week = CaseContact.where("created_at >= ?", 1.week.ago) - - unix_timestamps_of_case_contacts_created_in_last_week = case_contacts_created_in_last_week.pluck(:created_at).map { |creation_time| creation_time.to_i } - - render json: {timestamps: unix_timestamps_of_case_contacts_created_in_last_week} - end - - def monthly_line_graph_data - first_day_of_last_12_months = (12.months.ago.to_date..Date.current).select { |date| date.day == 1 }.map { |date| date.beginning_of_month } - - if first_day_of_last_12_months.size > 12 - first_day_of_last_12_months = first_day_of_last_12_months[1..12] - end - - monthly_counts_of_case_contacts_created = CaseContact.group_by_month(:created_at, last: 12).count - monthly_counts_of_case_contacts_with_notes_created = CaseContact.left_outer_joins(:contact_topic_answers).where("case_contacts.notes != '' OR contact_topic_answers.value != ''").select(:id).distinct.group_by_month(:created_at, last: 12).count - monthly_counts_of_users_who_have_created_case_contacts = CaseContact.select(:creator_id).distinct.group_by_month(:created_at, last: 12).count - - monthly_line_graph_combined_data = first_day_of_last_12_months.map do |month| - [ - month.strftime("%b %Y"), - monthly_counts_of_case_contacts_created[month], - monthly_counts_of_case_contacts_with_notes_created[month], - monthly_counts_of_users_who_have_created_case_contacts[month] - ] - end - - render json: monthly_line_graph_combined_data - end - - def monthly_unique_users_graph_data - first_day_of_last_12_months = (12.months.ago.to_date..Date.current).select { |date| date.day == 1 }.map { |date| date.beginning_of_month.strftime("%b %Y") } - - if first_day_of_last_12_months.size > 12 - first_day_of_last_12_months = first_day_of_last_12_months[1..12] - end - - monthly_counts_of_volunteers = LoginActivity.joins("INNER JOIN users ON users.id = login_activities.user_id AND login_activities.user_type = 'User'").where(users: {type: "Volunteer"}, success: true).group_by_month(:created_at, format: "%b %Y").distinct.count(:user_id) - monthly_counts_of_supervisors = LoginActivity.joins("INNER JOIN users ON users.id = login_activities.user_id AND login_activities.user_type = 'User'").where(users: {type: "Supervisor"}, success: true).group_by_month(:created_at, format: "%b %Y").distinct.count(:user_id) - monthly_counts_of_casa_admins = LoginActivity.joins("INNER JOIN users ON users.id = login_activities.user_id AND login_activities.user_type = 'User'").where(users: {type: "CasaAdmin"}, success: true).group_by_month(:created_at, format: "%b %Y").distinct.count(:user_id) - monthly_logged_counts_of_volunteers = CaseContact.joins(supervisor_volunteer: :volunteer).group_by_month(:created_at, format: "%b %Y").distinct.count(:creator_id) - - monthly_line_graph_combined_data = first_day_of_last_12_months.map do |month| - [ - month, - monthly_counts_of_volunteers[month] || 0, - monthly_counts_of_supervisors[month] || 0, - monthly_counts_of_casa_admins[month] || 0, - monthly_logged_counts_of_volunteers[month] || 0 - ] - end - - render json: monthly_line_graph_combined_data - end - private def each_old_object diff --git a/app/controllers/hearing_types_controller.rb b/app/controllers/hearing_types_controller.rb index 524ddf5ec5..607eab3f83 100644 --- a/app/controllers/hearing_types_controller.rb +++ b/app/controllers/hearing_types_controller.rb @@ -1,4 +1,6 @@ class HearingTypesController < ApplicationController + layout "casa_app" + before_action -> { @active_nav = "settings" } before_action :set_hearing_type, except: [:new, :create] after_action :verify_authorized diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index 628d15d34a..58f8032211 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -2,6 +2,8 @@ class ImportsController < ApplicationController require "csv" include ActionView::Helpers::UrlHelper + layout "casa_app" + before_action -> { @active_nav = "settings" } before_action :failed_csv_service, only: [:create, :download_failed] after_action :verify_authorized skip_after_action :verify_policy_scoped # TODO: index should call policy_scope; remove this skip once it does diff --git a/app/controllers/judges_controller.rb b/app/controllers/judges_controller.rb index 68dd5574df..d6074db9e8 100644 --- a/app/controllers/judges_controller.rb +++ b/app/controllers/judges_controller.rb @@ -1,4 +1,6 @@ class JudgesController < ApplicationController + layout "casa_app" + before_action -> { @active_nav = "settings" } before_action :set_judge, except: [:new, :create] after_action :verify_authorized diff --git a/app/controllers/languages_controller.rb b/app/controllers/languages_controller.rb index bc6883fd22..e5efd5fc54 100644 --- a/app/controllers/languages_controller.rb +++ b/app/controllers/languages_controller.rb @@ -1,4 +1,6 @@ class LanguagesController < ApplicationController + layout "casa_app" + before_action -> { @active_nav = "settings" } before_action :set_language, only: %i[edit update] def new diff --git a/app/controllers/learning_hour_topics_controller.rb b/app/controllers/learning_hour_topics_controller.rb index c42d4b2a1f..d8e76c5d1c 100644 --- a/app/controllers/learning_hour_topics_controller.rb +++ b/app/controllers/learning_hour_topics_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class LearningHourTopicsController < ApplicationController + layout "casa_app" + before_action -> { @active_nav = "settings" } before_action :set_learning_hour_topic, only: %i[edit update] after_action :verify_authorized diff --git a/app/controllers/learning_hour_types_controller.rb b/app/controllers/learning_hour_types_controller.rb index 8314baef0d..593f4ef67b 100644 --- a/app/controllers/learning_hour_types_controller.rb +++ b/app/controllers/learning_hour_types_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class LearningHourTypesController < ApplicationController + layout "casa_app" + before_action -> { @active_nav = "settings" } before_action :set_learning_hour_type, only: %i[edit update] after_action :verify_authorized diff --git a/app/controllers/learning_hours/volunteers_controller.rb b/app/controllers/learning_hours/volunteers_controller.rb index 1b228bc3f5..d1094d66b2 100644 --- a/app/controllers/learning_hours/volunteers_controller.rb +++ b/app/controllers/learning_hours/volunteers_controller.rb @@ -4,7 +4,9 @@ class LearningHours::VolunteersController < ApplicationController def show authorize @volunteer + @active_nav = "learning" @learning_hours = LearningHour.where(user: @volunteer) + render layout: "casa_app" end private diff --git a/app/controllers/learning_hours_controller.rb b/app/controllers/learning_hours_controller.rb index d36579420f..bbc0541467 100644 --- a/app/controllers/learning_hours_controller.rb +++ b/app/controllers/learning_hours_controller.rb @@ -1,21 +1,38 @@ class LearningHoursController < ApplicationController before_action :set_learning_hour, only: %i[show edit update destroy] + before_action :set_active_nav, only: %i[index show new create edit update] after_action :verify_authorized, except: :index # TODO add this back and fix all tests def index authorize LearningHour - @learning_hours = LearningHoursDashboardRowsService + rows = LearningHoursDashboardRowsService .new(current_user, policy_scope(LearningHour)) .perform + + if current_user.volunteer? + @learning_hours = rows + else + # Supervisor/admin roster: rows are one per volunteer (an array for supervisors, a + # relation for admins). Paginate uniformly as an array with Pagy. + rows = rows.to_a + per_page = 25 + page = params[:page].to_i.clamp(1, [(rows.size.to_f / per_page).ceil, 1].max) + @pagy = Pagy.new(count: rows.size, page: page, limit: per_page) + @learning_hours = rows[@pagy.offset, per_page] || [] + end + + render :index, layout: "casa_app" end def show authorize @learning_hour + render layout: "casa_app" end def new authorize LearningHour @learning_hour = LearningHour.new + render layout: "casa_app" end def create @@ -26,13 +43,14 @@ def create if @learning_hour.save format.html { redirect_to learning_hours_path, notice: "New entry was successfully created." } else - format.html { render :new, status: :unprocessable_content } + format.html { render :new, status: :unprocessable_content, layout: "casa_app" } end end end def edit authorize @learning_hour + render layout: "casa_app" end def update @@ -41,7 +59,7 @@ def update if @learning_hour.update(update_learning_hours_params) format.html { redirect_to learning_hour_path(@learning_hour), notice: "Entry was successfully updated." } else - format.html { render :edit, status: :unprocessable_content } + format.html { render :edit, status: :unprocessable_content, layout: "casa_app" } end end end @@ -61,6 +79,10 @@ def set_learning_hour redirect_to learning_hours_path end + def set_active_nav + @active_nav = "learning" + end + def learning_hours_params params.require(:learning_hour).permit(:occurred_at, :duration_minutes, :duration_hours, :name, :user_id, :learning_hour_type_id, :learning_hour_topic_id) diff --git a/app/controllers/mileage_rates_controller.rb b/app/controllers/mileage_rates_controller.rb index c1c27ca230..f584b16ad2 100644 --- a/app/controllers/mileage_rates_controller.rb +++ b/app/controllers/mileage_rates_controller.rb @@ -1,4 +1,6 @@ class MileageRatesController < ApplicationController + layout "casa_app" + before_action -> { @active_nav = "settings" } 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_mileage_rate, only: %i[edit update] diff --git a/app/controllers/notes_controller.rb b/app/controllers/notes_controller.rb index e7968c7850..b7ba75d322 100644 --- a/app/controllers/notes_controller.rb +++ b/app/controllers/notes_controller.rb @@ -1,4 +1,6 @@ class NotesController < ApplicationController + layout "casa_app" + before_action :find_volunteer before_action :find_note, only: %i[edit update destroy] @@ -10,6 +12,7 @@ def create def edit authorize @note + @active_nav = "volunteers" end def update 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/other_duties_controller.rb b/app/controllers/other_duties_controller.rb index 8e6e22e66c..5073195db1 100644 --- a/app/controllers/other_duties_controller.rb +++ b/app/controllers/other_duties_controller.rb @@ -1,4 +1,6 @@ class OtherDutiesController < ApplicationController + layout "casa_app" + before_action :set_other_duty, except: [:new, :create, :index] before_action :convert_duration_minutes, only: [:update, :create] skip_after_action :verify_policy_scoped # TODO: index should call policy_scope; remove this skip once it does diff --git a/app/controllers/placement_types_controller.rb b/app/controllers/placement_types_controller.rb index 23141d679b..06c48daaea 100644 --- a/app/controllers/placement_types_controller.rb +++ b/app/controllers/placement_types_controller.rb @@ -1,4 +1,6 @@ class PlacementTypesController < ApplicationController + layout "casa_app" + before_action -> { @active_nav = "settings" } before_action :set_placement_type, only: %i[edit update] after_action :verify_authorized after_action :verify_policy_scoped diff --git a/app/controllers/placements_controller.rb b/app/controllers/placements_controller.rb index f8a8fdb45e..b7cc86f4ab 100644 --- a/app/controllers/placements_controller.rb +++ b/app/controllers/placements_controller.rb @@ -1,4 +1,6 @@ class PlacementsController < ApplicationController + layout "casa_app" + before_action -> { @active_nav = "cases" } before_action :set_casa_case before_action :set_placement, only: %i[edit show update destroy] before_action :require_organization! diff --git a/app/controllers/reimbursements_controller.rb b/app/controllers/reimbursements_controller.rb index f78b0ed3d5..91043ef721 100644 --- a/app/controllers/reimbursements_controller.rb +++ b/app/controllers/reimbursements_controller.rb @@ -5,24 +5,15 @@ def new def index authorize :reimbursement + @active_nav = "reimbursements" @complete_status = params[:status] == "complete" - @datatable_url = datatable_reimbursements_path(format: :json, status: params[:status]) - @volunteers_for_filter = volunteers_for_filter( - fetch_filtered_reimbursements(@complete_status) - ) - @occurred_at_filter_start_date = (Time.now - 1.year).strftime("%Y/%m/%d") - # @grouped_reimbursements = @reimbursements.group_by { |cc| "#{cc.occurred_at}-#{cc.creator_id}" } - end - - def datatable - authorize :reimbursement - - @complete_status = params[:status] == "complete" - datatable = ReimbursementDatatable.new( - fetch_filtered_reimbursements(@complete_status), params - ) - - render json: datatable + # Volunteer options come from the status-scoped set (before the volunteer/occurred-at + # filters) so selecting a volunteer does not collapse the dropdown to that one option. + scoped = status_scoped_reimbursements(@complete_status) + @volunteers_for_filter = volunteers_for_filter(scoped) + @occurred_at_filter_start_date = 1.year.ago.to_date + @pagy, @reimbursements = pagy(apply_filters_to_query(scoped).order(occurred_at: :desc)) + render :index, layout: "casa_app" end def change_complete_status @@ -41,7 +32,7 @@ def change_complete_status private def apply_filters_to_query(query) - query = query.where(creator_id: params[:volunteers]) if params[:volunteers] + query = query.where(creator_id: params[:volunteers]) if params[:volunteers].present? apply_occurred_at_filters(query) end @@ -75,17 +66,15 @@ def fetch_reimbursements policy_scope(case_contacts, policy_scope_class: ReimbursementPolicy::Scope) end - def fetch_filtered_reimbursements(complete_only) - apply_filters_to_query( - fetch_reimbursements - .want_driving_reimbursement(true) - .created_max_ago(1.year.ago) - .filter_by_reimbursement_status(complete_only) - ) + def status_scoped_reimbursements(complete_only) + fetch_reimbursements + .want_driving_reimbursement(true) + .created_max_ago(1.year.ago) + .filter_by_reimbursement_status(complete_only) end def get_normalised_time_for_occurred_at_filter(key) - normalised_date = Date.strptime(params[:occurred_at][key], "%Y/%m/%d") + normalised_date = Date.parse(params[:occurred_at][key]) normalised_time = DateTime.new(normalised_date.year, normalised_date.month, normalised_date.day) return normalised_time if key == :start diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index df0685c6ab..8c03c39880 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -4,6 +4,8 @@ class ReportsController < ApplicationController def index authorize :application, :see_reports_page? + @active_nav = "reports" + render layout: "casa_app" end def export_emails diff --git a/app/controllers/supervisors_controller.rb b/app/controllers/supervisors_controller.rb index c6415ae021..c6a5531f9e 100644 --- a/app/controllers/supervisors_controller.rb +++ b/app/controllers/supervisors_controller.rb @@ -7,18 +7,25 @@ class SupervisorsController < ApplicationController before_action :set_supervisor, only: [:edit, :update, :activate, :deactivate, :resend_invitation, :change_to_admin] before_action :all_volunteers_ever_assigned, only: [:update] before_action :supervisor_has_unassigned_volunteers, only: [:edit] + before_action :set_active_nav, only: [:index, :new, :create, :edit, :update, :activate, :deactivate] after_action :verify_authorized def index authorize Supervisor - @supervisors = policy_scope(current_organization.supervisors) + @status = %w[active inactive all].include?(params[:status]) ? params[:status] : "active" + supervisors = policy_scope(current_organization.supervisors) + supervisors = supervisors.where(active: true) if @status == "active" + supervisors = supervisors.where(active: false) if @status == "inactive" + @supervisors = supervisors.order(:display_name) @casa_cases = current_organization.casa_cases.missing_court_dates + render :index, layout: "casa_app" end def new authorize Supervisor @supervisor = Supervisor.new + render layout: "casa_app" end def create @@ -35,7 +42,7 @@ def create sms_status = deliver_sms_to @supervisor, body_msg redirect_to edit_supervisor_path(@supervisor), notice: sms_acct_creation_notice("supervisor", sms_status) else - render new_supervisor_path, status: :unprocessable_content + render :new, status: :unprocessable_content, layout: "casa_app" end end @@ -45,6 +52,7 @@ def edit all_volunteers_ever_assigned end @unassigned_volunteer_count ||= 0 + render layout: "casa_app" end def update @@ -55,7 +63,7 @@ def update @supervisor.filter_old_emails!(@supervisor.email) redirect_to edit_supervisor_path(@supervisor), notice: notice else - render :edit, status: :unprocessable_content + render :edit, status: :unprocessable_content, layout: "casa_app" end end @@ -66,7 +74,7 @@ def activate redirect_to edit_supervisor_path(@supervisor), notice: "Supervisor was activated. They have been sent an email." else - render :edit, notice: "Supervisor could not be activated." + render :edit, layout: "casa_app", notice: "Supervisor could not be activated." end end @@ -75,7 +83,7 @@ def deactivate if @supervisor.deactivate redirect_to edit_supervisor_path(@supervisor), notice: "Supervisor was deactivated." else - render :edit, notice: "Supervisor could not be deactivated." + render :edit, layout: "casa_app", notice: "Supervisor could not be deactivated." end end @@ -93,20 +101,16 @@ def change_to_admin redirect_to edit_casa_admin_path(@supervisor), notice: "Supervisor was changed to Admin." end - def datatable - authorize Supervisor - supervisors = policy_scope(current_organization.supervisors) - datatable = SupervisorDatatable.new supervisors, params - - render json: datatable - end - private def set_supervisor @supervisor = Supervisor.find(params[:id]) end + def set_active_nav + @active_nav = "supervisors" + end + def all_volunteers_ever_assigned @unassigned_volunteer_count = @supervisor.volunteers_ever_assigned.count - @supervisor.volunteers.count @all_volunteers_ever_assigned = @supervisor.volunteers_ever_assigned diff --git a/app/controllers/users/confirmations_controller.rb b/app/controllers/users/confirmations_controller.rb new file mode 100644 index 0000000000..9f686b917a --- /dev/null +++ b/app/controllers/users/confirmations_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Renders Devise's confirmation (resend) page on the casa_auth shell instead of the retired +# Bootstrap layouts/devise. +class Users::ConfirmationsController < Devise::ConfirmationsController + layout "casa_auth" +end 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..bf5aa8d5b5 100644 --- a/app/controllers/volunteers_controller.rb +++ b/app/controllers/volunteers_controller.rb @@ -1,12 +1,29 @@ class VolunteersController < ApplicationController include SmsBodyHelper - before_action :set_volunteer, except: %i[index new create datatable stop_impersonating] + before_action :set_volunteer, except: %i[index new create stop_impersonating] + before_action :set_edit_context, only: %i[edit update activate deactivate] after_action :verify_authorized, except: %i[stop_impersonating] def index authorize Volunteer - @supervisors = policy_scope(current_organization.supervisors) + @active_nav = "volunteers" + @supervisors = policy_scope(current_organization.supervisors.active) + @search = params[:search].to_s + @status = %w[active inactive all].include?(params[:status]) ? params[:status] : "active" + @supervisor_filter = params[:supervisor].to_s + @transition = %w[yes no].include?(params[:transition]) ? params[:transition] : "" + @extra_languages = %w[yes no].include?(params[:languages]) ? params[:languages] : "" + @sort = VolunteerDatatable::ORDERABLE_FIELDS.include?(params[:sort]) ? params[:sort] : "display_name" + @direction = (params[:direction] == "desc") ? "desc" : "asc" + + datatable = VolunteerDatatable.new(policy_scope(current_organization.volunteers), volunteer_index_params) + count = datatable.index_count + per_page = 25 + page = params[:page].to_i.clamp(1, [(count.to_f / per_page).ceil, 1].max) + @pagy = Pagy.new(count: count, page: page, limit: per_page) + @volunteers = datatable.index_relation.offset(@pagy.offset).limit(per_page).to_a + render :index, layout: "casa_app" end def show @@ -14,17 +31,11 @@ def show redirect_to action: :edit end - def datatable - authorize Volunteer - volunteers = policy_scope current_organization.volunteers - datatable = VolunteerDatatable.new volunteers, params - - render json: datatable - end - def new @volunteer = current_organization.volunteers.new authorize @volunteer + @active_nav = "volunteers" + render layout: "casa_app" end def create @@ -50,13 +61,14 @@ def create sms_status = deliver_sms_to @volunteer, account_activation_msg("volunteer", hash_of_short_urls) redirect_to edit_volunteer_path(@volunteer), notice: sms_acct_creation_notice("volunteer", sms_status) else - render :new, status: :unprocessable_content + @active_nav = "volunteers" + render :new, status: :unprocessable_content, layout: "casa_app" end end def edit authorize @volunteer - @supervisors = policy_scope current_organization.supervisors.active + render layout: "casa_app" end def update @@ -67,7 +79,7 @@ def update @volunteer.filter_old_emails!(@volunteer.email) redirect_to edit_volunteer_path(@volunteer), notice: notice else - render :edit, status: :unprocessable_content + render :edit, status: :unprocessable_content, layout: "casa_app" end end @@ -82,7 +94,7 @@ def activate redirect_to edit_volunteer_path(@volunteer), notice: "Volunteer was activated. They have been sent an email." end else - render :edit, status: :unprocessable_content + render :edit, status: :unprocessable_content, layout: "casa_app" end end @@ -91,7 +103,7 @@ def deactivate if @volunteer.deactivate redirect_to edit_volunteer_path(@volunteer), notice: "Volunteer was deactivated." else - render :edit, status: :unprocessable_content + render :edit, status: :unprocessable_content, layout: "casa_app" end end @@ -131,7 +143,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 @@ -151,6 +163,50 @@ def set_volunteer @volunteer = Volunteer.find(params[:id]) end + # Shared setup for the actions that render the casa_app edit page (edit + the + # update/activate/deactivate failure re-renders): light up the sidebar nav and + # load the active supervisors the "assign a supervisor" form needs. + def set_edit_context + @active_nav = "volunteers" + @supervisors = policy_scope current_organization.supervisors.active + end + + # Map the index's plain GET filters into the DataTables param shape VolunteerDatatable + # understands, so the migrated (bespoke Pagy) index reuses its exact filter/search/order SQL. + def volunteer_index_params + { + search: {value: @search}, + additional_filters: { + active: volunteer_active_filter, + supervisor: volunteer_supervisor_filter, + transition_aged_youth: (@transition.present? ? [(@transition == "yes").to_s] : %w[true false]), + extra_languages: (@extra_languages.present? ? [(@extra_languages == "yes").to_s] : nil) + }, + columns: {"0" => {name: @sort}}, + order: {"0" => {column: "0", dir: @direction}} + }.with_indifferent_access + end + + def volunteer_active_filter + case @status + when "inactive" then %w[false] + when "all" then %w[true false] + else %w[true] + end + end + + # The datatable's supervisor filter is value-list based: [""] means "no supervisor", a list of + # ids means those supervisors, and "" mixed with ids means "null OR those". "All" therefore + # passes "" + every active supervisor id so it also includes volunteers whose supervisor is + # inactive/absent (their joined supervisor is null). + def volunteer_supervisor_filter + case @supervisor_filter + when "", "all" then ["", *@supervisors.map { |s| s.id.to_s }] + when "unassigned" then [""] + else [@supervisor_filter] + end + end + def generate_devise_password Devise.friendly_token.first(8) end diff --git a/app/datatables/case_contact_datatable.rb b/app/datatables/case_contact_datatable.rb deleted file mode 100644 index 7240caf1a9..0000000000 --- a/app/datatables/case_contact_datatable.rb +++ /dev/null @@ -1,114 +0,0 @@ -# frozen_string_literal: true - -class CaseContactDatatable < ApplicationDatatable - ORDERABLE_FIELDS = %w[ - occurred_at - contact_made - medium_type - duration_minutes - ].freeze - - def initialize(base_relation, params, current_user) - super(base_relation, params) - @current_user = current_user - end - - private - - attr_reader :current_user - - def data - records.map do |case_contact| - policy = CaseContactPolicy.new(current_user, case_contact) - requested_followup = case_contact.followups.find(&:requested?) - - { - id: case_contact.id, - occurred_at: I18n.l(case_contact.occurred_at, format: :full, default: nil), - casa_case: { - id: case_contact.casa_case_id, - case_number: case_contact.casa_case&.case_number - }, - contact_types: case_contact.contact_types.map(&:name).join(", "), - medium_type: case_contact.medium_type&.titleize, - creator: { - id: case_contact.creator_id, - display_name: case_contact.creator&.display_name, - email: case_contact.creator&.email, - role: case_contact.creator&.role - }, - contact_made: case_contact.contact_made, - duration_minutes: case_contact.duration_minutes, - contact_topics: case_contact.contact_topics.map(&:question), - contact_topic_answers: case_contact.contact_topic_answers - .reject { |a| a.value.blank? } - .map { |a| {question: a.contact_topic&.question, value: a.value} }, - notes: case_contact.notes.presence, - is_draft: !case_contact.active?, - has_followup: requested_followup.present?, - can_edit: policy.update?, - can_destroy: policy.destroy?, - edit_path: Rails.application.routes.url_helpers.edit_case_contact_path(case_contact), - followup_id: requested_followup&.id - } - end - end - - def filtered_records - apply_additional_filters(raw_records.where(search_filter)) - end - - def apply_additional_filters(records) - records = records.occurred_starting_at(additional_filters[:occurred_starting_at]) - records = records.occurred_ending_at(additional_filters[:occurred_ending_at]) - records = records.with_casa_case(Array(additional_filters[:casa_case_ids])) if additional_filters[:casa_case_ids].present? - records = records.contact_type(Array(additional_filters[:contact_type_ids])) if additional_filters[:contact_type_ids].present? - records = records.contact_medium(additional_filters[:contact_medium]) - records = records.contact_made(additional_filters[:contact_made]) - records = records.no_drafts(additional_filters[:no_drafts].to_i) if additional_filters[:no_drafts].present? - records - end - - def raw_records - base_relation - .joins("INNER JOIN users creators ON creators.id = case_contacts.creator_id") - .left_joins(:casa_case) - .includes(:casa_case, :contact_types, :contact_topics, :followups, :creator, contact_topic_answers: :contact_topic) - .preload(:casa_org, :creator_casa_org) - .order(order_clause) - .order(:id) - end - - def search_filter - return "TRUE" if search_term.blank? - - ilike_fields = %w[ - creators.display_name - creators.email - casa_cases.case_number - case_contacts.notes - ] - - ilike_clauses = ilike_fields.map { |field| "#{field} ILIKE ?" }.join(" OR ") - contact_type_clause = "case_contacts.id IN (#{contact_type_search_subquery})" - - full_clause = "#{ilike_clauses} OR #{contact_type_clause}" - [full_clause, ilike_fields.count.times.map { "%#{search_term}%" }].flatten - end - - def contact_type_search_subquery - @contact_type_search_subquery ||= lambda { - return "SELECT NULL WHERE FALSE" if search_term.blank? - - CaseContact - .select("DISTINCT case_contacts.id") - .joins(case_contact_contact_types: :contact_type) - .where("contact_types.name ILIKE ?", "%#{search_term}%") - .to_sql - }.call - end - - def order_clause - @order_clause ||= build_order_clause - end -end diff --git a/app/datatables/reimbursement_datatable.rb b/app/datatables/reimbursement_datatable.rb deleted file mode 100644 index edf82ffb31..0000000000 --- a/app/datatables/reimbursement_datatable.rb +++ /dev/null @@ -1,62 +0,0 @@ -class ReimbursementDatatable < ApplicationDatatable - ORDERABLE_FIELDS = %w[ - display_name - case_number - occurred_at - miles_driven - ].freeze - - private - - def data - records.map do |case_contact| - { - casa_case: { - id: case_contact.casa_case.id, - case_number: case_contact.casa_case.case_number - }, - complete: case_contact.reimbursement_complete, - contact_types: case_contact_types(case_contact), - id: case_contact.id, - mark_as_complete_path: mark_as_complete_path(case_contact), - miles_driven: case_contact.miles_driven, - occurred_at: case_contact.occurred_at, - volunteer: { - address: case_contact.creator.address&.content, - display_name: case_contact.creator.display_name, - email: case_contact.creator.email, - id: case_contact.creator.id - } - } - end - end - - def case_contact_types(case_contact) - case_contact.contact_types.map do |contact_type| - { - name: contact_type.name, - group_name: contact_type.contact_type_group.name - } - end - end - - def mark_as_complete_path(case_contact) - "/reimbursements/#{case_contact.id}/mark_as_complete" - end - - def raw_records - base_relation - .order(order_clause) - .select( - <<-SQL - case_contacts.*, - users.display_name AS volunteer - SQL - ) - .joins(:creator) - end - - def order_clause - @order_clause ||= build_order_clause - end -end diff --git a/app/datatables/supervisor_datatable.rb b/app/datatables/supervisor_datatable.rb deleted file mode 100644 index 62425a202d..0000000000 --- a/app/datatables/supervisor_datatable.rb +++ /dev/null @@ -1,47 +0,0 @@ -class SupervisorDatatable < ApplicationDatatable - ORDERABLE_FIELDS = %w[ - active - display_name - email - ] - - private - - def data - records.map do |supervisor| - { - id: supervisor.id, - active: supervisor.active?, - display_name: supervisor.display_name, - email: supervisor.email, - volunteer_assignments: supervisor.volunteers.count, - transitions_volunteers: supervisor.volunteers_serving_transition_aged_youth, - no_attempt_for_two_weeks: supervisor.no_attempt_for_two_weeks - } - end - end - - def raw_records - base_relation.order(order_clause, :id) - end - - def filtered_records - raw_records.where(active_filter) - end - - def active_filter - @active_filter ||= - lambda do - filter = additional_filters[:active] - - bool_filter filter do - ["users.active = ?", filter[0]] - end - end.call - end - - def order_clause - @order_clause ||= - build_order_clause || Arel.sql("COALESCE(users.display_name, users.email) #{order_direction}") - end -end diff --git a/app/datatables/volunteer_datatable.rb b/app/datatables/volunteer_datatable.rb index ffb8c7de5e..f84c156023 100644 --- a/app/datatables/volunteer_datatable.rb +++ b/app/datatables/volunteer_datatable.rb @@ -10,6 +10,21 @@ class VolunteerDatatable < ApplicationDatatable hours_spent_in_days ] + # Server-side entry point for the migrated (bespoke Pagy) index. Reuses the same + # filter/search/order SQL as the DataTables JSON path; the controller maps plain GET + # params into the DataTables param shape. Preloads languages for the extra-languages column. + def index_relation + filtered_records.includes(:languages) + end + + # Count for the migrated index's Pagy bar. filtered_records carries a custom SELECT + # (COALESCE aliases) and an ORDER by one of those aliases, neither of which AR's COUNT + # can wrap, so strip them and count distinct volunteers (distinct guards the + # extra-languages join fan-out). + def index_count + index_relation.except(:select, :order, :includes).distinct.count("users.id") + end + private def data @@ -18,7 +33,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 +42,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..a3325c356b 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 @@ -128,15 +141,7 @@ def ambiguous_volunteer_address_message end def form_title - active? ? "Editing Existing Case Contact" : "Record New Case Contact" - end - - def form_page_notes - { - details: nil, - notes: "This question will be included in the court report for your assigned foster youth. Your response here will appear on the generated report for this case. To download the report, head to 'Group Actions'.", - expenses: nil - } + active? ? "Editing existing case contact" : "Record new case contact" end def form_updated_message diff --git a/app/helpers/banner_helper.rb b/app/helpers/banner_helper.rb index d358a7de3d..7c7cfd93a7 100644 --- a/app/helpers/banner_helper.rb +++ b/app/helpers/banner_helper.rb @@ -1,7 +1,7 @@ module BannerHelper def conditionally_add_hidden_class(current_banner_is_active) unless current_banner_is_active && current_organization.has_alternate_active_banner?(@banner.id) - "d-none" + "hidden" end end 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/metrics_helper.rb b/app/helpers/metrics_helper.rb new file mode 100644 index 0000000000..575bc89cad --- /dev/null +++ b/app/helpers/metrics_helper.rb @@ -0,0 +1,231 @@ +module MetricsHelper + 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, base_path) + 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, "#{base_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..b3d6c4e060 --- /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 align-top", "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__/dashboard.test.js b/app/javascript/__tests__/dashboard.test.js deleted file mode 100644 index 3803cc4052..0000000000 --- a/app/javascript/__tests__/dashboard.test.js +++ /dev/null @@ -1,697 +0,0 @@ -/* eslint-env jest */ -/** - * @jest-environment jsdom - */ - -import Swal from 'sweetalert2' -import { defineCaseContactsTable } from '../src/dashboard' -import { fireSwalFollowupAlert } from '../src/case_contact' -jest.mock('sweetalert2', () => ({ - __esModule: true, - default: { fire: jest.fn() } -})) - -jest.mock('../src/case_contact', () => ({ - fireSwalFollowupAlert: jest.fn() -})) - -// Mock DataTable -const mockDataTable = jest.fn() -$.fn.DataTable = mockDataTable - -describe('defineCaseContactsTable', () => { - let tableElement - - beforeEach(() => { - // Reset mocks - mockDataTable.mockClear() - - // Set up DOM - document.body.innerHTML = ` - - - - - - - - - - - - - - - - - -
    DateCaseRelationshipMediumCreated ByContactedTopicsDraft
    - ` - - tableElement = $('table#case_contacts') - }) - - describe('DataTable initialization', () => { - it('initializes DataTable on the case_contacts table', () => { - defineCaseContactsTable() - - expect(mockDataTable).toHaveBeenCalledTimes(1) - expect(mockDataTable.mock.instances[0][0]).toBe(tableElement[0]) - }) - - it('configures DataTable with server-side processing', () => { - defineCaseContactsTable() - - const config = mockDataTable.mock.calls[0][0] - - expect(config.serverSide).toBe(true) - expect(config.processing).toBe(true) - expect(config.searching).toBe(true) - }) - - it('disables autoWidth so columns do not expand to oversized fixed widths', () => { - defineCaseContactsTable() - - const config = mockDataTable.mock.calls[0][0] - - expect(config.autoWidth).toBe(false) - expect(config.scrollX).toBeUndefined() - }) - - it('sets default sort to Date column descending', () => { - defineCaseContactsTable() - - const config = mockDataTable.mock.calls[0][0] - - expect(config.order).toEqual([[2, 'desc']]) - }) - - it('disables ordering on bell, chevron, and ellipsis columns', () => { - defineCaseContactsTable() - - const config = mockDataTable.mock.calls[0][0] - - expect(config.columnDefs).toEqual([ - { orderable: false, targets: [0, 1, 10] } - ]) - }) - }) - - describe('AJAX configuration', () => { - it('uses the data-source URL from the table', () => { - defineCaseContactsTable() - - const config = mockDataTable.mock.calls[0][0] - - expect(config.ajax.url).toBe('/case_contacts/new_design/datatable.json') - expect(config.ajax.type).toBe('POST') - expect(config.ajax.dataType).toBe('json') - }) - - it('includes error handler for AJAX requests', () => { - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation() - - defineCaseContactsTable() - - const config = mockDataTable.mock.calls[0][0] - const mockError = 'Network error' - const mockCode = 500 - - config.ajax.error({}, mockError, mockCode) - - expect(consoleErrorSpy).toHaveBeenCalledWith('DataTable error:', mockError, mockCode) - - consoleErrorSpy.mockRestore() - }) - }) - - describe('column configurations', () => { - let columns - - beforeEach(() => { - defineCaseContactsTable() - columns = mockDataTable.mock.calls[0][0].columns - }) - - it('configures 11 columns', () => { - expect(columns).toHaveLength(11) - }) - - describe('Bell icon column (index 0)', () => { - it('is not orderable or searchable', () => { - expect(columns[0].orderable).toBe(false) - expect(columns[0].searchable).toBe(false) - }) - - it('renders filled bell icon when has_followup is "true"', () => { - const rendered = columns[0].render('true', 'display', {}) - - expect(rendered).toBe('') - }) - - it('renders faded bell icon when has_followup is "false"', () => { - const rendered = columns[0].render('false', 'display', {}) - - expect(rendered).toBe('') - }) - }) - - describe('Chevron icon column (index 1)', () => { - it('is not orderable or searchable', () => { - expect(columns[1].orderable).toBe(false) - expect(columns[1].searchable).toBe(false) - }) - - it('renders chevron-down icon as an accessible button', () => { - const rendered = columns[1].render(null, 'display', {}) - - expect(rendered).toBe('') - }) - }) - - describe('Date column (index 2)', () => { - it('uses occurred_at data field', () => { - expect(columns[2].data).toBe('occurred_at') - expect(columns[2].name).toBe('occurred_at') - }) - - it('renders date string or empty string', () => { - expect(columns[2].render('January 15, 2024')).toBe('January 15, 2024') - expect(columns[2].render(null)).toBe('') - expect(columns[2].render('')).toBe('') - }) - }) - - describe('Case column (index 3)', () => { - it('is not orderable', () => { - expect(columns[3].orderable).toBe(false) - }) - - it('renders link to casa_case when data exists', () => { - const data = { id: '123', case_number: 'CASA-2024-001' } - const rendered = columns[3].render(data, 'display', {}) - - expect(rendered).toBe('
    CASA-2024-001') - }) - - it('renders empty string when casa_case is null', () => { - expect(columns[3].render(null, 'display', {})).toBe('') - }) - - it('renders empty string when casa_case has no id', () => { - const data = { id: null, case_number: 'CASA-2024-001' } - - expect(columns[3].render(data, 'display', {})).toBe('') - }) - }) - - describe('Relationship (Contact Types) column (index 4)', () => { - it('is not orderable', () => { - expect(columns[4].orderable).toBe(false) - }) - - it('renders contact types string', () => { - expect(columns[4].render('Family, School')).toBe('Family, School') - expect(columns[4].render(null)).toBe('') - }) - }) - - describe('Medium column (index 5)', () => { - it('renders medium type', () => { - expect(columns[5].render('In-person')).toBe('In-person') - expect(columns[5].render('Text/Email')).toBe('Text/Email') - expect(columns[5].render(null)).toBe('') - }) - }) - - describe('Created By column (index 6)', () => { - it('is not orderable', () => { - expect(columns[6].orderable).toBe(false) - }) - - it('renders empty string when creator is null', () => { - expect(columns[6].render(null, 'display', {})).toBe('') - }) - - it('renders link to volunteer edit page for volunteers', () => { - const data = { - id: '456', - display_name: 'John Doe', - role: 'Volunteer' - } - const rendered = columns[6].render(data, 'display', {}) - - expect(rendered).toBe('John Doe') - }) - - it('renders link to supervisor edit page for supervisors', () => { - const data = { - id: '789', - display_name: 'Jane Smith', - role: 'Supervisor' - } - const rendered = columns[6].render(data, 'display', {}) - - expect(rendered).toBe('Jane Smith') - }) - - it('renders link to users edit page for casa admins', () => { - const data = { - id: '999', - display_name: 'Admin User', - role: 'Casa Admin' - } - const rendered = columns[6].render(data, 'display', {}) - - expect(rendered).toBe('Admin User') - }) - }) - - describe('Contacted column (index 7)', () => { - it('is not orderable', () => { - expect(columns[7].orderable).toBe(false) - }) - - it('renders checkmark icon when contact was made', () => { - const row = { contact_made: 'true', duration_minutes: null } - const rendered = columns[7].render('true', 'display', row) - - expect(rendered).toContain('') - }) - - it('renders cross icon when contact was not made', () => { - const row = { contact_made: 'false', duration_minutes: null } - const rendered = columns[7].render('false', 'display', row) - - expect(rendered).toContain('') - }) - - it('includes formatted duration when present', () => { - const row = { contact_made: 'true', duration_minutes: 90 } - const rendered = columns[7].render('true', 'display', row) - - expect(rendered).toContain('(01:30)') - }) - - it('formats duration with leading zeros', () => { - const row = { contact_made: 'true', duration_minutes: 5 } - const rendered = columns[7].render('true', 'display', row) - - expect(rendered).toContain('(00:05)') - }) - - it('handles hours and minutes correctly', () => { - const row = { contact_made: 'true', duration_minutes: 125 } - const rendered = columns[7].render('true', 'display', row) - - expect(rendered).toContain('(02:05)') - }) - - it('does not include duration when not present', () => { - const row = { contact_made: 'true', duration_minutes: null } - const rendered = columns[7].render('true', 'display', row) - - expect(rendered).not.toContain('(') - }) - }) - - describe('Topics column (index 8)', () => { - it('is not orderable', () => { - expect(columns[8].orderable).toBe(false) - }) - - it('renders each topic as a pill badge', () => { - const rendered = columns[8].render(['Topic 1', 'Topic 2']) - expect(rendered).toContain('Topic 1') - expect(rendered).toContain('Topic 2') - }) - - it('renders empty string when there are no topics', () => { - expect(columns[8].render(null)).toBe('') - expect(columns[8].render([])).toBe('') - }) - - it('shows only the first two topics when there are more than two', () => { - const rendered = columns[8].render(['A', 'B', 'C', 'D']) - expect(rendered).toContain('>A<') - expect(rendered).toContain('>B<') - expect(rendered).not.toContain('>C<') - expect(rendered).not.toContain('>D<') - }) - - it('shows a +N More badge for overflow topics', () => { - const rendered = columns[8].render(['A', 'B', 'C', 'D']) - expect(rendered).toContain('+2 More') - }) - - it('does not show an overflow badge when there are two or fewer topics', () => { - expect(columns[8].render(['A', 'B'])).not.toContain('More') - expect(columns[8].render(['A'])).not.toContain('More') - }) - }) - - describe('Draft column (index 9)', () => { - it('is not orderable', () => { - expect(columns[9].orderable).toBe(false) - }) - - it('renders Draft badge when is_draft is true', () => { - const rendered = columns[9].render(true, 'display', {}) - - expect(rendered).toBe('Draft') - }) - - it('renders empty string when is_draft is false', () => { - const rendered = columns[9].render(false, 'display', {}) - - expect(rendered).toBe('') - }) - - it('handles string "true" as truthy', () => { - const rendered = columns[9].render('true', 'display', {}) - - expect(rendered).toBe('Draft') - }) - - it('handles string "false" as falsy (explicit check for "true")', () => { - const rendered = columns[9].render('false', 'display', {}) - - // With explicit check for === true || === "true", string "false" should not render badge - expect(rendered).toBe('') - }) - - it('handles empty string as falsy', () => { - const rendered = columns[9].render('', 'display', {}) - - expect(rendered).toBe('') - }) - }) - - describe('Ellipsis menu column (index 10)', () => { - it('is not orderable or searchable', () => { - expect(columns[10].orderable).toBe(false) - expect(columns[10].searchable).toBe(false) - }) - - it('renders a button toggle with aria-label containing the contact date', () => { - const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'true', can_destroy: 'true', edit_path: '/case_contacts/1/edit', followup_id: '' } - const rendered = columns[10].render(null, 'display', row) - - expect(rendered).toContain('class="fas fa-ellipsis-v"') - expect(rendered).toContain('aria-label="Actions for case contact on July 01, 2024"') - expect(rendered).toContain('type="button"') - }) - - it('renders the ellipsis icon as aria-hidden', () => { - const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'true', can_destroy: 'true', edit_path: '/case_contacts/1/edit', followup_id: '' } - const rendered = columns[10].render(null, 'display', row) - - expect(rendered).toContain('aria-hidden="true"') - }) - - it('renders Edit item when can_edit is "true"', () => { - const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'true', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '' } - const rendered = columns[10].render(null, 'display', row) - - expect(rendered).toContain('href="/case_contacts/1/edit"') - expect(rendered).toContain('Edit') - }) - - it('renders Edit as disabled when can_edit is "false"', () => { - const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '' } - const rendered = columns[10].render(null, 'display', row) - - expect(rendered).toContain('Edit') - expect(rendered).toContain('disabled') - expect(rendered).toContain('aria-disabled="true"') - expect(rendered).not.toContain('href="/case_contacts/1/edit"') - }) - - it('renders Delete item when can_destroy is "true"', () => { - const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'true', edit_path: '/case_contacts/1/edit', followup_id: '' } - const rendered = columns[10].render(null, 'display', row) - - expect(rendered).toContain('cc-delete-action') - expect(rendered).toContain('data-id="1"') - expect(rendered).toContain('Delete') - }) - - it('renders Delete as disabled when can_destroy is "false"', () => { - const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '' } - const rendered = columns[10].render(null, 'display', row) - - expect(rendered).toContain('Delete') - expect(rendered).toContain('disabled') - expect(rendered).toContain('aria-disabled="true"') - expect(rendered).not.toContain('cc-delete-action') - }) - - it('renders Set Reminder when followup_id is empty', () => { - const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '' } - const rendered = columns[10].render(null, 'display', row) - - expect(rendered).toContain('cc-set-reminder-action') - expect(rendered).toContain('Set Reminder') - expect(rendered).not.toContain('Resolve Reminder') - }) - - it('renders Resolve Reminder when followup_id is present', () => { - const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '42' } - const rendered = columns[10].render(null, 'display', row) - - expect(rendered).toContain('cc-resolve-reminder-action') - expect(rendered).toContain('data-followup-id="42"') - expect(rendered).toContain('Resolve Reminder') - expect(rendered).not.toContain('Set Reminder') - }) - - it('always renders the reminder menu item', () => { - const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '' } - const rendered = columns[10].render(null, 'display', row) - - expect(rendered).toMatch(/Set Reminder|Resolve Reminder/) - }) - }) - }) - - describe('click handlers', () => { - let mockAjaxReload - let mockTableInstance - - const clickActionButton = (action, attrs = {}) => { - const dataAttrs = Object.entries(attrs).map(([k, v]) => `data-${k}="${v}"`).join(' ') - $('table#case_contacts tbody').append( - `` - ) - $(`.cc-${action}-action`).trigger('click') - } - - beforeEach(() => { - mockAjaxReload = jest.fn() - mockTableInstance = { ajax: { reload: mockAjaxReload } } - mockDataTable.mockReturnValue(mockTableInstance) - - // Add CSRF meta tag - document.head.innerHTML = '' - - defineCaseContactsTable() - }) - - afterEach(() => { - Swal.fire.mockReset() - fireSwalFollowupAlert.mockReset() - }) - - describe('Delete action', () => { - it('shows a SweetAlert confirmation dialog when cc-delete-action is clicked', () => { - Swal.fire.mockResolvedValue({ isConfirmed: false }) - - clickActionButton('delete', { id: '42' }) - - expect(Swal.fire).toHaveBeenCalled() - }) - - it('sends DELETE request when confirmed', async () => { - Swal.fire.mockResolvedValue({ isConfirmed: true }) - const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success()) - - clickActionButton('delete', { id: '42' }) - - await Promise.resolve() - - expect(ajaxSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: '/case_contacts/42', - type: 'DELETE', - headers: { 'X-CSRF-Token': 'test-csrf-token', Accept: 'application/json' } - })) - expect(ajaxSpy.mock.calls[0][0]).not.toHaveProperty('dataType') - - ajaxSpy.mockRestore() - }) - - it('does not send DELETE request when cancelled', async () => { - Swal.fire.mockResolvedValue({ isConfirmed: false }) - const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation() - - clickActionButton('delete', { id: '42' }) - - await Promise.resolve() - - expect(ajaxSpy).not.toHaveBeenCalled() - - ajaxSpy.mockRestore() - }) - - it('reloads the DataTable without resetting pagination after successful delete', async () => { - Swal.fire.mockResolvedValue({ isConfirmed: true }) - jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success()) - - clickActionButton('delete', { id: '42' }) - - await Promise.resolve() - - expect(mockAjaxReload).toHaveBeenCalledWith(null, false) - }) - }) - - describe('Set Reminder action', () => { - it('calls fireSwalFollowupAlert when cc-set-reminder-action is clicked', () => { - fireSwalFollowupAlert.mockResolvedValue({ isConfirmed: false }) - - clickActionButton('set-reminder', { id: '5' }) - - expect(fireSwalFollowupAlert).toHaveBeenCalled() - }) - - it('posts to the followups endpoint with CSRF header when confirmed without a note', async () => { - fireSwalFollowupAlert.mockResolvedValue({ value: '', isConfirmed: true }) - const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success()) - - clickActionButton('set-reminder', { id: '5' }) - - await Promise.resolve() - - expect(ajaxSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: '/case_contacts/5/followups', - type: 'POST', - data: {}, - headers: { 'X-CSRF-Token': 'test-csrf-token', Accept: 'application/json' } - })) - - ajaxSpy.mockRestore() - }) - - it('posts with note when confirmed with a note', async () => { - fireSwalFollowupAlert.mockResolvedValue({ value: 'My note', isConfirmed: true }) - const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success()) - - clickActionButton('set-reminder', { id: '5' }) - - await Promise.resolve() - - expect(ajaxSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: '/case_contacts/5/followups', - type: 'POST', - data: { note: 'My note' }, - headers: { 'X-CSRF-Token': 'test-csrf-token', Accept: 'application/json' } - })) - - ajaxSpy.mockRestore() - }) - - it('does not post when cancelled', async () => { - fireSwalFollowupAlert.mockResolvedValue({ isConfirmed: false }) - const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation() - - clickActionButton('set-reminder', { id: '5' }) - - await Promise.resolve() - - expect(ajaxSpy).not.toHaveBeenCalled() - - ajaxSpy.mockRestore() - }) - - it('reloads the DataTable without resetting pagination after creating a reminder', async () => { - fireSwalFollowupAlert.mockResolvedValue({ value: '', isConfirmed: true }) - jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success()) - - clickActionButton('set-reminder', { id: '5' }) - - await Promise.resolve() - - expect(mockAjaxReload).toHaveBeenCalledWith(null, false) - }) - }) - - describe('Resolve Reminder action', () => { - it('sends PATCH request when cc-resolve-reminder-action is clicked', () => { - const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success()) - - clickActionButton('resolve-reminder', { id: '5', 'followup-id': '42' }) - - expect(ajaxSpy).toHaveBeenCalledWith(expect.objectContaining({ - url: '/followups/42/resolve', - type: 'PATCH', - headers: { 'X-CSRF-Token': 'test-csrf-token', Accept: 'application/json' } - })) - expect(ajaxSpy.mock.calls[0][0]).not.toHaveProperty('dataType') - - ajaxSpy.mockRestore() - }) - - it('reloads the DataTable without resetting pagination after resolving a reminder', () => { - jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success()) - - clickActionButton('resolve-reminder', { id: '5', 'followup-id': '42' }) - - expect(mockAjaxReload).toHaveBeenCalledWith(null, false) - }) - }) - }) - - describe('edge cases', () => { - it('handles missing data-source attribute gracefully', () => { - tableElement.removeAttr('data-source') - - expect(() => defineCaseContactsTable()).not.toThrow() - - const config = mockDataTable.mock.calls[0][0] - expect(config.ajax.url).toBeUndefined() - }) - - it('handles table element not existing', () => { - document.body.innerHTML = '' - - // Should not throw when table doesn't exist - expect(() => defineCaseContactsTable()).not.toThrow() - }) - }) - - describe('DataTable integration', () => { - it('passes all required configuration options', () => { - defineCaseContactsTable() - - const config = mockDataTable.mock.calls[0][0] - - // Verify all critical config options are present - expect(config).toHaveProperty('autoWidth') - expect(config).toHaveProperty('searching') - expect(config).toHaveProperty('processing') - expect(config).toHaveProperty('serverSide') - expect(config).toHaveProperty('order') - expect(config).toHaveProperty('ajax') - expect(config).toHaveProperty('columnDefs') - expect(config).toHaveProperty('columns') - }) - - it('configures columns array matching table structure', () => { - defineCaseContactsTable() - - const config = mockDataTable.mock.calls[0][0] - const headerColumns = $('table#case_contacts thead th').length - - expect(config.columns.length).toBe(headerColumns) - }) - }) -}) 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/__tests__/nav_drawer_controller.test.js b/app/javascript/__tests__/nav_drawer_controller.test.js new file mode 100644 index 0000000000..9a8b07fd4d --- /dev/null +++ b/app/javascript/__tests__/nav_drawer_controller.test.js @@ -0,0 +1,71 @@ +/* eslint-env jest, browser */ +/** + * @jest-environment jsdom + */ +import { Application } from '@hotwired/stimulus' +import NavDrawerController from '../controllers/nav_drawer_controller' + +describe('nav_drawer_controller', () => { + let application + + const mount = async () => { + document.body.innerHTML = ` +
    + + + +
    ` + application = Application.start() + application.register('nav-drawer', NavDrawerController) + await new Promise((resolve) => setTimeout(resolve, 0)) + } + + const els = () => ({ + backdrop: document.querySelector('[data-nav-drawer-target="backdrop"]'), + sidebar: document.querySelector('[data-nav-drawer-target="sidebar"]'), + button: document.querySelector('[data-nav-drawer-target="button"]') + }) + + afterEach(() => { + if (application) application.stop() + document.body.innerHTML = '' + document.body.classList.remove('overflow-hidden') + }) + + test('the toggle button opens the drawer', async () => { + await mount() + const { backdrop, sidebar, button } = els() + button.click() + expect(sidebar.classList.contains('-translate-x-full')).toBe(false) + expect(backdrop.classList.contains('hidden')).toBe(false) + expect(button.getAttribute('aria-expanded')).toBe('true') + expect(document.body.classList.contains('overflow-hidden')).toBe(true) + }) + + test('the toggle button closes an open drawer', async () => { + await mount() + const { sidebar, button } = els() + button.click() // open + button.click() // close + expect(sidebar.classList.contains('-translate-x-full')).toBe(true) + expect(button.getAttribute('aria-expanded')).toBe('false') + expect(document.body.classList.contains('overflow-hidden')).toBe(false) + }) + + test('clicking the backdrop closes the drawer', async () => { + await mount() + const { backdrop, sidebar, button } = els() + button.click() // open + backdrop.click() + expect(sidebar.classList.contains('-translate-x-full')).toBe(true) + expect(backdrop.classList.contains('hidden')).toBe(true) + }) + + test('pressing Escape closes the drawer', async () => { + await mount() + const { sidebar, button } = els() + button.click() // open + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + expect(sidebar.classList.contains('-translate-x-full')).toBe(true) + }) +}) 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..5f20015a87 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -19,20 +19,15 @@ require('./src/case_contact') require('./src/case_emancipation') require('./src/casa_case') require('./src/new_casa_case') -require('./src/dashboard') require('./src/emancipations') require('./src/import') require('./src/password_confirmation') require('./src/read_more') -require('./src/reimbursements') require('./src/reports') require('./src/require_communication_preference') 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') -require('./src/learning_hours') 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/case_contact_form_controller.js b/app/javascript/controllers/case_contact_form_controller.js index c712117db8..d3b0915fc1 100644 --- a/app/javascript/controllers/case_contact_form_controller.js +++ b/app/javascript/controllers/case_contact_form_controller.js @@ -72,13 +72,16 @@ export default class extends Controller { } setReimbursementFormVisibility = () => { + // Toggles Tailwind's `hidden` (display:none). The case-contact form is casadesign + // (Tailwind) now; Bootstrap's `d-none` is not defined there. This controller is used + // only by that form, so switching the class here is safe. if (this.wantDrivingReimbursementTarget.checked) { - this.reimbursementFormTarget.classList.remove('d-none') + this.reimbursementFormTarget.classList.remove('hidden') this.expenseDestroyTargets.forEach(el => (el.value = '0')) } else { this.clearExpenses() this.clearMileage() - this.reimbursementFormTarget.classList.add('d-none') + this.reimbursementFormTarget.classList.add('hidden') } } } 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..107ff3bd6b 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -5,28 +5,49 @@ 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 NavDrawerController from './nav_drawer_controller' + import NavbarController from './navbar_controller' +import RevealController from './reveal_controller' + +import SearchableSelectController from './searchable_select_controller' + import SelectAllController from './select_all_controller' import SidebarController from './sidebar_controller' @@ -35,19 +56,33 @@ import SidebarGroupController from './sidebar_group_controller' import TruncatedTextController from './truncated_text_controller' +import TwilioController from './twilio_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('nav-drawer', NavDrawerController) application.register('navbar', NavbarController) +application.register('reveal', RevealController) +application.register('searchable-select', SearchableSelectController) application.register('select-all', SelectAllController) application.register('sidebar', SidebarController) application.register('sidebar-group', SidebarGroupController) application.register('truncated-text', TruncatedTextController) +application.register('twilio', TwilioController) 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/controllers/nav_drawer_controller.js b/app/javascript/controllers/nav_drawer_controller.js new file mode 100644 index 0000000000..5867c62b56 --- /dev/null +++ b/app/javascript/controllers/nav_drawer_controller.js @@ -0,0 +1,27 @@ +import { Controller } from '@hotwired/stimulus' + +// Mobile navigation drawer for the casa_app + all_casa_admin shells. Below the +// `lg` breakpoint the sidebar is off-canvas (translated -100%); this toggles it +// in/out with a dimmed backdrop and locks body scroll while open. On `lg+` the +// sidebar is static and the toggle button is hidden, so this is inert there. +// Replaces the inline +<%# 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/casa_org/_contact_topics.html.erb b/app/views/casa_org/_contact_topics.html.erb index 747435379f..f4970c427b 100644 --- a/app/views/casa_org/_contact_topics.html.erb +++ b/app/views/casa_org/_contact_topics.html.erb @@ -1,65 +1,59 @@ -
    -
    -
    -
    -
    -

    Contact Topics

    -
    -
    - diff --git a/app/views/casa_org/_contact_type_groups.html.erb b/app/views/casa_org/_contact_type_groups.html.erb index da99dab0f0..e9a927a433 100644 --- a/app/views/casa_org/_contact_type_groups.html.erb +++ b/app/views/casa_org/_contact_type_groups.html.erb @@ -1,57 +1,46 @@ -
    -
    -
    -
    -
    -

    Contact Type Groups

    -
    -
    - diff --git a/app/views/casa_org/_contact_types.html.erb b/app/views/casa_org/_contact_types.html.erb index 1dd6b6e0e4..9b78bc8cda 100644 --- a/app/views/casa_org/_contact_types.html.erb +++ b/app/views/casa_org/_contact_types.html.erb @@ -1,57 +1,52 @@ -
    -
    -
    -
    -
    -

    Contact Types

    -
    -
    - diff --git a/app/views/casa_org/_custom_org_links.html.erb b/app/views/casa_org/_custom_org_links.html.erb index 3d73f663e6..5e6c23c416 100644 --- a/app/views/casa_org/_custom_org_links.html.erb +++ b/app/views/casa_org/_custom_org_links.html.erb @@ -1,65 +1,68 @@ -
    -
    -
    -
    -
    -

    Custom Links

    -
    -
    - -
    -
    -
    - - +<%# Org-settings Custom Links list (casa_app; rendered only by casa_org/edit). %> +
    +
    +

    Custom links

    + <%= link_to new_custom_org_link_path, class: button_classes(:secondary) do %> + New custom link + <% end %> +
    +
    + + + + + + + + + + <% if @custom_org_links.blank? %> + + + + <% else %> + <% @custom_org_links.each do |custom_link| %> - - - - + + + + - - - <% if @custom_org_links.blank? %> - - - - <% else %> - <% @custom_org_links.each do |custom_link| %> - - - - - - - <% end %> - <% end %> - -
    Display textURLActive?Actions
    No custom links have been added for this organization.
    Display TextURLActive?Actions<%= custom_link.text %><%= truncate(custom_link.url, length: 90) %><%= custom_link.active ? "Yes" : "No" %> +
    + <%= link_to "Edit", edit_custom_org_link_path(custom_link), class: "font-medium text-brand-600 hover:text-brand-700" %> + <%= render "shared/confirm_button", url: custom_org_link_path(custom_link), title: "Delete custom link?", message: "This custom link will be removed from your organization.", confirm_label: "Delete", trigger_label: "Delete", trigger_class: "font-medium text-rose-600 hover:text-rose-700" %> +
    +
    - No custom links have been added for this organization. -
    <%= custom_link.text %><%= truncate(custom_link.url, length: 90) %><%= custom_link.active ? "Yes" : "No" %> - <%= link_to edit_custom_org_link_path(custom_link) do %> -
    - -
    - <% end %> - <%= link_to custom_org_link_path(custom_link), method: :delete do %> -
    - -
    - <% end %> -
    -
    -
    + <% end %> + <% end %> + + +
    + +
    + <% if @custom_org_links.blank? %> +

    No custom links have been added for this organization.

    + <% else %> + <% @custom_org_links.each do |custom_link| %> +
    +

    <%= custom_link.text %>

    +
    +
    +
    URL
    +
    <%= custom_link.url %>
    +
    +
    +
    Active?
    +
    <%= custom_link.active ? "Yes" : "No" %>
    +
    +
    +
    + <%= link_to "Edit", edit_custom_org_link_path(custom_link), class: "font-medium text-brand-600 hover:text-brand-700" %> + <%= render "shared/confirm_button", url: custom_org_link_path(custom_link), title: "Delete custom link?", message: "This custom link will be removed from your organization.", confirm_label: "Delete", trigger_label: "Delete", trigger_class: "font-medium text-rose-600 hover:text-rose-700" %> +
    +
    + <% end %> + <% end %>
    diff --git a/app/views/casa_org/_hearing_types.html.erb b/app/views/casa_org/_hearing_types.html.erb index a7a66c6174..39bb3a9fe8 100644 --- a/app/views/casa_org/_hearing_types.html.erb +++ b/app/views/casa_org/_hearing_types.html.erb @@ -1,57 +1,52 @@ -
    -
    -
    -
    -
    -

    Hearing Types

    -
    -
    - diff --git a/app/views/casa_org/_judges.html.erb b/app/views/casa_org/_judges.html.erb index c476e27639..557228733b 100644 --- a/app/views/casa_org/_judges.html.erb +++ b/app/views/casa_org/_judges.html.erb @@ -1,53 +1,46 @@ -
    -
    -
    -
    -
    -

    Judge

    -
    -
    - diff --git a/app/views/casa_org/_languages.html.erb b/app/views/casa_org/_languages.html.erb index 705de0b204..dd80a746a9 100644 --- a/app/views/casa_org/_languages.html.erb +++ b/app/views/casa_org/_languages.html.erb @@ -1,48 +1,48 @@ -
    -
    -
    -

    Languages

    -

    - A list of languages volunteers can choose from to add to their profile to let supervisors and admins know they can speak the language. -

    - <%= link_to new_language_path, class: "btn-sm main-btn primary-btn btn-hover" do %> - - New Language - <% end %> - -
    - - - - - - - - - - - - - - <% languages.each do |lang| %> - - - - - <% end %> - -
    NameActions
    English (default language)
    - <%= lang.name %> - - <%= link_to edit_language_path(lang) do %> -
    - -
    - <% end %> -
    -
    +<%# Org-settings Languages list (casa_app; rendered only by casa_org/edit). %> +
    +
    +
    +

    Languages

    +

    Languages volunteers can add to their profile so supervisors and admins know they can speak them.

    + <%= link_to new_language_path, class: button_classes(:secondary) do %> + New language + <% end %> +
    + + +
    +
    +

    English (default language)

    +
    + <% languages.each do |lang| %> +
    +

    <%= lang.name %>

    +
    + <%= link_to "Edit", edit_language_path(lang), class: "font-medium text-brand-600 hover:text-brand-700" %> +
    +
    + <% end %>
    diff --git a/app/views/casa_org/_learning_hour_topics.html.erb b/app/views/casa_org/_learning_hour_topics.html.erb index c53925f5bc..366557e5ee 100644 --- a/app/views/casa_org/_learning_hour_topics.html.erb +++ b/app/views/casa_org/_learning_hour_topics.html.erb @@ -1,50 +1,38 @@ -
    -
    -
    -
    -
    -

    Learning Topic

    -
    -
    - -
    -
    -
    - - - - - +<%# Org-settings Learning Topics list (casa_app; rendered only by casa_org/edit). %> +
    +
    +

    Learning topic

    + <%= link_to new_learning_hour_topic_path, class: button_classes(:secondary) do %> + New learning topic + <% end %> +
    +
    NameActions
    + + + + + + + + <% @learning_hour_topics.each do |learning_hour_topic| %> + + + - - - <% @learning_hour_topics.each do |learning_hour_topic| %> - - + <% end %> + +
    NameActions
    <%= learning_hour_topic.name %><%= link_to "Edit", edit_learning_hour_topic_path(learning_hour_topic), class: "font-medium text-brand-600 hover:text-brand-700" %>
    - <%= learning_hour_topic.name %> -
    +
    - - <%= link_to edit_learning_hour_topic_path(learning_hour_topic) do %> -
    - -
    - <% end %> - - - <% end %> - - +
    + <% @learning_hour_topics.each do |learning_hour_topic| %> +
    +

    <%= learning_hour_topic.name %>

    +
    + <%= link_to "Edit", edit_learning_hour_topic_path(learning_hour_topic), class: "font-medium text-brand-600 hover:text-brand-700" %> +
    -
    + <% end %>
    diff --git a/app/views/casa_org/_learning_hour_types.html.erb b/app/views/casa_org/_learning_hour_types.html.erb index 07bdfbc6eb..bfe2e381e4 100644 --- a/app/views/casa_org/_learning_hour_types.html.erb +++ b/app/views/casa_org/_learning_hour_types.html.erb @@ -1,53 +1,46 @@ -
    -
    -
    -
    -
    -

    Type of Learning

    -
    -
    - diff --git a/app/views/casa_org/_placement_types.html.erb b/app/views/casa_org/_placement_types.html.erb index fdf190f58b..41b80ec5ec 100644 --- a/app/views/casa_org/_placement_types.html.erb +++ b/app/views/casa_org/_placement_types.html.erb @@ -1,49 +1,38 @@ -
    -
    -
    -
    -
    -

    Placement Types

    -
    -
    - +<%# Org-settings Placement Types list (casa_app; rendered only by casa_org/edit). %> +
    +
    +

    Placement types

    + <%= link_to new_placement_type_path, class: button_classes(:secondary) do %> + New placement type + <% end %> +
    + + +
    + <% placement_types.each do |placement_type| %> +
    +

    <%= placement_type.name %>

    +
    + <%= link_to "Edit", edit_placement_type_path(placement_type), class: "font-medium text-brand-600 hover:text-brand-700" %>
    -
    - - - - - - - - - <% placement_types.each do |placement_type| %> - - - - - <% end %> - -
    NameActions
    - <%= placement_type.name %> - - <%= link_to edit_placement_type_path(placement_type) do %> -
    - -
    - <% end %> -
    -
    -
    + <% end %>
    diff --git a/app/views/casa_org/_sent_emails.html.erb b/app/views/casa_org/_sent_emails.html.erb index 23106c6b03..3b99c8d8ad 100644 --- a/app/views/casa_org/_sent_emails.html.erb +++ b/app/views/casa_org/_sent_emails.html.erb @@ -1,37 +1,49 @@ -
    -
    -
    -

    Sent Emails

    -
    - - - - - - - - - - - <% @sent_emails.each do |sent_email| %> - - - - - - - <% end %> - -
    Mailer TypeCategoryRecipientTime Sent
    - <%= sent_email.mailer_type %> - - <%= sent_email.category %> - - <%= sent_email.user.display_name %> <<%= sent_email.sent_address %>> - - <%= to_user_timezone(sent_email.created_at).strftime("%l:%M%P %d %b %Y") %> -
    +<%# Org-settings Sent Emails list (casa_app; rendered only by casa_org/edit). Cell content + + the id/time format are locked by the view spec; only the wrapper/table styling changed. %> +
    +

    Sent emails

    + + +
    + <% @sent_emails.each do |sent_email| %> +
    +

    <%= sent_email.mailer_type %>

    +
    +
    +
    Category
    +
    <%= sent_email.category %>
    +
    +
    +
    Recipient
    +
    <%= formatted_name(sent_email.user.display_name) %> <<%= sent_email.sent_address %>>
    +
    +
    +
    Time sent
    +
    <%= to_user_timezone(sent_email.created_at).strftime("%l:%M%P %d %b %Y") %>
    +
    +
    -
    + <% end %>
    diff --git a/app/views/casa_org/edit.html.erb b/app/views/casa_org/edit.html.erb index 427b7b5b38..68bd2deda6 100644 --- a/app/views/casa_org/edit.html.erb +++ b/app/views/casa_org/edit.html.erb @@ -1,183 +1,142 @@ -
    -
    -
    -
    -

    Editing CASA Organization

    -
    -
    -
    -
    -
    - <%= form_with(model: current_organization, local: true) do |form| %> - <%= render "/shared/error_messages", resource: current_organization %> -
    - <%= form.label :name, "Name" %> - <%= form.text_field :name, class: "form-control", required: true %> -
    -
    - <%= form.label :display_name, "Display name" %> - <%= form.text_field :display_name, class: "form-control" %> -
    -
    - <%= form.label :address, "Address" %> - <%= form.text_field :address, class: "form-control" %> -
    -
    - <%= form.label :logo, "Logo" %> - <%= form.file_field :logo, class: "form-control h-auto", accept: ".png,.gif,.jpg,.jpeg,.webp,.svg" %> +<% content_for :page_title, "Organization settings" %> +<%# casa_org#edit (organization settings) on the casa_app shell. The org profile form (with the + Twilio credentials revealed by the `twilio` Stimulus controller — replacing the jQuery + + Bootstrap-collapse toggle) followed by the admin entity lists. The anchor ids on the section + headings are deep-link targets (e.g. the case-contact form links to #case-contact-topics), so + keep them. %> +<% card = "rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6" %> +<% section_title = "text-xl font-bold tracking-tight text-slate-900" %> +<% label_class = "mb-1.5 block text-sm font-medium text-slate-700" %> +<% 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" %> +<% file_class = "block w-full rounded-lg border border-slate-300 text-sm text-slate-900 shadow-sm file:mr-3 file:border-0 file:bg-slate-100 file:px-3.5 file:py-2.5 file:text-sm file:font-medium file:text-slate-700 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30 focus:outline-none" %> +<% check_class = "mt-0.5 h-4 w-4 shrink-0 rounded border-slate-300 text-brand-600 focus:ring-brand-500" %> + +
    +
    +
    +

    Editing CASA Organization

    +

    Organization profile, features, and admin settings.

    -
    - <%= form.label :court_report_template, "Court report template" %> -
    -
    - <%= form.file_field :court_report_template, class: "form-control" %> + +
    + <%= form_with model: current_organization, local: true, class: "space-y-5" do |form| %> + <%= render "shared/form_errors", resource: current_organization %> + +
    + <%= form.label :name, "Name", class: label_class %> + <%= form.text_field :name, required: true, class: input_class %>
    -
    - <% if current_organization.court_report_template.attached? %> - <% ActiveStorage::Current.url_options = { host: request.base_url } %> - <%= link_to 'Download Current Template', current_organization.court_report_template.url(only_path: true), class: "btn btn-info" %> - <% end %> +
    + <%= form.label :display_name, "Display name", class: label_class %> + <%= form.text_field :display_name, class: input_class %>
    -
    -
    -
    -

    Organization Features

    -
    - <%= form.check_box :show_driving_reimbursement, class: 'form-check-input' %> - <%= form.label :show_driving_reimbursement, "Show driving reimbursement", class: 'form-check-label mb-2' %> -
    -
    - <%= form.check_box :learning_topic_active, class: 'form-check-input' %> - <%= form.label :learning_topic_active, "Enable Learning Topic", class: 'form-check-label mb-2' %> -
    -
    - <%= form.check_box :other_duties_enabled, class: 'form-check-input' %> - <%= form.label :other_duties_enabled, "Enable Other Duties", class: 'form-check-label mb-2' %> -
    -
    - <%= form.check_box :twilio_enabled, class: 'form-check-input accordionTwilio' %> - <%= form.label :twilio_enabled, "Enable Twilio", class: 'form-check-label mb-2' %> -
    - <%# Twilio Form Begin %> -
    -
    -
    - <%= form.label :twilio_phone_number, "Twilio Phone Number" %> - <%= form.text_field :twilio_phone_number, class: "form-control", required: true %> -
    -
    - <%= form.label :twilio_account_sid, "Twilio Account SID" %> - <%= form.text_field :twilio_account_sid, class: "form-control", required: true %> +
    + <%= form.label :address, "Address", class: label_class %> + <%= form.text_field :address, class: input_class %> +
    +
    + <%= form.label :logo, "Logo", class: label_class %> + <%= form.file_field :logo, accept: ".png,.gif,.jpg,.jpeg,.webp,.svg", class: file_class %> +
    +
    + <%= form.label :court_report_template, "Court report template", class: label_class %> +
    + <%= form.file_field :court_report_template, class: "#{file_class} flex-1" %> + <% if current_organization.court_report_template.attached? %> + <% ActiveStorage::Current.url_options = {host: request.base_url} %> + <%= link_to "Download current template", current_organization.court_report_template.url(only_path: true), class: button_classes(:secondary) %> + <% end %>
    -
    - <%= form.label :twilio_api_key_sid, "Twilio API Key SID" %> - <%= form.text_field :twilio_api_key_sid, class: "form-control", required: true %> +
    + +
    +

    Organization features

    +
    + + + + <% if Flipper.enabled?(:show_additional_expenses) %> + + <% end %>
    -
    - <%= form.label :twilio_api_key_secret, "Twilio API Key Secret" %> - <%= form.text_field :twilio_api_key_secret, class: "form-control", required: true %> +
    + +
    + <% twilio_on = current_organization.twilio_enabled %> + +
    "> +
    + <%= form.label :twilio_phone_number, "Twilio phone number", class: label_class %> + <%= form.text_field :twilio_phone_number, required: twilio_on, disabled: !twilio_on, data: {twilio_target: "field"}, class: input_class %> +
    +
    + <%= form.label :twilio_account_sid, "Twilio account SID", class: label_class %> + <%= form.text_field :twilio_account_sid, required: twilio_on, disabled: !twilio_on, data: {twilio_target: "field"}, class: input_class %> +
    +
    + <%= form.label :twilio_api_key_sid, "Twilio API key SID", class: label_class %> + <%= form.text_field :twilio_api_key_sid, required: twilio_on, disabled: !twilio_on, data: {twilio_target: "field"}, class: input_class %> +
    +
    + <%= form.label :twilio_api_key_secret, "Twilio API key secret", class: label_class %> + <%= form.text_field :twilio_api_key_secret, required: twilio_on, disabled: !twilio_on, data: {twilio_target: "field"}, class: input_class %> +
    -
    - <%# Twilio Form End %> - <% if Flipper.enabled?(:show_additional_expenses) %> -
    - <%= form.check_box :additional_expenses_enabled, class: 'form-check-input' %> - <%= form.label :additional_expenses_enabled, "Volunteers can add Other Expenses", class: 'form-check-label mb-2' %> + +
    + <%= button_tag type: "submit", class: button_classes(:primary) do %> + Submit + <% end %>
    - <% end %> -
    - <%= button_tag( - type: "submit", - class: "btn-sm main-btn primary-btn btn-hover" - ) do %> - Submit <% end %> -
    - <% end %> -
    -
    - <%= render "languages", languages: current_organization.languages %> -
    -
    - <%= render "custom_org_links" %> -
    - -
    -
    -
    -
    -

    - Manage Contact Types -

    -
    -
    -
    -
    -
    - <%= render "contact_type_groups", contact_type_groups: @contact_type_groups %> - <%= render "contact_types", contact_types: @contact_types %> -
    - -
    -
    -
    -
    -

    - Manage Court Details -

    -
    -
    -
    -
    -
    - <%= render "hearing_types" %> - <%= render "judges" %> - <%= render "sent_emails" %> -
    -
    -
    -
    -
    -

    - Manage Learning Hours -

    -
    -
    -
    -
    -
    - <%= render "learning_hour_types" %> -
    -
    - <%= render "learning_hour_topics" %> -
    -
    -
    -
    -
    -

    - Manage Case Contact Topics -

    -
    -
    -
    -
    -
    - <%= render "contact_topics" %> -
    + -
    -
    -
    -
    -

    - Manage Case Placement Types -

    -
    -
    + <%= render "languages", languages: current_organization.languages %> + <%= render "custom_org_links" %> + +
    +

    Manage contact types

    + <%= render "contact_type_groups", contact_type_groups: @contact_type_groups %> + <%= render "contact_types", contact_types: @contact_types %> +
    + +
    +

    Manage court details

    + <%= render "hearing_types" %> + <%= render "judges" %> + <%= render "sent_emails" %> +
    + +
    +

    Manage learning hours

    + <%= render "learning_hour_types" %> + <%= render "learning_hour_topics" %> +
    + +
    +

    Manage case contact topics

    + <%= render "contact_topics" %> +
    + +
    +

    Manage case placement types

    + <%= render "placement_types", placement_types: @placement_types %> +
    -
    - <%= render "placement_types", placement_types: @placement_types %> -
    diff --git a/app/views/case_assignments/index.html.erb b/app/views/case_assignments/index.html.erb deleted file mode 100644 index ddcef964a5..0000000000 --- a/app/views/case_assignments/index.html.erb +++ /dev/null @@ -1,55 +0,0 @@ -

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

    - -
    - -<% if @volunteer.casa_cases %> - - - - - - - - - - <% @volunteer.case_assignments.each do |assignment| %> - - - - - - <% end %> - -
    Case NumberTransition Aged YouthActions
    - Case Number - <%= assignment.casa_case.case_number %> - - Transition Aged Youth - <%= assignment.casa_case.decorate.transition_aged_youth %> - - <%= button_to "Unassign Volunteer", - volunteer_case_assignment_path(@volunteer, assignment), - method: :delete, - class: "btn btn-danger" %> -
    -<% end %> - -
    - -
    -
    -

    Assign a New Volunteer

    - - <%= form_with(model: CaseAssignment.new, url: volunteer_case_assignments_path) do |form| %> -
    - - -
    - <%= form.submit "Assign Case", class: 'btn btn-primary' %> - <% end %> -
    -
    diff --git a/app/views/case_contacts/_case_contact.html.erb b/app/views/case_contacts/_case_contact.html.erb index fbabde64af..a4c502c581 100644 --- a/app/views/case_contacts/_case_contact.html.erb +++ b/app/views/case_contacts/_case_contact.html.erb @@ -1,74 +1,71 @@ +<%# Case-contact card (casa_app). Rendered by case_contacts#index and #drafts (both casa_app), + so it is rewritten in place rather than twinned. Keeps inert class hooks the specs use + (.container-fluid.mb-1 wrapper, .full-card, .card-title) and the reminder JS hooks in + _followup. TruncatedTextComponent renders each topic answer + the notes with a "label:" + prefix (the view spec asserts "Some question:" / "Additional notes:"). Edit/Delete/undelete + keep data-turbo:false. %> +<% pill = "inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium" %>
    -
    -
    -
    -
    - <%= contact.decorate.medium_icon_classes %>"> -
    -
    - "> - <%= "[DELETE] " if policy(contact).restore? && contact.deleted? %> - <%= contact.decorate.contact_groups %> - - <% if !contact.active? %> - Draft - <% end %> - <%= link_to("undelete", restore_case_contact_path(contact.id), method: :post, data: { turbo: false }, - class: "btn btn-info") if policy(contact).restore? && contact.deleted? %> -
    -
    - <%= contact.decorate.contact_types %> -
    - <%= contact.decorate.subheading %> -
    -
    -
      - <% contact.contact_topic_answers.reject { _1.value.blank? }.each do |answer| %> -
    • <%= render TruncatedTextComponent.new(answer.value, label: answer.contact_topic.question) %>
    • - <% end %> - <% if contact.notes %> -
    • <%= render TruncatedTextComponent.new(contact.notes, label: "Additional Notes") %>
    • - <% end %> -
    -
    +
    +
    +
    + +
    +
    +

    <%= "[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: "#{pill} bg-sky-50 text-sky-700 hover:bg-sky-100" %> + <% end %>
    +

    <%= contact.decorate.contact_types %>

    +

    <%= contact.decorate.subheading %>

    + + <% answers = contact.contact_topic_answers.reject { _1.value.blank? } %> + <% if answers.any? || contact.notes.present? %> +
      + <% answers.each do |answer| %> +
    • <%= render TruncatedTextComponent.new(answer.value, label: answer.contact_topic.question) %>
    • + <% end %> + <% if contact.notes %> +
    • <%= render TruncatedTextComponent.new(contact.notes, label: "Additional notes") %>
    • + <% end %> +
    + <% end %>
    -
    + +
    <% if Pundit.policy(current_user, contact).update? %> <%= render "case_contacts/followup", contact: contact, followup: contact.requested_followup %> -
    - <%= link_to edit_case_contact_path(contact), class: "text-danger", data: { turbo: false } do %> - Edit - <% end %> -
    + <%= 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), class: "main-btn btn-sm danger-btn btn-hover", method: :delete, data: { turbo: false } do %> - Delete - <% end %> + <%= link_to case_contact_path(contact.id), method: :delete, data: {turbo: false}, class: "#{button_classes(:danger_outline)} ml-auto" do %> + Delete + <% end %> <% end %> -
    -
    + +
    Created by: - <% if policy(contact).edit? %> - <% if current_user.volunteer? %> - <%= contact.creator&.display_name %> + <% creator_name = formatted_name(contact.creator&.display_name) %> + <% if policy(contact).edit? && !current_user.volunteer? %> + <% if contact.creator&.supervisor? %> + <%= link_to creator_name, edit_supervisor_path(contact.creator), data: {turbo: false}, class: "font-medium text-slate-700 hover:text-brand-700 hover:underline" %> + <% elsif contact.creator&.casa_admin? %> + <%= link_to creator_name, edit_users_path, data: {turbo: false}, class: "font-medium text-slate-700 hover:text-brand-700 hover:underline" %> <% else %> - <% if contact.creator&.supervisor? %> - <%= link_to 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 } %> - <% else %> - <%= link_to contact.creator&.display_name, edit_volunteer_path(contact.creator), data: { turbo: false } %> - <% end %> + <%= link_to creator_name, edit_volunteer_path(contact.creator), data: {turbo: false}, class: "font-medium text-slate-700 hover:text-brand-700 hover:underline" %> <% end %> <% else %> - <%= contact.creator&.display_name %> + <%= creator_name %> <% end %> -
    +
    diff --git a/app/views/case_contacts/_confirm_note_content_dialog.html.erb b/app/views/case_contacts/_confirm_note_content_dialog.html.erb index 894e9ab0ef..9aa5c257b7 100644 --- a/app/views/case_contacts/_confirm_note_content_dialog.html.erb +++ b/app/views/case_contacts/_confirm_note_content_dialog.html.erb @@ -2,7 +2,7 @@