diff --git a/.codeclimate.yml b/.codeclimate.yml index 998bef4649b..c829e16a4da 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -84,7 +84,7 @@ plugins: rubocop: enabled: true channel: rubocop-0-79 - scss-lint: + stylelint: enabled: true grep: enabled: true diff --git a/Gemfile.lock b/Gemfile.lock index aa035d911aa..66ecd4038cf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -297,7 +297,7 @@ GEM fasterer (0.6.0) colorize (~> 0.7) ruby_parser (>= 3.13.0) - ffi (1.11.1) + ffi (1.16.3) foreman (0.85.0) thor (~> 0.19.1) formatador (0.2.5) @@ -513,8 +513,8 @@ GEM thor (>= 0.19.0, < 2.0) rainbow (3.0.0) rake (12.3.3) - rb-fsevent (0.10.3) - rb-inotify (0.10.0) + rb-fsevent (0.11.2) + rb-inotify (0.10.1) ffi (~> 1.0) rb-readline (0.5.5) rchardet (1.8.0) @@ -632,8 +632,7 @@ GEM sawyer (0.8.2) addressable (>= 2.3.5) faraday (> 0.8, < 2.0) - scss_lint (0.58.0) - rake (>= 0.9, < 13) + scss_lint (0.60.0) sass (~> 3.5, >= 3.5.5) selenium-webdriver (4.9.0) rexml (~> 3.2, >= 3.2.5) diff --git a/app/controllers/decision_reviews_controller.rb b/app/controllers/decision_reviews_controller.rb index 687c6066c52..0d46cfaa441 100644 --- a/app/controllers/decision_reviews_controller.rb +++ b/app/controllers/decision_reviews_controller.rb @@ -6,6 +6,7 @@ class DecisionReviewsController < ApplicationController before_action :verify_access, :react_routed, :set_application before_action :verify_veteran_record_access, only: [:show] + before_action :verify_business_line, only: [:index, :generate_report] delegate :incomplete_tasks, :incomplete_tasks_type_counts, @@ -17,6 +18,7 @@ class DecisionReviewsController < ApplicationController :completed_tasks_type_counts, :completed_tasks_issue_type_counts, :included_tabs, + :can_generate_claim_history?, to: :business_line SORT_COLUMN_MAPPINGS = { @@ -30,19 +32,13 @@ class DecisionReviewsController < ApplicationController }.freeze def index - if business_line - respond_to do |format| - format.html { render "index" } - format.csv do - jobs_as_csv = BusinessLineReporter.new(business_line).as_csv - filename = Time.zone.now.strftime("#{business_line.url}-%Y%m%d.csv") - send_data jobs_as_csv, filename: filename - end - format.json { queue_tasks } + respond_to do |format| + format.html { render "index" } + format.csv do + jobs_as_csv = BusinessLineReporter.new(business_line).as_csv + send_data jobs_as_csv, filename: csv_filename end - else - # TODO: make index show error message - render json: { error: "#{business_line_slug} not found" }, status: :not_found + format.json { queue_tasks } end end @@ -56,7 +52,7 @@ def show def update if task - if task.complete_with_payload!(decision_issue_params, decision_date) + if task.complete_with_payload!(decision_issue_params, decision_date, current_user) business_line.tasks.reload render json: { task_filter_details: task_filter_details }, status: :ok else @@ -69,6 +65,29 @@ def update end end + def generate_report + return render "errors/404" unless can_generate_claim_history? + return requires_admin_access_redirect unless business_line.user_is_admin?(current_user) + + respond_to do |format| + format.html { render "index" } + format.csv do + filter_params = change_history_params + + unless filter_params[:report_type] + fail ActionController::ParameterMissing.new(:report_type), report_type_missing_message + end + + parsed_filters = ChangeHistoryFilterParser.new(filter_params).parse_filters + events_as_csv = create_change_history_csv(parsed_filters) + filename = Time.zone.now.strftime("taskreport-%Y%m%d_%H%M.csv") + send_data events_as_csv, filename: filename, type: "text/csv", disposition: "attachment" + end + end + rescue ActionController::ParameterMissing => error + render json: { error: error.message }, status: :bad_request, content_type: "application/json" + end + def business_line_slug allowed_params[:business_line_slug] || allowed_params[:decision_review_business_line_slug] end @@ -107,7 +126,8 @@ def task_filter_details def business_line_config_options { - tabs: included_tabs + tabs: included_tabs, + canGenerateClaimHistory: can_generate_claim_history? } end @@ -184,6 +204,12 @@ def verify_access redirect_to "/unauthorized" end + def verify_business_line + unless business_line + render json: { error: "#{business_line_slug} not found" }, status: :not_found + end + end + def verify_veteran_record_access if task.type == VeteranRecordRequest.name && !task.appeal.veteran&.accessible? render(Caseflow::Error::ActionForbiddenError.new( @@ -192,6 +218,21 @@ def verify_veteran_record_access end end + def csv_filename + Time.zone.now.strftime("#{business_line.url}-%Y%m%d.csv") + end + + def report_type_missing_message + "param is missing or the value is empty: reportType" + end + + def requires_admin_access_redirect + Rails.logger.info("User without admin access to the business line #{business_line} "\ + "couldn't access #{request.original_url}") + session["return_to"] = request.original_url + redirect_to "/unauthorized" + end + def allowed_params params.permit( :decision_review_business_line_slug, @@ -209,6 +250,22 @@ def allowed_params ) end + def change_history_params + params.permit( + :report_type, + :status_report_type, + events: {}, + timing: {}, + statuses: {}, + days_waiting: {}, + decision_review_type: {}, + issue_type: {}, + issue_disposition: {}, + personnel: {}, + facility: {} + ) + end + def power_of_attorney_data { representative_type: task.appeal&.representative_type, @@ -219,4 +276,37 @@ def power_of_attorney_data poa_last_synced_at: task.appeal&.poa_last_synced_at } end + + def create_change_history_csv(filter_params) + create_metric_log do + base_url = "#{request.base_url}/decision_reviews/#{business_line.url}/tasks/" + events = ClaimHistoryService.new(business_line, filter_params).build_events + ChangeHistoryReporter.new(events, base_url, filter_params.to_h).as_csv + end + end + + def create_metric_log + start_time = Time.zone.now + return_data = yield + end_time = Time.zone.now + + MetricsService.store_record_metric( + SecureRandom.uuid, + { + name: "generate_report", + message: "Generate Change History Report #{Time.zone.today} #{RequestStore[:current_user].css_id}", + type: "info", + product: "vha", + attrs: { service: "decision_reviews", endpoint: "generate_report" }, + sent_to: "rails_console", + start: start_time, + end: end_time, + duration: (end_time - start_time) * 1000, + additional_info: params[:filters].as_json + }, + nil + ) + + return_data + end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 28186438141..451e722da24 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -8,6 +8,7 @@ class UsersController < ApplicationController def index return filter_by_role if params[:role] return filter_by_css_id_or_name if css_id + return filter_by_organization if params[:organization] render json: {}, status: :ok end @@ -79,6 +80,13 @@ def filter_by_css_id_or_name render json: { users: json_users(users) } end + def filter_by_organization + finder = UserFinder.new(organization: params[:organization]) + users = finder.users || [] + + render json: { users: json_users(users) } + end + # Depending on the route and the requested resource, the requested user's id could be sent as :id or :user_id # ex from rake routes: user GET /users/:id or user_task_pages GET /users/:user_id/task_pages def id diff --git a/app/models/claim_review.rb b/app/models/claim_review.rb index b56e7cd4a56..952236380c3 100644 --- a/app/models/claim_review.rb +++ b/app/models/claim_review.rb @@ -102,11 +102,13 @@ def handle_issues_with_no_decision_date! # Guard clause to only perform this update for VHA claim reviews for now return nil if benefit_type != "vha" + review_task = tasks.find { |task| task.is_a?(DecisionReviewTask) } + + return nil unless review_task&.open? + if request_issues_without_decision_dates? - review_task = tasks.find { |task| task.is_a?(DecisionReviewTask) } review_task&.on_hold! elsif !request_issues_without_decision_dates? - review_task = tasks.find { |task| task.is_a?(DecisionReviewTask) } review_task&.assigned! end end diff --git a/app/models/concerns/business_line_task.rb b/app/models/concerns/business_line_task.rb index 3faf4a5ee35..978e6692316 100644 --- a/app/models/concerns/business_line_task.rb +++ b/app/models/concerns/business_line_task.rb @@ -3,10 +3,10 @@ module BusinessLineTask extend ActiveSupport::Concern - def complete_with_payload!(_decision_issue_params, _decision_date) + def complete_with_payload!(_decision_issue_params, _decision_date, user) return false unless validate_task - update!(status: Constants.TASK_STATUSES.completed, closed_at: Time.zone.now) + update!(status: Constants.TASK_STATUSES.completed, closed_at: Time.zone.now, completed_by: user) end private diff --git a/app/models/organizations/business_line.rb b/app/models/organizations/business_line.rb index cc3a4f66ff0..c829e6323ef 100644 --- a/app/models/organizations/business_line.rb +++ b/app/models/organizations/business_line.rb @@ -16,6 +16,10 @@ def tasks_query_type } end + def can_generate_claim_history? + false + end + # Example Params: # sort_order: 'desc', # sort_by: 'assigned_at', @@ -69,6 +73,11 @@ def completed_tasks_issue_type_counts QueryBuilder.new(query_type: :completed, parent: self).issue_type_count end + def change_history_rows(filters = {}) + QueryBuilder.new(query_params: filters, parent: self).change_history_rows + end + + # rubocop:disable Metrics/ClassLength class QueryBuilder attr_accessor :query_type, :parent, :query_params @@ -98,11 +107,11 @@ class QueryBuilder def initialize(query_type: :in_progress, parent: business_line, query_params: {}) @query_type = query_type @parent = parent - @query_params = query_params + @query_params = query_params.dup # Initialize default sorting - query_params[:sort_by] ||= DEFAULT_ORDERING_HASH[query_type][:sort_by] - query_params[:sort_order] ||= "desc" + @query_params[:sort_by] ||= DEFAULT_ORDERING_HASH[query_type][:sort_by] + @query_params[:sort_order] ||= "desc" end def build_query @@ -164,11 +173,211 @@ def issue_type_count issue_count_options end - # rubocop:enable Metrics/MethodLength # rubocop:enable Metrics/AbcSize + def change_history_rows + change_history_sql_block = <<-SQL + WITH versions_agg AS ( + SELECT + versions.item_id, + versions.item_type, + ARRAY_AGG(versions.object_changes ORDER BY versions.id) AS object_changes_array, + MAX(CASE + WHEN versions.object_changes LIKE '%closed_at:%' THEN versions.whodunnit + ELSE NULL + END) AS version_closed_by_id + FROM + versions + GROUP BY + versions.item_id, versions.item_type + ) + SELECT tasks.id AS task_id, tasks.status AS task_status, request_issues.id AS request_issue_id, + request_issues_updates.created_at AS request_issue_update_time, decision_issues.description AS decision_description, + request_issues.benefit_type AS request_issue_benefit_type, request_issues_updates.id AS request_issue_update_id, + request_issues.created_at AS request_issue_created_at, + intakes.completed_at AS intake_completed_at, update_users.full_name AS update_user_name, tasks.created_at AS task_created_at, + intake_users.full_name AS intake_user_name, update_users.station_id AS update_user_station_id, tasks.closed_at AS task_closed_at, + intake_users.station_id AS intake_user_station_id, decision_issues.created_at AS decision_created_at, + COALESCE(decision_users.station_id, decision_users_completed_by.station_id) AS decision_user_station_id, + COALESCE(decision_users.full_name, decision_users_completed_by.full_name) AS decision_user_name, + COALESCE(decision_users.css_id, decision_users_completed_by.css_id) AS decision_user_css_id, + intake_users.css_id AS intake_user_css_id, update_users.css_id AS update_user_css_id, + request_issues_updates.before_request_issue_ids, request_issues_updates.after_request_issue_ids, + request_issues_updates.withdrawn_request_issue_ids, request_issues_updates.edited_request_issue_ids, + decision_issues.caseflow_decision_date, request_issues.decision_date_added_at, + tasks.appeal_type, tasks.appeal_id, request_issues.nonrating_issue_category, request_issues.nonrating_issue_description, + request_issues.decision_date, decision_issues.disposition, tasks.assigned_at, request_issues.unidentified_issue_text, + request_decision_issues.decision_issue_id, request_issues.closed_at AS request_issue_closed_at, + tv.object_changes_array AS task_versions, (CURRENT_TIMESTAMP::date - tasks.assigned_at::date) AS days_waiting, + COALESCE(intakes.veteran_file_number, higher_level_reviews.veteran_file_number, supplemental_claims.veteran_file_number) AS veteran_file_number, + COALESCE( + NULLIF(CONCAT(unrecognized_party_details.name, ' ', unrecognized_party_details.last_name), ' '), + NULLIF(CONCAT(people.first_name, ' ', people.last_name), ' '), + bgs_attorneys.name + ) AS claimant_name + FROM tasks + INNER JOIN request_issues ON request_issues.decision_review_type = tasks.appeal_type + AND request_issues.decision_review_id = tasks.appeal_id + LEFT JOIN higher_level_reviews ON tasks.appeal_type = 'HigherLevelReview' + AND tasks.appeal_id = higher_level_reviews.id + LEFT JOIN supplemental_claims ON tasks.appeal_type = 'SupplementalClaim' + AND tasks.appeal_id = supplemental_claims.id + LEFT JOIN intakes ON tasks.appeal_type = intakes.detail_type + AND intakes.detail_id = tasks.appeal_id + LEFT JOIN request_issues_updates ON request_issues_updates.review_type = tasks.appeal_type + AND request_issues_updates.review_id = tasks.appeal_id + LEFT JOIN request_decision_issues ON request_decision_issues.request_issue_id = request_issues.id + LEFT JOIN decision_issues ON decision_issues.decision_review_id = tasks.appeal_id + AND decision_issues.decision_review_type = tasks.appeal_type AND decision_issues.id = request_decision_issues.decision_issue_id + LEFT JOIN claimants ON claimants.decision_review_id = tasks.appeal_id + AND claimants.decision_review_type = tasks.appeal_type + LEFT join versions_agg tv ON tv.item_type = 'Task' AND tv.item_id = tasks.id + LEFT JOIN people ON claimants.participant_id = people.participant_id + LEFT JOIN bgs_attorneys ON claimants.participant_id = bgs_attorneys.participant_id + LEFT JOIN unrecognized_appellants ON claimants.id = unrecognized_appellants.claimant_id + LEFT JOIN unrecognized_party_details ON unrecognized_appellants.unrecognized_party_detail_id = unrecognized_party_details.id + LEFT JOIN users intake_users ON intakes.user_id = intake_users.id + LEFT JOIN users update_users ON request_issues_updates.user_id = update_users.id + LEFT JOIN users decision_users ON decision_users.id = tv.version_closed_by_id::int + LEFT JOIN users decision_users_completed_by ON decision_users_completed_by.id = tasks.completed_by_id + WHERE tasks.type = 'DecisionReviewTask' + AND tasks.assigned_to_type = 'Organization' + AND tasks.assigned_to_id = '#{parent.id.to_i}' + SQL + + # Append all of the filter queries to the end of the sql block + change_history_sql_block += change_history_sql_filter_array.join(" ") + + ActiveRecord::Base.transaction do + # increase the timeout for the transaction because the query more than the default 30 seconds + ActiveRecord::Base.connection.execute "SET LOCAL statement_timeout = 180000" + ActiveRecord::Base.connection.execute change_history_sql_block + end + end + # rubocop:enable Metrics/MethodLength + private + #################### Change history filter helpers ############################ + + def change_history_sql_filter_array + [ + # Task status and claim type filtering always happens regardless of params + task_status_filter, + claim_type_filter, + # All the other filters are optional + task_id_filter, + dispositions_filter, + issue_types_filter, + days_waiting_filter, + station_id_filter, + user_css_id_filter + ].compact + end + + def task_status_filter + if query_params[:task_status].present? + " AND #{where_clause_from_array(Task, :status, query_params[:task_status]).to_sql}" + else + " AND tasks.status IN ('assigned', 'in_progress', 'on_hold', 'completed', 'cancelled') " + end + end + + def claim_type_filter + if query_params[:claim_type].present? + " AND #{where_clause_from_array(Task, :appeal_type, query_params[:claim_type]).to_sql}" + else + " AND tasks.appeal_type IN ('HigherLevelReview', 'SupplementalClaim') " + end + end + + def task_id_filter + if query_params[:task_id].present? + " AND #{where_clause_from_array(Task, :id, query_params[:task_id]).to_sql} " + end + end + + def dispositions_filter + if query_params[:dispositions].present? + disposition_params = query_params[:dispositions] - ["Blank"] + sql = where_clause_from_array(DecisionIssue, :disposition, disposition_params).to_sql + + if query_params[:dispositions].include?("Blank") + if disposition_params.empty? + " AND decision_issues.disposition IS NULL " + else + " AND (#{sql} OR decision_issues.disposition IS NULL) " + end + else + " AND #{sql} " + end + end + end + + def issue_types_filter + if query_params[:issue_types].present? + sql = where_clause_from_array(RequestIssue, :nonrating_issue_category, query_params[:issue_types]).to_sql + " AND #{sql} " + end + end + + def days_waiting_filter + if query_params[:days_waiting].present? + number_of_days = query_params[:days_waiting][:number_of_days] + operator = query_params[:days_waiting][:operator] + case operator + when ">", "<", "=" + <<-SQL + AND (CURRENT_TIMESTAMP::date - tasks.assigned_at::date)::integer #{operator} '#{number_of_days.to_i}' + SQL + when "between" + end_days = query_params[:days_waiting][:end_days] + <<-SQL + AND (CURRENT_TIMESTAMP::date - tasks.assigned_at::date)::integer BETWEEN '#{number_of_days.to_i}' AND '#{end_days.to_i}' + AND (CURRENT_TIMESTAMP::date - tasks.assigned_at::date)::integer BETWEEN '#{number_of_days.to_i}' AND '#{end_days.to_i}' + SQL + end + end + end + + # rubocop:disable Metrics/AbcSize + def station_id_filter + if query_params[:facilities].present? + <<-SQL + AND + ( + #{User.arel_table.alias(:intake_users)[:station_id].in(query_params[:facilities]).to_sql} + OR + #{User.arel_table.alias(:update_users)[:station_id].in(query_params[:facilities]).to_sql} + OR + #{User.arel_table.alias(:decision_users)[:station_id].in(query_params[:facilities]).to_sql} + OR + #{User.arel_table.alias(:decision_users_completed_by)[:station_id].in(query_params[:facilities]).to_sql} + ) + SQL + end + end + + def user_css_id_filter + if query_params[:personnel].present? + <<-SQL + AND + ( + #{User.arel_table.alias(:intake_users)[:css_id].in(query_params[:personnel]).to_sql} + OR + #{User.arel_table.alias(:update_users)[:css_id].in(query_params[:personnel]).to_sql} + OR + #{User.arel_table.alias(:decision_users)[:css_id].in(query_params[:personnel]).to_sql} + OR + #{User.arel_table.alias(:decision_users_completed_by)[:css_id].in(query_params[:personnel]).to_sql} + ) + SQL + end + end + # rubocop:enable Metrics/AbcSize + + #################### End of Change history filter helpers ######################## + def business_line_id parent.id end @@ -508,7 +717,12 @@ def locate_issue_type_filter(filters) filter["col"].include?("issueTypesColumn") end end + + def where_clause_from_array(table_class, column, values_array) + table_class.arel_table[column].in(values_array) + end end + # rubocop:enable Metrics/ClassLength end require_dependency "vha_business_line" diff --git a/app/models/organizations/vha_business_line.rb b/app/models/organizations/vha_business_line.rb index af4b9a98a2f..a639ce7edeb 100644 --- a/app/models/organizations/vha_business_line.rb +++ b/app/models/organizations/vha_business_line.rb @@ -16,4 +16,8 @@ def tasks_query_type completed: "recently_completed" } end + + def can_generate_claim_history? + true + end end diff --git a/app/models/request_issue.rb b/app/models/request_issue.rb index e067788565f..99fdbc5ad4b 100644 --- a/app/models/request_issue.rb +++ b/app/models/request_issue.rb @@ -69,6 +69,7 @@ class RequestIssue < CaseflowRecord before_save :set_contested_rating_issue_profile_date before_save :close_if_ineligible! + after_create :set_decision_date_added_at, if: :decision_date_exists? # amoeba gem for splitting appeal request issues amoeba do enable @@ -159,6 +160,10 @@ def active_or_ineligible_or_withdrawn active_or_ineligible.or(withdrawn) end + def active_or_decided + active.or(decided).order(id: :asc) + end + def active_or_decided_or_withdrawn active.or(decided).or(withdrawn).order(id: :asc) end @@ -512,7 +517,7 @@ def save_edited_contention_text!(new_description) def save_decision_date!(new_decision_date) fail DecisionDateInFutureError, id if new_decision_date.to_date > Time.zone.today - update!(decision_date: new_decision_date) + update!(decision_date: new_decision_date, decision_date_added_at: Time.zone.now) # Special handling for claim reviews that contain issues without a decision date decision_review.try(:handle_issues_with_no_decision_date!) @@ -1018,5 +1023,14 @@ def check_for_untimely! def appeal_active? decision_review.tasks.open.any? end + + def decision_date_exists? + decision_date.present? + end + + def set_decision_date_added_at + self.decision_date_added_at = created_at + save! + end end # rubocop:enable Metrics/ClassLength diff --git a/app/models/serializers/work_queue/decision_review_task_serializer.rb b/app/models/serializers/work_queue/decision_review_task_serializer.rb index 7455091447b..3d7940f3ce7 100644 --- a/app/models/serializers/work_queue/decision_review_task_serializer.rb +++ b/app/models/serializers/work_queue/decision_review_task_serializer.rb @@ -72,7 +72,8 @@ def self.representative_tz(object) uuid: decision_review(object).uuid, isLegacyAppeal: false, issueCount: issue_count(object), - activeRequestIssues: skip_acquiring_request_issues || request_issues(object).active.map(&:serialize), + activeOrDecidedRequestIssues: + skip_acquiring_request_issues || request_issues(object).active_or_decided.map(&:serialize), appellant_type: decision_review(object).claimant&.type } end diff --git a/app/models/tasks/decision_review_task.rb b/app/models/tasks/decision_review_task.rb index 40c42443f5c..534e897e7cd 100644 --- a/app/models/tasks/decision_review_task.rb +++ b/app/models/tasks/decision_review_task.rb @@ -25,12 +25,15 @@ def ui_hash serialize_task[:attributes] end - def complete_with_payload!(decision_issue_params, decision_date) + def complete_with_payload!(decision_issue_params, decision_date, user) return false unless validate_task(decision_issue_params) transaction do appeal.create_decision_issues_for_tasks(decision_issue_params, decision_date) - update!(status: Constants.TASK_STATUSES.completed, closed_at: Time.zone.now) + update!(status: Constants.TASK_STATUSES.completed, closed_at: Time.zone.now, completed_by: user) + decision_issue_params.each do |param| + RequestIssue.find(param[:request_issue_id]).close_decided_issue! + end end appeal.on_decision_issues_sync_processed if appeal.is_a?(HigherLevelReview) diff --git a/app/services/claim_change_history/change_history_filter_parser.rb b/app/services/claim_change_history/change_history_filter_parser.rb new file mode 100644 index 00000000000..2e423e43e13 --- /dev/null +++ b/app/services/claim_change_history/change_history_filter_parser.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +class ChangeHistoryFilterParser + attr_reader :filter_params + + def initialize(filter_params) + @filter_params = filter_params + end + + def parse_filters + { + report_type: filter_params[:report_type], + events: events_filter_helper, + task_status: task_status_filter_helper, + status_report_type: filter_params[:status_report_type], + claim_type: filter_params[:decision_review_type]&.values, + personnel: filter_params[:personnel]&.values, + dispositions: disposition_filter_helper, + issue_types: filter_params[:issue_type]&.values, + facilities: filter_params[:facility]&.values, + timing: filter_params[:timing].to_h, + days_waiting: days_waiting_filter_helper + }.deep_transform_keys(&:to_sym) + end + + private + + def events_filter_helper + event_mapping = { + "added_decision_date" => :added_decision_date, + "added_issue" => :added_issue, + "added_issue_no_decision_date" => :added_issue_without_decision_date, + "claim_created" => :claim_creation, + "claim_closed" => [:completed, :cancelled], + "claim_status_incomplete" => :incomplete, + "claim_status_inprogress" => :in_progress, + "completed_disposition" => :completed_disposition, + "removed_issue" => :removed_issue, + "withdrew_issue" => :withdrew_issue, + "claim_cancelled" => :cancelled + } + + filter_params[:events]&.values&.map { |event_type| event_mapping[event_type] }&.flatten + end + + def task_status_filter_helper + status_mapping = { + "incomplete" => "on_hold", + "in_progress" => %w[assigned in_progress], + "completed" => "completed", + "cancelled" => "cancelled" + } + + filter_params[:statuses]&.values&.map { |task_status| status_mapping[task_status] }&.flatten + end + + def disposition_filter_helper + disposition_mapping = { + "granted" => "Granted", + "Granted" => "Granted", + "denied" => "Denied", + "Denied" => "Denied", + "dta_error" => "DTA Error", + "DTA ERROR" => "DTA Error", + "dismissed" => "Dismissed", + "Dismissed" => "Dismissed", + "withdrawn" => "Withdrawn", + "Withdrawn" => "Withdrawn", + "blank" => "Blank", + "Blank" => "Blank" + } + + filter_params[:issue_disposition]&.values&.map { |disposition| disposition_mapping[disposition] } + end + + # :reek:FeatureEnvy + def days_waiting_filter_helper + operator_mapping = { + "lessThan" => "<", + "moreThan" => ">", + "equalTo" => "=", + "between" => "between" + } + + days_waiting_hash = filter_params[:days_waiting].to_h + + # Map the operator into something that SQL can understand + operator = days_waiting_hash["comparison_operator"] + if operator.present? + days_waiting_hash["comparison_operator"] = operator_mapping[operator] + end + + # Transform the keys to conform to what the service and query expects + key_changes = { "comparison_operator" => :operator, "value_one" => :number_of_days, "value_two" => :end_days } + + days_waiting_hash.transform_keys { |key| key_changes[key] || key } + end +end diff --git a/app/services/claim_change_history/change_history_reporter.rb b/app/services/claim_change_history/change_history_reporter.rb new file mode 100644 index 00000000000..68cf043c19c --- /dev/null +++ b/app/services/claim_change_history/change_history_reporter.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class ChangeHistoryReporter + attr_reader :events + attr_reader :event_filters + attr_reader :tasks_url + + CHANGE_HISTORY_CSV_COLUMNS = %w[ + Veteran\ File\ Number + Claimant + Task\ URL + Current\ Claim\ Status + Days\ Waiting + Claim\ Type + Facility + Edit\ User\ Name + Edit\ Date + Edit\ Action + Issue\ Type + Issue\ Description + Prior\ Decision\ Date + Disposition + Disposition\ Description + Disposition\ Date + ].freeze + + def initialize(events, tasks_url, event_filters = {}) + @events = events + @event_filters = event_filters + @tasks_url = tasks_url + end + + # :reek:FeatureEnvy + def formatted_event_filters + event_filters.reject { |_, value| value.blank? }.map do |key, value| + value_str = if value.is_a?(Array) + "[#{value.join(', ')}]" + elsif value.is_a?(Hash) + "[#{value.map { |string_k, string_v| "#{string_k}: #{string_v || 'None'}" }.join(', ')}]" + else + value.to_s + end + "#{key}: #{value_str}" + end + end + + def as_csv + CSV.generate do |csv| + csv << formatted_event_filters + csv << CHANGE_HISTORY_CSV_COLUMNS + events.each do |event| + event_columns = event.to_csv_array.flatten + # Replace the url from the event with the domain url retrieved from the controller request + event_columns[2] = [tasks_url, event.task_id].join + csv << event_columns + end + end + end +end diff --git a/app/services/claim_change_history/claim_history_event.rb b/app/services/claim_change_history/claim_history_event.rb new file mode 100644 index 00000000000..19bbcca34cf --- /dev/null +++ b/app/services/claim_change_history/claim_history_event.rb @@ -0,0 +1,548 @@ +# frozen_string_literal: true + +class InvalidEventType < StandardError + def initialize(event_type) + super("Invalid event type: #{event_type}") + end +end + +# :reek:TooManyInstanceVariables +# :reek:TooManyConstants +# rubocop:disable Metrics/ClassLength +class ClaimHistoryEvent + attr_reader :task_id, :event_type, :event_date, :assigned_at, :days_waiting, + :veteran_file_number, :claim_type, :claimant_name, :user_facility, + :benefit_type, :issue_type, :issue_description, :decision_date, + :disposition, :decision_description, :withdrawal_request_date, + :task_status, :disposition_date, :intake_completed_date, :event_user_name, + :event_user_css_id + + EVENT_TYPES = [ + :completed_disposition, + :claim_creation, + :withdrew_issue, + :removed_issue, + :added_decision_date, + :added_issue, + :added_issue_without_decision_date, + :in_progress, + :completed, + :incomplete, + :cancelled + ].freeze + + ISSUE_EVENTS = [ + :completed_disposition, + :added_issue, + :withdrew_issue, + :removed_issue, + :added_decision_date, + :added_issue_without_decision_date + ].freeze + + DISPOSITION_EVENTS = [ + :completed_disposition, + :added_issue, + :added_issue_without_decision_date, + :added_decision_date + ].freeze + + STATUS_EVENTS = [:in_progress, :incomplete, :completed, :claim_creation, :cancelled].freeze + + REQUEST_ISSUE_TIME_WINDOW = 15 + STATUS_EVENT_TIME_WINDOW = 2 + + class << self + def from_change_data(event_type, change_data) + new(event_type, change_data) + end + + def create_completed_disposition_event(change_data) + if change_data["disposition"] + event_hash = { + "event_date" => change_data["decision_created_at"], + "event_user_name" => change_data["decision_user_name"], + "user_facility" => change_data["decision_user_station_id"], + "event_user_css_id" => change_data["decision_user_css_id"] + } + from_change_data(:completed_disposition, change_data.merge(event_hash)) + end + end + + def create_claim_creation_event(change_data) + from_change_data(:claim_creation, change_data.merge(intake_event_hash(change_data))) + end + + # rubocop:disable Metrics/MethodLength + def create_status_events(change_data) + status_events = [] + versions = parse_versions(change_data) + + hookless_cancelled_events = handle_hookless_cancelled_status_events(versions, change_data) + status_events.push(*hookless_cancelled_events) + + if versions.present? + first_version, *rest_of_versions = versions + + # Assume that if the dates are equal then it should be a assigned -> on_hold status event that is recorded + # Due to the way intake is processed a task is always created as assigned first + time_difference = (first_version["updated_at"][0] - first_version["updated_at"][1]).to_f.abs + + # If the time difference is > than 2 seconds then assume it is a valid status change instead of the + # Normal intake assigned -> on_hold that will happen for no decision date HLR/SC intakes + if time_difference > STATUS_EVENT_TIME_WINDOW + status_events.push event_from_version(first_version, 0, change_data) + end + + status_events.push event_from_version(first_version, 1, change_data) + + rest_of_versions.map do |version| + status_events.push event_from_version(version, 1, change_data) + end + elsif hookless_cancelled_events.empty? + # No versions so make an event with the current status + # There is a chance that a task has no intake either through data setup or through a remanded SC + event_date = change_data["intake_completed_at"] || change_data["task_created_at"] + status_events.push from_change_data(task_status_to_event_type(change_data["task_status"]), + change_data.merge("event_date" => event_date, + "event_user_name" => "System")) + end + + status_events + end + # rubocop:enable Metrics/MethodLength + + def parse_versions(change_data) + versions = change_data["task_versions"] + if versions + # Quite a bit faster but less safe. Should probably be fine since it's coming from the database + # rubocop:disable Security/YAMLLoad + versions[1..-2].split(",").map { |yaml| YAML.load(yaml.gsub(/^"|"$/, "")) } + # versions[1..-2].split(",").map { |yaml| YAML.safe_load(yaml.gsub(/^"|"$/, ""), [Time]) } + # rubocop:enable Security/YAMLLoad + + end + end + + def create_issue_events(change_data) + issue_events = [] + before_request_issue_ids = (change_data["before_request_issue_ids"] || "").scan(/\d+/).map(&:to_i) + after_request_issue_ids = (change_data["after_request_issue_ids"] || "").scan(/\d+/).map(&:to_i) + withdrawn_request_issue_ids = (change_data["withdrawn_request_issue_ids"] || "").scan(/\d+/).map(&:to_i) + edited_request_issue_ids = (change_data["edited_request_issue_ids"] || "").scan(/\d+/).map(&:to_i) + removed_request_issue_ids = (before_request_issue_ids - after_request_issue_ids) + updates_hash = update_event_hash(change_data).merge("event_date" => change_data["request_issue_update_time"]) + + # Adds all request issue events to the issue events array + issue_events.push(*process_issue_ids(withdrawn_request_issue_ids, + :withdrew_issue, + change_data.merge(updates_hash))) + issue_events.push(*process_issue_ids(removed_request_issue_ids, :removed_issue, change_data.merge(updates_hash))) + issue_events.push(*process_issue_ids(edited_request_issue_ids, :edited_issue, change_data.merge(updates_hash))) + + issue_events + end + + def process_issue_ids(request_issue_ids, event_type, change_data) + created_events = [] + + request_issue_ids.each do |request_issue_id| + issue_data = retrieve_issue_data(request_issue_id, change_data) + + unless issue_data + Rails.logger.error("No request issue found during change history generation for id: #{request_issue_id}") + next + end + + request_issue_data = change_data.merge(issue_data) + if event_type == :edited_issue + # Compare the two dates to try to guess if it was adding a decision date or not + same_transaction = date_strings_within_seconds?(request_issue_data["decision_date_added_at"], + request_issue_data["request_issue_update_time"], + REQUEST_ISSUE_TIME_WINDOW) + if request_issue_data["decision_date_added_at"].present? && same_transaction + created_events.push from_change_data(:added_decision_date, request_issue_data) + end + else + created_events.push from_change_data(event_type, request_issue_data) + end + end + + created_events + end + + def create_add_issue_event(change_data) + # Try to guess if it was added during intake. If not, it was a probably added during an issue update + same_transaction = date_strings_within_seconds?(change_data["intake_completed_at"], + change_data["request_issue_created_at"], + REQUEST_ISSUE_TIME_WINDOW) + # If it was during intake or if there's no request issue update time then use the intake event hash + # This will also catch most request issues that were added to claims that don't have an intake + event_hash = if same_transaction || !change_data["request_issue_update_time"] + intake_event_hash(change_data) + else + # try to guess the request issue update user data + add_issue_update_event_hash(change_data) + end + + event_type = determine_add_issue_event_type(change_data) + from_change_data(event_type, change_data.merge(event_hash)) + end + + private + + def retrieve_issue_data(request_issue_id, change_data) + # If the request issue id is the same as the database row that is being parsed, then skip the database fetch + return {} if change_data["request_issue_id"] == request_issue_id + + # Manually try to fetch the request issue from the database + request_issue = RequestIssue.find_by(id: request_issue_id) + + if request_issue + { + "nonrating_issue_category" => request_issue.nonrating_issue_category, + "nonrating_issue_description" => request_issue.nonrating_issue_description || + request_issue.unidentified_issue_text, + "decision_date" => request_issue.decision_date, + "decision_date_added_at" => request_issue.decision_date_added_at&.iso8601, + "request_issue_closed_at" => request_issue.closed_at + } + end + end + + def task_status_to_event_type(task_status) + { + "in_progress" => :in_progress, + "assigned" => :in_progress, + "on_hold" => :incomplete, + "completed" => :completed, + "cancelled" => :cancelled + }[task_status] + end + + def event_from_version(changes, index, change_data) + # If there is no task status change in the set of papertrail changes, ignore the object + if changes["status"] + event_type = task_status_to_event_type(changes["status"][index]) + event_date_hash = { "event_date" => changes["updated_at"][index], "event_user_name" => "System" } + from_change_data(event_type, change_data.merge(event_date_hash)) + end + end + + def determine_add_issue_event_type(change_data) + # If there is no decision_date_added_at time, assume it is old data and that it had a decision date on creation + had_decision_date = if change_data["decision_date"] && change_data["decision_date_added_at"] + # Assume if the time window was within 15 seconds of creation that it had a decision date + date_strings_within_seconds?(change_data["request_issue_created_at"], + change_data["decision_date_added_at"], + REQUEST_ISSUE_TIME_WINDOW) + elsif change_data["decision_date"].blank? + false + else + true + end + + had_decision_date ? :added_issue : :added_issue_without_decision_date + end + + def intake_event_hash(change_data) + { + # There is a chance that a task has no intake either through data setup or through a remanded SC, + # so include a backup event date and user name as System + "event_date" => change_data["intake_completed_at"] || change_data["task_created_at"], + "event_user_name" => change_data["intake_user_name"] || "System", + "user_facility" => change_data["intake_user_station_id"], + "event_user_css_id" => change_data["intake_user_css_id"] + } + end + + def update_event_hash(change_data) + { + "event_user_name" => change_data["update_user_name"], + "user_facility" => change_data["update_user_station_id"], + "event_user_css_id" => change_data["update_user_css_id"] + } + end + + def add_issue_update_event_hash(change_data) + # Check the current request issue updates time to see if the issue update is in the correct row + # If it is, then do the normal update_event_hash information + if date_strings_within_seconds?(change_data["request_issue_created_at"], + change_data["request_issue_update_time"], + REQUEST_ISSUE_TIME_WINDOW) + update_event_hash(change_data).merge("event_date" => change_data["request_issue_created_at"]) + else + # If it's not, then do some database fetches to grab the correct information + retrieve_issue_update_data(change_data) + end + end + + def retrieve_issue_update_data(change_data) + # This DB fetch is gross, but thankfully it should happen very rarely + task = Task.includes(appeal: :request_issues_updates).where(id: change_data["task_id"]).first + issue_update = task.appeal.request_issues_updates.find do |update| + (update.after_request_issue_ids - update.before_request_issue_ids).include?(change_data["request_issue_id"]) + end + if issue_update + { + "event_date" => change_data["request_issue_created_at"], + "event_user_name" => issue_update.user&.full_name, + "user_facility" => issue_update.user&.station_id, + "event_user_css_id" => issue_update.user&.css_id + } + # If for some reason there was no match, then just default to the row that already exists in the change data + else + update_event_hash(change_data).merge("event_date" => change_data["request_issue_created_at"]) + end + end + + def date_strings_within_seconds?(first_date, second_date, time_in_seconds) + return false unless first_date && second_date + + # Less variables for less garbage collection since this method is used a lot + ((DateTime.iso8601(first_date.tr(" ", "T")) - + DateTime.iso8601(second_date.tr(" ", "T"))).abs * 24 * 60 * 60).to_f < time_in_seconds + end + + def handle_hookless_cancelled_status_events(versions, change_data) + # The remove request issues circumvents the normal paper trail hooks and results in a weird database state + return [] unless versions + + cancelled_task_versions = versions.select { |element| element.is_a?(Hash) && element.empty? } + + return [] if cancelled_task_versions.empty? + + # Mutate the versions array and remove these empty object changes from it + versions.reject! { |element| element.is_a?(Hash) && element.empty? } + + create_hookless_cancelled_events(versions, change_data) + end + + def create_hookless_cancelled_events(versions, change_data) + if versions.present? + [ + # If there are other versions, then those will be created and used in addition to this cancelled event + from_change_data(:cancelled, change_data.merge("event_date" => change_data["task_closed_at"], + "event_user_name" => "System")) + ] + else + [ + # If there are no other versions, assume the state went from assigned -> cancelled + from_change_data(:in_progress, change_data.merge("event_date" => change_data["intake_completed_at"] || + change_data["task_created_at"], + "event_user_name" => "System")), + from_change_data(:cancelled, change_data.merge("event_date" => change_data["task_closed_at"], + "event_user_name" => "System")) + ] + end + end + end + + ############### End of Class methods ################## + + def initialize(event_type, change_data) + if EVENT_TYPES.include?(event_type) + set_attributes_from_change_history_data(event_type, change_data) + else + fail InvalidEventType, "Invalid event type: #{event_type}" + end + end + + def to_csv_array + [ + veteran_file_number, claimant_name, task_url, readable_task_status, + days_waiting, readable_claim_type, readable_facility_name, readable_user_name, readable_event_date, + readable_event_type, issue_or_status_information, disposition_information + ] + end + + # This needs to be replaced later depending on request data or usage in the app + def task_url + "https://www.caseflowdemo.com/decision_reviews/vha/tasks/#{task_id}" + end + + def readable_task_status + { + "assigned" => "in progress", + "in_progress" => "in progress", + "on_hold" => "incomplete", + "completed" => "completed", + "cancelled" => "cancelled" + }[task_status] + end + + def readable_claim_type + { + "HigherLevelReview" => "Higher-Level Review", + "SupplementalClaim" => "Supplemental Claim" + }[claim_type] + end + + def readable_user_name + if event_user_name == "System" + event_user_name + elsif event_user_name.present? + abbreviated_user_name(event_user_name) + end + end + + def readable_event_date + format_date_string(event_date) + end + + def readable_decision_date + format_date_string(decision_date) + end + + def readable_disposition_date + format_date_string(disposition_date) + end + + def readable_facility_name + return "" unless user_facility + + [Constants::BGS_FACILITY_CODES[user_facility], " (", user_facility, ")"].join + end + + def readable_event_type + { + in_progress: "Claim status - In Progress", + incomplete: "Claim status - Incomplete", + completed: "Claim closed", + claim_creation: "Claim created", + completed_disposition: "Completed disposition", + added_issue: "Added Issue", + added_issue_without_decision_date: "Added issue - No decision date", + withdrew_issue: "Withdrew issue", + removed_issue: "Removed issue", + added_decision_date: "Added decision date", + cancelled: "Claim closed" + }[event_type] + end + + def issue_event? + ISSUE_EVENTS.include?(event_type) + end + + def event_can_contain_disposition? + DISPOSITION_EVENTS.include?(event_type) + end + + def disposition_event? + event_type == :completed_disposition + end + + def status_event? + STATUS_EVENTS.include?(event_type) + end + + private + + def set_attributes_from_change_history_data(new_event_type, change_data) + @event_type = new_event_type + @claimant_name = change_data["claimant_name"] + @event_date = change_data["event_date"] + parse_event_attributes(change_data) + parse_intake_attributes(change_data) + parse_task_attributes(change_data) + parse_issue_attributes(change_data) + parse_disposition_attributes(change_data) + end + + def parse_task_attributes(change_data) + @task_id = change_data["task_id"] + @task_status = change_data["task_status"] + @claim_type = change_data["appeal_type"] + @assigned_at = change_data["assigned_at"] + @days_waiting = change_data["days_waiting"] + end + + def parse_intake_attributes(change_data) + @intake_completed_date = change_data["intake_completed_at"] + @veteran_file_number = change_data["veteran_file_number"] + end + + def parse_issue_attributes(change_data) + if issue_event? + @issue_type = change_data["nonrating_issue_category"] + @issue_description = change_data["nonrating_issue_description"] || change_data["unidentified_issue_text"] + @decision_date = change_data["decision_date"] + @withdrawal_request_date = change_data["request_issue_closed_at"] + end + @benefit_type = change_data["request_issue_benefit_type"] + end + + def parse_disposition_attributes(change_data) + if event_can_contain_disposition? + @disposition = change_data["disposition"] + @disposition_date = change_data["caseflow_decision_date"] + @decision_description = change_data["decision_description"] + end + end + + def parse_event_attributes(change_data) + standardize_event_date + @user_facility = change_data["user_facility"] + @event_user_name = change_data["event_user_name"] + @event_user_css_id = change_data["event_user_css_id"] + end + + def standardize_event_date + # Try to keep all the dates consistent as a iso8601 string if possible + @event_date = if event_date.is_a?(String) + event_date + else + event_date&.iso8601 + end + end + + ############ CSV and Serializer Helpers ############ + + def abbreviated_user_name(name_string) + first_name, last_name = name_string.split(" ") + name_abbreviation(first_name, last_name) + end + + def issue_information + if issue_event? + [issue_type, issue_description, readable_decision_date] + end + end + + def disposition_information + if disposition_event? + [disposition, decision_description, readable_disposition_date] + end + end + + def issue_or_status_information + if status_event? + [nil, status_description] + else + issue_information + end + end + + def status_description + { + in_progress: "Claim can be processed.", + incomplete: "Claim cannot be processed until decision date is entered.", + completed: "Claim closed.", + claim_creation: "Claim created.", + cancelled: "Claim closed." + }[event_type] + end + + def format_date_string(date) + if date.class == String + DateTime.iso8601(date.tr(" ", "T")).strftime("%-m/%-d/%Y") + elsif date.present? + date.strftime("%-m/%-d/%Y") + end + end + + def name_abbreviation(first_name, last_name) + [first_name[0].capitalize, ". ", last_name].join + end +end +# rubocop:enable Metrics/ClassLength diff --git a/app/services/claim_change_history/claim_history_service.rb b/app/services/claim_change_history/claim_history_service.rb new file mode 100644 index 00000000000..8ad2f6b7f79 --- /dev/null +++ b/app/services/claim_change_history/claim_history_service.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +# :reek:TooManyInstanceVariables +class ClaimHistoryService + attr_reader :business_line, :processed_task_ids, + :processed_request_issue_ids, :processed_request_issue_update_ids, + :processed_decision_issue_ids, :events, :filters + attr_writer :filters + + TIMING_RANGES = %w[ + before + after + between + last_7_days + last_30_days + last_365_days + ].freeze + + def initialize(business_line = VhaBusinessLine.singleton, filters = {}) + @business_line = business_line + @filters = filters.to_h + @processed_task_ids = Set.new + @processed_request_issue_update_ids = Set.new + @processed_decision_issue_ids = Set.new + @processed_request_issue_ids = Set.new + @events = [] + end + + def build_events + # Reset the instance attributes from the last time build_events was ran + reset_processing_attributes + + all_data = business_line.change_history_rows(@filters) + + all_data.entries.each do |change_data| + process_request_issue_update_events(change_data) + process_request_issue_events(change_data) + process_task_events(change_data) + process_decision_issue_events(change_data) + end + + # Compact and sort in place to reduce garbage collection + @events.compact! + @events.sort_by! { |event| [event.task_id, event.event_date] } + + # This currently relies on the events being sorted before hand + filter_events_for_last_action_taken! + + @events + end + + private + + def reset_processing_attributes + @processed_task_ids.clear + @processed_request_issue_update_ids.clear + @processed_decision_issue_ids.clear + @processed_request_issue_ids.clear + @events.clear + end + + def save_events(new_events) + filtered_events = matches_filter(new_events) + + if filtered_events.present? + @events.push(*filtered_events) + end + end + + def matches_filter(new_events) + # Days Waiting, Task ID, Task Status, and Claim Type are all filtered entirely by the business line DB query + # The events, Issue types, dispositions, personnel, and facilities filters are partially filtered by DB query + # and then further filtered below in this service class after the event has been created + + # Ensure that we always treat this as an array of events for processing + filtered_events = ensure_array(new_events) + # Go ahead and extract any nil events + filtered_events = process_event_filter(filtered_events.compact) + filtered_events = process_issue_type_filter(filtered_events) + filtered_events = process_dispositions_filter(filtered_events) + filtered_events = process_timing_filter(filtered_events) + + # These are mutally exclusive in the UI, but no technical reason why both can't be used together + filtered_events = process_personnel_filter(filtered_events) + filtered_events = process_facility_filter(filtered_events) + + filtered_events.compact + end + + def process_event_filter(new_events) + return new_events if @filters[:events].blank? + + new_events.select { |event| event && ensure_array(@filters[:events]).include?(event.event_type) } + end + + def process_issue_type_filter(new_events) + return new_events if @filters[:issue_types].blank? + + new_events.select { |event| event && ensure_array(@filters[:issue_types]).include?(event.issue_type) } + end + + def process_dispositions_filter(new_events) + return new_events if @filters[:dispositions].blank? + + new_events.select do |event| + event && ensure_array(@filters[:dispositions]).include?(event.disposition) || + @filters[:dispositions].include?("Blank") && event.disposition.nil? + end + end + + def process_timing_filter(new_events) + return new_events unless @filters[:timing].present? && TIMING_RANGES.include?(@filters[:timing][:range]) + + # Try to guess the date format from either a string or iso8601 date string object + start_date, end_date = date_range_for_timing_filter + start_date, end_date = parse_date_strings(start_date, end_date) + + new_events.select do |event| + event_date = Date.parse(event.event_date) + (start_date.nil? || event_date >= start_date) && (end_date.nil? || event_date <= end_date) + end + end + + def date_range_for_timing_filter + { + "before" => [nil, @filters[:timing][:start_date]], + "after" => [@filters[:timing][:start_date], nil], + "between" => [@filters[:timing][:start_date], @filters[:timing][:end_date]], + "last_7_days" => [Time.zone.today - 6, Time.zone.today], + "last_30_days" => [Time.zone.today - 29, Time.zone.today], + "last_365_days" => [Time.zone.today - 364, Time.zone.today] + }[@filters[:timing][:range]] + end + + # Date helpers for filtering + def parse_date_strings(start_date, end_date) + start_date = parse_date(start_date) + end_date = parse_date(end_date) + [start_date, end_date] + end + + # Function to attempt to guess the date from the filter. Works for 11/1/2023 format and iso8601 + def parse_date(date) + if date.is_a?(String) + begin + Date.strptime(date, "%m/%d/%Y") + rescue ArgumentError + Date.iso8601(date) + end + else + date + end + end + + def process_personnel_filter(new_events) + return new_events if @filters[:personnel].blank? + + new_events.select { |event| ensure_array(@filters[:personnel]).include?(event.event_user_css_id) } + end + + def process_facility_filter(new_events) + return new_events if @filters[:facilities].blank? + + # Station ids are strings for some reason + new_events.select { |event| ensure_array(@filters[:facilities]).include?(event.user_facility) } + end + + def filter_events_for_last_action_taken! + return nil unless @filters[:status_report_type].present? && @filters[:status_report_type] == "last_action_taken" + + # This currently assumes that the events will be sorted by task_id and event_date before this + # Use slice_when to group events by task_id + grouped_events = events.slice_when { |prev, curr| prev.task_id != curr.task_id } + + # Map each group to its last event + filtered_events = grouped_events.map(&:last) + + @events = filtered_events + end + + def process_request_issue_update_events(change_data) + request_issue_update_id = change_data["request_issue_update_id"] + + if request_issue_update_id && !@processed_request_issue_update_ids.include?(request_issue_update_id) + @processed_request_issue_update_ids.add(request_issue_update_id) + save_events(ClaimHistoryEvent.create_issue_events(change_data)) + end + end + + def process_task_events(change_data) + task_id = change_data["task_id"] + + if task_id && !@processed_task_ids.include?(task_id) + @processed_task_ids.add(task_id) + save_events(ClaimHistoryEvent.create_claim_creation_event(change_data)) + save_events(ClaimHistoryEvent.create_status_events(change_data)) + end + end + + def process_request_issue_events(change_data) + request_issue_id = change_data["request_issue_id"] + + if request_issue_id && !@processed_request_issue_ids.include?(request_issue_id) + @processed_request_issue_ids.add(request_issue_id) + save_events(ClaimHistoryEvent.create_add_issue_event(change_data)) + end + end + + def process_decision_issue_events(change_data) + decision_issue_id = change_data["decision_issue_id"] + + if decision_issue_id && !@processed_decision_issue_ids.include?(decision_issue_id) + @processed_decision_issue_ids.add(decision_issue_id) + save_events(ClaimHistoryEvent.create_completed_disposition_event(change_data)) + end + end + + def ensure_array(variable) + variable.is_a?(Array) ? variable : [variable] + end +end diff --git a/app/services/metrics_service.rb b/app/services/metrics_service.rb index aaf8919ed0a..7d3c8b0f922 100644 --- a/app/services/metrics_service.rb +++ b/app/services/metrics_service.rb @@ -119,7 +119,8 @@ def self.store_record_metric(uuid, params, caller) sent_to_info: params[:sent_to_info], start: params[:start], end: params[:end], - duration: params[:duration] + duration: params[:duration], + additional_info: params[:additional_info] } metric = Metric.create_metric(caller || self, params, RequestStore[:current_user]) diff --git a/app/views/decision_reviews/index.html.erb b/app/views/decision_reviews/index.html.erb index 8e276a690a0..c7ffff71255 100644 --- a/app/views/decision_reviews/index.html.erb +++ b/app/views/decision_reviews/index.html.erb @@ -11,17 +11,14 @@ businessLineUrl: business_line.url, featureToggles: { decisionReviewQueueSsnColumn: FeatureToggle.enabled?(:decision_review_queue_ssn_column, user: current_user), + poa_button_refresh: FeatureToggle.enabled?(:poa_button_refresh, user: current_user), metricsBrowserError: FeatureToggle.enabled?(:metrics_browser_error, user: current_user) }, poaAlert: {}, baseTasksUrl: business_line.tasks_url, businessLineConfig: business_line_config_options, - taskFilterDetails: task_filter_details + taskFilterDetails: task_filter_details, + isBusinessLineAdmin: business_line.user_is_admin?(current_user) }, - ui: { - featureToggles: { - poa_button_refresh: FeatureToggle.enabled?(:poa_button_refresh) - } - } }) %> <% end %> diff --git a/app/views/decision_reviews/show.html.erb b/app/views/decision_reviews/show.html.erb index 9e269261b89..108e6dc77ef 100644 --- a/app/views/decision_reviews/show.html.erb +++ b/app/views/decision_reviews/show.html.erb @@ -15,17 +15,14 @@ appeal: task.appeal_ui_hash, poaAlert: {}, featureToggles: { - decisionReviewQueueSsnColumn: FeatureToggle.enabled?(:decision_review_queue_ssn_column, user: current_user) + decisionReviewQueueSsnColumn: FeatureToggle.enabled?(:decision_review_queue_ssn_column, user: current_user), + poa_button_refresh: FeatureToggle.enabled?(:poa_button_refresh, user: current_user) }, loadingPowerOfAttorney: { loading: false, error: false - }, - ui: { - featureToggles: { - poa_button_refresh: FeatureToggle.enabled?(:poa_button_refresh) - } - } + }, + isBusinessLineAdmin: business_line.user_is_admin?(current_user) } }) %> <% end %> diff --git a/client/.storybook/utils.js b/client/.storybook/utils.js index ae01c37f17a..caa9039b69f 100644 --- a/client/.storybook/utils.js +++ b/client/.storybook/utils.js @@ -12,7 +12,7 @@ module.exports = { "id": "6115", "isLegacyAppeal": false, "issueCount": 1, - "activeRequestIssues": 1 + "activeOrDecidedRequestIssues": 1 }, "issue_count": 1, "issue_types": "Caregiver | Eligibility", @@ -41,7 +41,7 @@ module.exports = { "id": "6114", "isLegacyAppeal": false, "issueCount": 1, - "activeRequestIssues": 1 + "activeOrDecidedRequestIssues": 1 }, "issue_count": 1, "issue_types": "Initial Eligibility and Enrollment in VHA Healthcare", @@ -70,7 +70,7 @@ module.exports = { "id": "17", "isLegacyAppeal": false, "issueCount": 1, - "activeRequestIssues": 1 + "activeOrDecidedRequestIssues": 1 }, "issue_count": 1, "issue_types": "Initial Eligibility and Enrollment in VHA Healthcare", @@ -99,7 +99,7 @@ module.exports = { "id": "3f67e4b2-392b-4248-a722-0845b2d3a29e", "isLegacyAppeal": false, "issueCount": 0, - "activeRequestIssues": 0 + "activeOrDecidedRequestIssues": 0 }, "issue_count": 0, "issue_types": "", @@ -128,7 +128,7 @@ module.exports = { "id": "14", "isLegacyAppeal": false, "issueCount": 1, - "activeRequestIssues": 1 + "activeOrDecidedRequestIssues": 1 }, "issue_count": 1, "issue_types": "Caregiver | Revocation/Discharge", @@ -157,7 +157,7 @@ module.exports = { "id": "13", "isLegacyAppeal": false, "issueCount": 1, - "activeRequestIssues": 1 + "activeOrDecidedRequestIssues": 1 }, "issue_count": 1, "issue_types": "Initial Eligibility and Enrollment in VHA Healthcare", @@ -186,7 +186,7 @@ module.exports = { "id": "5061", "isLegacyAppeal": false, "issueCount": 1, - "activeRequestIssues": 1 + "activeOrDecidedRequestIssues": 1 }, "issue_count": 1, "issue_types": "Initial Eligibility and Enrollment in VHA Healthcare", @@ -243,7 +243,7 @@ module.exports = { "id": "5105", "isLegacyAppeal": false, "issueCount": 1, - "activeRequestIssues": 1 + "activeOrDecidedRequestIssues": 1 }, "issue_count": 1, "issue_types": "Foreign Medical Program", @@ -272,7 +272,7 @@ module.exports = { "id": "5096", "isLegacyAppeal": false, "issueCount": 2, - "activeRequestIssues": 2 + "activeOrDecidedRequestIssues": 2 }, "issue_count": 2, "issue_types": "Foreign Medical Program,Spina Bifida Treatment (Non-Compensation)", @@ -301,7 +301,7 @@ module.exports = { "id": "5083", "isLegacyAppeal": false, "issueCount": 1, - "activeRequestIssues": 1 + "activeOrDecidedRequestIssues": 1 }, "issue_count": 1, "issue_types": "Spina Bifida Treatment (Non-Compensation)", @@ -330,7 +330,7 @@ module.exports = { "id": "5081", "isLegacyAppeal": false, "issueCount": 1, - "activeRequestIssues": 1 + "activeOrDecidedRequestIssues": 1 }, "issue_count": 1, "issue_types": "Camp Lejune Family Member", @@ -359,7 +359,7 @@ module.exports = { "id": "5106", "isLegacyAppeal": false, "issueCount": 1, - "activeRequestIssues": 1 + "activeOrDecidedRequestIssues": 1 }, "issue_count": 1, "issue_types": "Camp Lejune Family Member", @@ -388,7 +388,7 @@ module.exports = { "id": "5101", "isLegacyAppeal": false, "issueCount": 1, - "activeRequestIssues": 1 + "activeOrDecidedRequestIssues": 1 }, "issue_count": 1, "issue_types": "Other", @@ -417,7 +417,7 @@ module.exports = { "id": "5094", "isLegacyAppeal": false, "issueCount": 1, - "activeRequestIssues": 1 + "activeOrDecidedRequestIssues": 1 }, "issue_count": 1, "issue_types": "Caregiver | Other", diff --git a/client/app/components/SearchableDropdown.jsx b/client/app/components/SearchableDropdown.jsx index 30cac9f2d29..2496a46dbff 100644 --- a/client/app/components/SearchableDropdown.jsx +++ b/client/app/components/SearchableDropdown.jsx @@ -407,6 +407,7 @@ SearchableDropdown.propTypes = { PropTypes.func, // Or the instance of a DOM native element (see the note about SSR) PropTypes.shape({ current: PropTypes.instanceOf(Element) }), + PropTypes.shape({ current: PropTypes.instanceOf(Object) }), ]), label: PropTypes.string, strongLabel: PropTypes.bool, diff --git a/client/app/nonComp/actions/changeHistorySlice.js b/client/app/nonComp/actions/changeHistorySlice.js new file mode 100644 index 00000000000..497e29009af --- /dev/null +++ b/client/app/nonComp/actions/changeHistorySlice.js @@ -0,0 +1,72 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import ApiUtil from '../../util/ApiUtil'; +import { getMinutesToMilliseconds } from '../../util/DateUtil'; + +// Define the initial state +const initialState = { + // We might not keep filters here and may only persist them in local state + status: 'idle', + error: null, +}; + +export const downloadReportCSV = createAsyncThunk('changeHistory/downloadReport', + async ({ organizationUrl, filterData }, thunkApi) => { + try { + const postData = ApiUtil.convertToSnakeCase(filterData); + const getOptions = { + query: postData.filters, + headers: { Accept: 'text/csv' }, + responseType: 'arraybuffer', + timeout: { response: getMinutesToMilliseconds(3) } + }; + const response = await ApiUtil.get(`/decision_reviews/${organizationUrl}/report`, getOptions); + + // Create a Blob from the array buffer + const blob = new Blob([response.body], { type: 'text/csv' }); + + // Access the filename from the response headers + const contentDisposition = response.headers['content-disposition']; + const matches = contentDisposition.match(/filename="(.+)"/); + + const filename = matches ? matches[1] : 'report.csv'; + + // Create a download link to trigger the download of the csv + const link = document.createElement('a'); + + link.href = window.URL.createObjectURL(blob); + link.download = filename; + + // Append the link to the document, trigger a click on the link, and remove the link from the document + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + return thunkApi.fulfillWithValue('success', { analytics: true }); + + } catch (error) { + console.error(error); + + return thunkApi.rejectWithValue(`CSV generation failed: ${error.message}`, { analytics: true }); + } + }); + +const changeHistorySlice = createSlice({ + name: 'changeHistory', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder. + addCase(downloadReportCSV.pending, (state) => { + state.status = 'loading'; + }). + addCase(downloadReportCSV.fulfilled, (state) => { + state.status = 'succeeded'; + }). + addCase(downloadReportCSV.rejected, (state, action) => { + state.status = 'failed'; + state.error = action.error.message; + }); + }, +}); + +export default changeHistorySlice.reducer; diff --git a/client/app/nonComp/actions/usersSlice.js b/client/app/nonComp/actions/usersSlice.js new file mode 100644 index 00000000000..1fe53ee277f --- /dev/null +++ b/client/app/nonComp/actions/usersSlice.js @@ -0,0 +1,97 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import ApiUtil from '../../util/ApiUtil'; +import { find, get, has } from 'lodash'; + +// Define the initial state +const initialState = { + users: [], + status: 'idle', + error: null, +}; + +/** + * Asynchronous Redux Thunk for fetching user data based on different query parameters. + * + * @param {string} queryType - Specifies the type of query ('organization', 'css_id', 'role', etc.). + * @param {Object} queryParams - Additional parameters for the query, e.g., { query, judgeID, excludeOrg }. + * query - The data that is being used in the user query e.g. the org name or url + * - organization - the query can be the Organization name or url + * - role - [Attorney, Judge, HearingCoordinator, non_judges, non_dvcs] + * - css_id - The CSS ID or name of users + * optional params + * judgeID - optional parameter that is the id of a judge that can be used during the + * attorney role query. It returns only attorneys associated with that judge + * excludeOrg - optional parameter that is the name or url of an organizatoin used during + * the css_id query. It excludes users that are in that organization + * @returns {Promise} A Promise that resolves with the fetched user data and analytics metadata, + * or rejects with an error message and analytics information in case of failure. + */ +export const fetchUsers = createAsyncThunk('users/fetchUsers', async ({ queryType, queryParams }, thunkApi) => { + let usersEndpoint = '/users'; + const { query, judgeID, excludeOrg } = queryParams; + + if (queryType === 'organization') { + usersEndpoint = `${usersEndpoint}?organization=${query}`; + } else if (queryType === 'css_id') { + // This searches by user name and by css id but the query param is always called css_id + usersEndpoint = `${usersEndpoint}?css_id=${query}`; + } else if (queryType === 'role') { + usersEndpoint = `${usersEndpoint}?role=${query}`; + } else { + return thunkApi.rejectWithValue('Invalid query type', { analytics: true }); + } + + // Optional get parameters that some of the controller actions use + if (judgeID) { + usersEndpoint = `${usersEndpoint}&judge_id=${judgeID}`; + } + + if (excludeOrg) { + usersEndpoint = `${usersEndpoint}&exclude_org=${excludeOrg}`; + } + + try { + const response = await ApiUtil.get(usersEndpoint); + + // Use the first key since the returning body can be a variety of different keys e.g. users, judges, attorneys + const usersKey = Object.keys(response.body)[0]; + + // Sometimes the key is .data and sometimes it's the toplevel object so try .data first and then the toplevel + const possibleKeys = [`${usersKey}.data`, `${usersKey}`]; + const foundKeyPath = find(possibleKeys, (keyPath) => has(response.body, keyPath)); + const result = foundKeyPath ? get(response.body, foundKeyPath) : null; + + // Some of the data contains .attributes and some doesn't so expand all the attributes out to the top level objects + const preparedData = result.map(({ attributes, ...rest }) => ({ ...attributes, ...rest })); + + return thunkApi.fulfillWithValue(preparedData, { analytics: true }); + + } catch (error) { + console.error(error); + + return thunkApi.rejectWithValue('Users API request failed', { analytics: true }); + } +} +); + +const usersSlice = createSlice({ + name: 'users', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder. + addCase(fetchUsers.pending, (state) => { + state.status = 'loading'; + }). + addCase(fetchUsers.fulfilled, (state, action) => { + state.status = 'succeeded'; + state.users = action.payload; + }). + addCase(fetchUsers.rejected, (state, action) => { + state.status = 'failed'; + state.error = action.error.message; + }); + }, +}); + +export default usersSlice.reducer; diff --git a/client/app/nonComp/components/BoardGrant.jsx b/client/app/nonComp/components/BoardGrant.jsx index d38d53f07ad..0e559044ec8 100644 --- a/client/app/nonComp/components/BoardGrant.jsx +++ b/client/app/nonComp/components/BoardGrant.jsx @@ -138,10 +138,10 @@ BoardGrantUnconnected.propTypes = { const BoardGrant = connect( (state) => ({ - appeal: state.appeal, - businessLine: state.businessLine, - task: state.task, - decisionIssuesStatus: state.decisionIssuesStatus + appeal: state.nonComp.appeal, + businessLine: state.nonComp.businessLine, + task: state.nonComp.task, + decisionIssuesStatus: state.nonComp.decisionIssuesStatus }) )(BoardGrantUnconnected); diff --git a/client/app/nonComp/components/Disposition.jsx b/client/app/nonComp/components/Disposition.jsx index 4818c8dfe5f..14bb3dd0e12 100644 --- a/client/app/nonComp/components/Disposition.jsx +++ b/client/app/nonComp/components/Disposition.jsx @@ -100,7 +100,7 @@ class NonCompDispositions extends React.PureComponent { this.state = { requestIssues: formatRequestIssuesWithDecisionIssues( - this.props.task.appeal.activeRequestIssues, this.props.appeal.decisionIssues), + this.props.task.appeal.activeOrDecidedRequestIssues, this.props.appeal.decisionIssues), decisionDate: '', isFilledOut: false }; @@ -266,8 +266,8 @@ NonCompDispositions.propTypes = { export default connect( (state) => ({ - appeal: state.appeal, - task: state.task, - decisionIssuesStatus: state.decisionIssuesStatus + appeal: state.nonComp.appeal, + task: state.nonComp.task, + decisionIssuesStatus: state.nonComp.decisionIssuesStatus }) )(NonCompDispositions); diff --git a/client/app/nonComp/components/NonCompLayout.jsx b/client/app/nonComp/components/NonCompLayout.jsx new file mode 100644 index 00000000000..84c9a5bd7af --- /dev/null +++ b/client/app/nonComp/components/NonCompLayout.jsx @@ -0,0 +1,23 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import AppSegment from '@department-of-veterans-affairs/caseflow-frontend-toolkit/components/AppSegment'; + +const NonCompLayout = ({ buttons, children }) => { + return ( +
+ +
+ {children} +
+
+ {buttons ? buttons : null} +
+ ); +}; + +NonCompLayout.propTypes = { + buttons: PropTypes.node, + children: PropTypes.node, +}; + +export default NonCompLayout; diff --git a/client/app/nonComp/components/NonCompTabs.jsx b/client/app/nonComp/components/NonCompTabs.jsx index 00878b8c83a..6350f5c69fe 100644 --- a/client/app/nonComp/components/NonCompTabs.jsx +++ b/client/app/nonComp/components/NonCompTabs.jsx @@ -21,7 +21,6 @@ const NonCompTabsUnconnected = (props) => { }; const isVhaBusinessLine = props.businessLineUrl === 'vha'; - const queryParams = new URLSearchParams(window.location.search); const currentTabName = queryParams.get(QUEUE_CONFIG.TAB_NAME_REQUEST_PARAM) || 'in_progress'; const defaultSortColumn = currentTabName === 'completed' ? 'completedDateColumn' : 'daysWaitingColumn'; @@ -87,7 +86,16 @@ const NonCompTabsUnconnected = (props) => { filter((key) => props.businessLineConfig.tabs.includes(key)). map((key) => ALL_TABS[key]); + const resetPageNumberOnTabChange = (value) => { + // If the user has selected a new tab then we should reset the pagination page to 0 + // This is to prevent situations where Viewing 31-45 of 1 total gets displayed and blocks user navigation + if (value !== getTabByIndex) { + tabPaginationOptions.page = 0; + } + }; + return ( resetPageNumberOnTabChange(value))} name="tasks-organization-queue" tabs={tabs} defaultPage={props.currentTab || getTabByIndex} @@ -112,11 +120,11 @@ NonCompTabsUnconnected.propTypes = { const NonCompTabs = connect( (state) => ({ - currentTab: state.currentTab, - baseTasksUrl: state.baseTasksUrl, - taskFilterDetails: state.taskFilterDetails, - businessLineUrl: state.businessLineUrl, - businessLineConfig: state.businessLineConfig, + currentTab: state.nonComp.currentTab, + baseTasksUrl: state.nonComp.baseTasksUrl, + taskFilterDetails: state.nonComp.taskFilterDetails, + businessLineUrl: state.nonComp.businessLineUrl, + businessLineConfig: state.nonComp.businessLineConfig, }) )(NonCompTabsUnconnected); diff --git a/client/app/nonComp/components/PowerOfAttorneyDecisionReview.jsx b/client/app/nonComp/components/PowerOfAttorneyDecisionReview.jsx index ee79eee59a8..1aa8a5ee700 100644 --- a/client/app/nonComp/components/PowerOfAttorneyDecisionReview.jsx +++ b/client/app/nonComp/components/PowerOfAttorneyDecisionReview.jsx @@ -14,19 +14,18 @@ import { getPoAValue } from '../actions/task'; * @returns {function} -- A function that selects the power of attorney from the Redux state. */ const powerOfAttorneyFromNonCompState = () => - (state) => { - return { + (state) => ( + { /* eslint-disable-next-line camelcase */ - appellantType: state.task?.appellant_type, + appellantType: state.nonComp.task?.appellant_type, /* eslint-disable-next-line camelcase */ - powerOfAttorney: state.task?.power_of_attorney, - loading: state?.loadingPowerOfAttorney?.loading, - error: state?.loadingPowerOfAttorney?.error, - poaAlert: state.poaAlert, - taskId: state.task?.id - }; - } - ; + powerOfAttorney: state.nonComp.task?.power_of_attorney, + loading: state.nonComp.loadingPowerOfAttorney?.loading, + error: state.nonComp.loadingPowerOfAttorney?.error, + poaAlert: state.nonComp.poaAlert, + taskId: state.nonComp.task?.id + } + ); /** * Wraps a component with logic to fetch the power of attorney data from the API. diff --git a/client/app/nonComp/components/RecordRequest.jsx b/client/app/nonComp/components/RecordRequest.jsx index 9298ba46661..8c6b4a0d907 100644 --- a/client/app/nonComp/components/RecordRequest.jsx +++ b/client/app/nonComp/components/RecordRequest.jsx @@ -98,10 +98,10 @@ RecordRequestUnconnected.propTypes = { const RecordRequest = connect( (state) => ({ - appeal: state.appeal, - businessLine: state.businessLine, - task: state.task, - decisionIssuesStatus: state.decisionIssuesStatus + appeal: state.nonComp.appeal, + businessLine: state.nonComp.businessLine, + task: state.nonComp.task, + decisionIssuesStatus: state.nonComp.decisionIssuesStatus }) )(RecordRequestUnconnected); diff --git a/client/app/nonComp/components/ReportPage/ConditionContainer.jsx b/client/app/nonComp/components/ReportPage/ConditionContainer.jsx new file mode 100644 index 00000000000..0242168f5b3 --- /dev/null +++ b/client/app/nonComp/components/ReportPage/ConditionContainer.jsx @@ -0,0 +1,96 @@ +import React, { useMemo } from 'react'; +import { useWatch, useFormContext } from 'react-hook-form'; +import { ConditionDropdown } from './ConditionDropdown'; +import Link from '@department-of-veterans-affairs/caseflow-frontend-toolkit/components/Link'; +import { DaysWaiting } from './Conditions/DaysWaiting'; +import { DecisionReviewType } from './Conditions/DecisionReviewType'; +import { IssueType } from './Conditions/IssueType'; +import { IssueDisposition } from './Conditions/IssueDisposition'; +import { Facility } from './Conditions/Facility'; +import { Personnel } from './Conditions/Personnel'; +import PropTypes from 'prop-types'; + +export const ConditionContainer = ({ control, index, remove, field }) => { + // this can't easily be extracted to somewhere else without breaking the form + const variableOptions = [ + { label: 'Days Waiting', + value: 'daysWaiting', + component: DaysWaiting }, + { label: 'Decision Review Type', + value: 'decisionReviewType', + component: DecisionReviewType }, + { label: 'Issue Type', + value: 'issueType', + component: IssueType }, + { label: 'Issue Disposition', + value: 'issueDisposition', + component: IssueDisposition }, + { label: 'Personnel', + value: 'personnel', + component: Personnel }, + { label: 'Facility', + value: 'facility', + component: Facility }, + ]; + + const { watch, register } = useFormContext(); + const conds = watch('conditions'); + + let selectedOptions = conds.map((cond) => cond.condition).filter((cond) => cond !== null); + + // personnel and facility are mutually exclusive + if (selectedOptions.includes('facility')) { + selectedOptions = selectedOptions.concat('personnel'); + } else if (selectedOptions.includes('personnel')) { + selectedOptions = selectedOptions.concat('facility'); + } + + const filteredOptions = variableOptions.filter((option) => + !selectedOptions.some((selectedOption) => option.value === selectedOption)); + + const name = `conditions.${index}`; + + const conditionsLength = useWatch({ name: 'conditions' }).length; + const shouldShowAnd = (conditionsLength > 1) && (index !== (conditionsLength - 1)); + const selectedConditionValue = useWatch({ control, name: `${name}.condition` }); + + const hasMiddleContent = selectedConditionValue && selectedConditionValue !== 'daysWaiting'; + const middleContentClassName = hasMiddleContent ? + 'report-page-variable-content' : + 'report-page-variable-content-wider'; + + const conditionContent = useMemo(() => { + const selectedVariableOption = variableOptions.find((opt) => opt.value === selectedConditionValue); + + if (!selectedConditionValue || !selectedVariableOption) { + return
; + } + + if (selectedVariableOption.component) { + const ConditionContent = selectedVariableOption.component; + + return ; + } + }, [control, name, register, selectedConditionValue, variableOptions]); + + return
+
+
+ +
+ {hasMiddleContent ?
including
: null} +
+ {conditionContent} +
+
+ remove(index)}>Remove condition + {shouldShowAnd ?
AND
: null} +
; +}; + +ConditionContainer.propTypes = { + control: PropTypes.object, + field: PropTypes.object, + index: PropTypes.number, + remove: PropTypes.func, +}; diff --git a/client/app/nonComp/components/ReportPage/ConditionDropdown.jsx b/client/app/nonComp/components/ReportPage/ConditionDropdown.jsx new file mode 100644 index 00000000000..2feccc46c4a --- /dev/null +++ b/client/app/nonComp/components/ReportPage/ConditionDropdown.jsx @@ -0,0 +1,41 @@ +import React, { useState } from 'react'; +import SearchableDropdown from 'app/components/SearchableDropdown'; +import { Controller, useFormContext } from 'react-hook-form'; +import PropTypes from 'prop-types'; +import { get } from 'lodash'; + +export const ConditionDropdown = ({ control, filteredOptions, name }) => { + let [disabled, setDisabled] = useState(false); + + const dropdownName = `${name}.condition`; + + const { errors } = useFormContext(); + + return ( + { + setDisabled(true); + onChange(valObj?.value); + }} + placeholder="Select a variable" + /> + )} + />; +}; + +ConditionDropdown.propTypes = { + control: PropTypes.object, + filteredOptions: PropTypes.array, + name: PropTypes.string, + errors: PropTypes.object +}; diff --git a/client/app/nonComp/components/ReportPage/Conditions/DaysWaiting.jsx b/client/app/nonComp/components/ReportPage/Conditions/DaysWaiting.jsx new file mode 100644 index 00000000000..4871ea809e9 --- /dev/null +++ b/client/app/nonComp/components/ReportPage/Conditions/DaysWaiting.jsx @@ -0,0 +1,105 @@ +import React from 'react'; +import SearchableDropdown from 'app/components/SearchableDropdown'; +import NumberField from 'app/components/NumberField'; +import styled from 'styled-components'; +import PropTypes from 'prop-types'; +import * as yup from 'yup'; + +import { Controller, useFormContext } from 'react-hook-form'; +import DAYS_WAITING_CONDITION_OPTIONS from 'constants/DAYS_WAITING_CONDITION_OPTIONS'; +import * as ERRORS from 'constants/REPORT_PAGE_VALIDATION_ERRORS'; +import { get } from 'lodash'; + +const WidthDiv = styled.div` + max-width: 45%; + width: 100% +`; + +export const daysWaitingSchema = yup.object({ + comparisonOperator: yup.string(). + oneOf(DAYS_WAITING_CONDITION_OPTIONS.map((cond) => cond.value), ERRORS.MISSING_TIME_RANGE), + valueOne: yup.number().typeError(ERRORS.MISSING_NUMBER). + required(ERRORS.MISSING_NUMBER), + valueTwo: yup.number().label('Max days'). + when('comparisonOperator', { + is: 'between', + then: (schema) => schema.typeError(ERRORS.MISSING_NUMBER). + moreThan(yup.ref('valueOne'), ERRORS.MAX_DAYS_TOO_SMALL). + required(ERRORS.MISSING_NUMBER), + otherwise: (schema) => schema.notRequired() + }) +}); + +export const DaysWaiting = ({ control, register, name, field }) => { + const dropdownName = `${name}.options.comparisonOperator`; + const valueOneName = `${name}.options.valueOne`; + const valueTwoName = `${name}.options.valueTwo`; + + const { setValue, errors } = useFormContext(); + + const displayValueOne = field.options.comparisonOperator; + + const displayValueTwo = field.options.comparisonOperator === 'between'; + + const valueOneLabel = displayValueTwo ? 'Min days' : 'Number of days'; + + return
+ + ( + { + setValue(valueOneName, null); + setValue(valueTwoName, null); + onChange(valObj?.value); + }} + /> + )} + /> + + {displayValueOne ? + { + setValue(valueOneName, value); + }} + /> : null} + {displayValueTwo ? + <> + to + { + setValue(valueTwoName, value); + }} + /> + : null} + +
; +}; + +DaysWaiting.propTypes = { + control: PropTypes.object, + register: PropTypes.func, + name: PropTypes.string, + field: PropTypes.object, + errors: PropTypes.object +}; diff --git a/client/app/nonComp/components/ReportPage/Conditions/DecisionReviewType.jsx b/client/app/nonComp/components/ReportPage/Conditions/DecisionReviewType.jsx new file mode 100644 index 00000000000..0cf4879d68c --- /dev/null +++ b/client/app/nonComp/components/ReportPage/Conditions/DecisionReviewType.jsx @@ -0,0 +1,69 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useFormContext } from 'react-hook-form'; +import Checkbox from 'app/components/Checkbox'; +import * as yup from 'yup'; +import { get } from 'lodash'; +import { AT_LEAST_ONE_OPTION } from 'constants/REPORT_PAGE_VALIDATION_ERRORS'; + +const CHECKBOX_OPTIONS = [ + { + label: 'Higher-Level Reviews', + name: 'HigherLevelReview' + }, + { + label: 'Supplemental Claims', + name: 'SupplementalClaim' + } +]; + +export const decisionReviewTypeSchema = yup.object({ + HigherLevelReview: yup.boolean(), + SupplementalClaim: yup.boolean(), +}).test('at-least-one-true', AT_LEAST_ONE_OPTION, (obj) => { + const atLeastOneTrue = Object.values(obj).some((value) => value === true); + + if (!atLeastOneTrue) { + return false; + } + + return true; +}); + +export const DecisionReviewType = ({ field, name, register }) => { + const { errors } = useFormContext(); + const hasFormErrors = get(errors, name); + + const classNames = hasFormErrors ? + 'decisionReviewTypeContainer decisionReviewTypeContainerError' : + 'decisionReviewTypeContainer'; + + const errorMessage = get(errors, name)?.options?.message; + + return ( +
+ {hasFormErrors ? +
{errorMessage}
: + null + } +
+ {CHECKBOX_OPTIONS.map((checkbox) => ( + + ))} +
+
+ ); +}; + +DecisionReviewType.propTypes = { + field: PropTypes.object, + name: PropTypes.string, + register: PropTypes.func +}; diff --git a/client/app/nonComp/components/ReportPage/Conditions/Facility.jsx b/client/app/nonComp/components/ReportPage/Conditions/Facility.jsx new file mode 100644 index 00000000000..5cbe0070cfb --- /dev/null +++ b/client/app/nonComp/components/ReportPage/Conditions/Facility.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Controller, useFormContext } from 'react-hook-form'; +import { get } from 'lodash'; +import * as yup from 'yup'; +import SearchableDropdown from 'app/components/SearchableDropdown'; +import BGS_FACILITY_CODES from 'app/../constants/BGS_FACILITY_CODES'; +import * as ERRORS from 'constants/REPORT_PAGE_VALIDATION_ERRORS'; + +export const facilitySchema = yup.object({ + facilityCodes: yup.array().min(1, ERRORS.AT_LEAST_ONE_OPTION) +}); + +// Convert to array and sort alphabetically by label +const formattedFacilityCodes = Object.entries(BGS_FACILITY_CODES).map((facility) => { + return { + value: facility[0], + label: facility[1] + }; +}). + sort((stringA, stringB) => stringA.label.localeCompare(stringB.label)); + +export const Facility = ({ control, field, name }) => { + const { errors } = useFormContext(); + + const fieldName = `${name}.options.facilityCodes`; + + return ( +
+ ( + + )} + /> +
+ ); +}; + +Facility.propTypes = { + control: PropTypes.object, + field: PropTypes.object, + name: PropTypes.string, +}; diff --git a/client/app/nonComp/components/ReportPage/Conditions/IssueDisposition.jsx b/client/app/nonComp/components/ReportPage/Conditions/IssueDisposition.jsx new file mode 100644 index 00000000000..f926b7ebb41 --- /dev/null +++ b/client/app/nonComp/components/ReportPage/Conditions/IssueDisposition.jsx @@ -0,0 +1,44 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Controller, useFormContext } from 'react-hook-form'; +import { get } from 'lodash'; +import * as yup from 'yup'; +import SearchableDropdown from 'app/components/SearchableDropdown'; +import * as ERRORS from 'constants/REPORT_PAGE_VALIDATION_ERRORS'; +import { ISSUE_DISPOSITION_LIST } from 'constants/REPORT_TYPE_CONSTANTS'; + +export const issueDispositionSchema = yup.object({ + issueDispositions: yup.array().min(1, ERRORS.AT_LEAST_ONE_OPTION) +}); + +export const IssueDisposition = ({ control, field, name }) => { + const { errors } = useFormContext(); + const fieldName = `${name}.options.issueDispositions`; + + return ( +
+ ( + + )} + /> +
+ ); +}; + +IssueDisposition.propTypes = { + control: PropTypes.object, + field: PropTypes.object, + name: PropTypes.string, +}; diff --git a/client/app/nonComp/components/ReportPage/Conditions/IssueType.jsx b/client/app/nonComp/components/ReportPage/Conditions/IssueType.jsx new file mode 100644 index 00000000000..fe016db0be7 --- /dev/null +++ b/client/app/nonComp/components/ReportPage/Conditions/IssueType.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Controller, useFormContext } from 'react-hook-form'; +import { get } from 'lodash'; +import * as yup from 'yup'; +import SearchableDropdown from 'app/components/SearchableDropdown'; +import ISSUE_CATEGORIES from 'constants/ISSUE_CATEGORIES'; +import * as ERRORS from 'constants/REPORT_PAGE_VALIDATION_ERRORS'; + +export const issueTypeSchema = yup.object({ + issueTypes: yup.array().min(1, ERRORS.AT_LEAST_ONE_OPTION) +}); + +const formattedIssueTypes = ISSUE_CATEGORIES.vha.map((issue) => { + return { + value: issue, + label: issue + }; +}). + sort((stringA, stringB) => stringA.label.localeCompare(stringB.label)); + +export const IssueType = ({ control, field, name }) => { + const { errors } = useFormContext(); + const nameIssueTypes = `${name}.options.issueTypes`; + + return ( +
+ ( + + )} + /> +
+ ); +}; + +IssueType.propTypes = { + control: PropTypes.object, + field: PropTypes.object, + name: PropTypes.string, +}; diff --git a/client/app/nonComp/components/ReportPage/Conditions/Personnel.jsx b/client/app/nonComp/components/ReportPage/Conditions/Personnel.jsx new file mode 100644 index 00000000000..8b65ca03275 --- /dev/null +++ b/client/app/nonComp/components/ReportPage/Conditions/Personnel.jsx @@ -0,0 +1,62 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { Controller, useFormContext } from 'react-hook-form'; +import { useSelector } from 'react-redux'; +import { object, array } from 'yup'; +import { get } from 'lodash'; + +import { MISSING_PERSONNEL } from 'constants/REPORT_PAGE_VALIDATION_ERRORS'; +import SearchableDropdown from 'app/components/SearchableDropdown'; + +export const personnelSchema = object({ + personnel: array().of(object()). + min(1, MISSING_PERSONNEL). + typeError(MISSING_PERSONNEL) +}); + +export const Personnel = ({ control, field, name }) => { + const { setValue, errors } = useFormContext(); + const teamMembers = useSelector((state) => state.orgUsers.users); + const businessLineUrl = useSelector((state) => state.nonComp.businessLineUrl); + const namePersonnel = `${name}.options.personnel`; + + const dropdownOptions = useMemo(() => { + return teamMembers.map((member) => ( + { + label: member.full_name, + value: member.css_id + } + )).sort((stringA, stringB) => stringA.label.localeCompare(stringB.label)); + }, [teamMembers]); + + return ( +
+ { + return ( + { + setValue(namePersonnel, valObj); + }} + errorMessage={get(errors, namePersonnel)?.message} + /> + ); + }} + /> +
+ ); +}; + +Personnel.propTypes = { + control: PropTypes.object, + name: PropTypes.string, + field: PropTypes.object, +}; diff --git a/client/app/nonComp/components/ReportPage/RHFControlledDropdown.jsx b/client/app/nonComp/components/ReportPage/RHFControlledDropdown.jsx new file mode 100644 index 00000000000..1b07d104350 --- /dev/null +++ b/client/app/nonComp/components/ReportPage/RHFControlledDropdown.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import PropTypes from 'prop-types'; + +import SearchableDropdown from 'app/components/SearchableDropdown'; + +const RHFControlledDropdown = ({ control, ...props }) => ( + <> +

{ props.header }

+ ( + { + onChange(valObj?.value); + }} + required={props.required} + optional={props.optional} + /> + )} + /> + +); + +export const RHFControlledDropdownContainer = (props) => { + const methods = useFormContext(); + + return ; +}; + +RHFControlledDropdown.propTypes = { + control: PropTypes.object, + header: PropTypes.string, + name: PropTypes.string, + label: PropTypes.string, + options: PropTypes.array, + optional: PropTypes.bool, + required: PropTypes.bool +}; + +export default RHFControlledDropdownContainer; diff --git a/client/app/nonComp/components/ReportPage/ReportPageConditions.jsx b/client/app/nonComp/components/ReportPage/ReportPageConditions.jsx new file mode 100644 index 00000000000..1a40ec4e575 --- /dev/null +++ b/client/app/nonComp/components/ReportPage/ReportPageConditions.jsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { useFormContext, useFieldArray } from 'react-hook-form'; + +import { ConditionContainer } from './ConditionContainer'; +import { personnelSchema } from './Conditions/Personnel'; +import Button from 'app/components/Button'; + +import * as yup from 'yup'; +import { daysWaitingSchema } from './Conditions/DaysWaiting'; +import { decisionReviewTypeSchema } from './Conditions/DecisionReviewType'; +import { facilitySchema } from './Conditions/Facility'; +import { issueDispositionSchema } from './Conditions/IssueDisposition'; +import * as ERRORS from 'constants/REPORT_PAGE_VALIDATION_ERRORS'; +import { issueTypeSchema } from './Conditions/IssueType'; + +const conditionOptionSchemas = { + daysWaiting: daysWaitingSchema, + decisionReviewType: decisionReviewTypeSchema, + facility: facilitySchema, + issueDisposition: issueDispositionSchema, + issueType: issueTypeSchema, + personnel: personnelSchema +}; + +export const conditionsSchema = yup.array().of( + yup.lazy((value) => { + return yup.object( + { condition: yup.string().typeError(ERRORS.MISSING_CONDITION). + oneOf(['daysWaiting', 'decisionReviewType', 'facility', 'issueDisposition', 'issueType', 'personnel']). + required(), + options: conditionOptionSchemas[value.condition] + }); + }) +); + +export const ReportPageConditions = () => { + const { control, watch } = useFormContext(); + const { fields, append, remove } = useFieldArray({ + control, + name: 'conditions', + defaultValues: [{ condition: '', options: {} }] + }); + + const watchFieldArray = watch('conditions'); + const controlledFields = fields.map((field, index) => { + return { + ...field, + ...watchFieldArray[index] + }; + }); + + return ( +
+
+ {/* Update margin depending on the presence of controlledField elements */} +

Conditions

+ {controlledFields.map((field, index) => { + return ; + })} + +
+ ); +}; diff --git a/client/app/nonComp/components/ReportPage/ReportPageDateSelector.jsx b/client/app/nonComp/components/ReportPage/ReportPageDateSelector.jsx new file mode 100644 index 00000000000..16194146513 --- /dev/null +++ b/client/app/nonComp/components/ReportPage/ReportPageDateSelector.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { useController } from 'react-hook-form'; +import PropTypes from 'prop-types'; + +import DateSelector from 'app/components/DateSelector'; +import { marginTop } from 'app/hearings/components/details/style'; + +const ReportPageDateSelector = ({ name, label, control, errorMessage }) => { + const { field } = useController({ + control, + name, + label + }); + + return ( +
+
+ { + field.onChange(val); + }} + type="date" + noFutureDates + inputStyling={marginTop('0 !important')} + errorMessage={errorMessage} + /> +
+
+ ); +}; + +ReportPageDateSelector.propTypes = { + name: PropTypes.string, + label: PropTypes.string, + control: PropTypes.object, + errorMessage: PropTypes.string +}; + +export default ReportPageDateSelector; diff --git a/client/app/nonComp/components/ReportPage/TimingSpecification.jsx b/client/app/nonComp/components/ReportPage/TimingSpecification.jsx new file mode 100644 index 00000000000..fe113e84236 --- /dev/null +++ b/client/app/nonComp/components/ReportPage/TimingSpecification.jsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { useFormContext } from 'react-hook-form'; +import PropTypes from 'prop-types'; + +import RHFControlledDropdownContainer from 'app/nonComp/components/ReportPage/RHFControlledDropdown'; +import ReportPageDateSelector from 'app/nonComp/components/ReportPage/ReportPageDateSelector'; + +import { TIMING_SPECIFIC_OPTIONS } from 'constants/REPORT_TYPE_CONSTANTS'; +import { format, add } from 'date-fns'; + +import * as ERRORS from 'constants/REPORT_PAGE_VALIDATION_ERRORS'; + +import * as yup from 'yup'; + +export const timingSchema = yup.lazy((value) => { + // eslint-disable-next-line no-undefined + if (value !== undefined) { + return yup.object({ + startDate: yup.date(). + when('range', { + is: (range) => ['after', 'before', 'between'].includes(range), + then: yup.date().typeError(ERRORS.MISSING_DATE). + max(format(add(new Date(), { hours: 1 }), 'MM/dd/yyyy'), ERRORS.DATE_FUTURE), + otherwise: (schema) => schema.notRequired() + }), + endDate: yup.date(). + when('range', { + is: 'between', + then: yup.date().typeError(ERRORS.MISSING_DATE). + max(format(add(new Date(), { hours: 1 }), 'MM/dd/yyyy'), ERRORS.DATE_FUTURE). + min(yup.ref('startDate'), ERRORS.END_DATE_SMALL), + otherwise: (schema) => schema.notRequired() + }) + }); + } + + return yup.mixed().notRequired(); +}); + +export const TimingSpecification = () => { + const { watch, control, formState } = useFormContext(); + const watchTimingSpecification = watch('timing.range'); + const isTimingsSpecificationBetween = watchTimingSpecification === 'between'; + + return ( +
+
+ + { + ['after', 'before', 'between'].includes(watchTimingSpecification) ? + : + null + } + { + isTimingsSpecificationBetween ? + : + null + } +
+ ); +}; + +TimingSpecification.propTypes = { + control: PropTypes.object, + watchTimingSpecification: PropTypes.string +}; +export default TimingSpecification; diff --git a/client/app/nonComp/components/TaskTableTab.jsx b/client/app/nonComp/components/TaskTableTab.jsx index 14cb9764d69..fb809f3fccf 100644 --- a/client/app/nonComp/components/TaskTableTab.jsx +++ b/client/app/nonComp/components/TaskTableTab.jsx @@ -156,7 +156,7 @@ TaskTableTabUnconnected.propTypes = { const TaskTableTab = connect( (state) => ({ - featureToggles: state.featureToggles + featureToggles: state.nonComp.featureToggles }), )(TaskTableTabUnconnected); diff --git a/client/app/nonComp/index.jsx b/client/app/nonComp/index.jsx index bb5034bcff8..a9a41ff526b 100644 --- a/client/app/nonComp/index.jsx +++ b/client/app/nonComp/index.jsx @@ -5,14 +5,13 @@ import NavigationBar from '../components/NavigationBar'; import { BrowserRouter } from 'react-router-dom'; import PageRoute from '../components/PageRoute'; import AppFrame from '../components/AppFrame'; -import AppSegment from '@department-of-veterans-affairs/caseflow-frontend-toolkit/components/AppSegment'; import { LOGO_COLORS } from '../constants/AppConstants'; import Footer from '@department-of-veterans-affairs/caseflow-frontend-toolkit/components/Footer'; import { FlashAlerts } from './components/Alerts'; - import ReviewPage from './pages/ReviewPage'; import TaskPage from './pages/TaskPage'; -import { nonCompReducer, mapDataToInitialState } from './reducers'; +import ReportPage from './pages/ReportPage'; +import CombinedNonCompReducer, { mapDataToInitialState } from './reducers'; class NonComp extends React.PureComponent { render() { @@ -21,7 +20,7 @@ class NonComp extends React.PureComponent { const appName = this.props.serverNonComp.businessLine; return ( - +
- - {this.props.flash && } -
- - -
-
+ {this.props.flash && } + + +