From c0dc2e7ef1dbff6968481a2e26f9e28b2c38761f Mon Sep 17 00:00:00 2001 From: Sampo Tawast <5328394+sirtawast@users.noreply.github.com> Date: Tue, 17 Sep 2024 10:58:20 +0300 Subject: [PATCH] feat: find related applications for a single employee (hl-1354) (#3321) * test: change test name that didn't reflect the test * feat(backend): search related applications by using app id * test: add case for searching related applications with app_no * feat(handler): ui to search related applications --- .../applications/api/v1/search_views.py | 41 +++++++ .../tests/test_application_search.py | 58 ++++++++++ ...st_command_import_archival_applications.py | 2 +- .../handler/public/locales/en/common.json | 4 +- .../handler/public/locales/fi/common.json | 4 +- .../handler/public/locales/sv/common.json | 4 +- .../employeeView/EmployeeView.tsx | 17 +++ .../ApplicationsArchive.tsx | 102 ++++++++++++++---- .../useApplicationsArchive.ts | 6 +- .../src/hooks/useSearchApplicationQuery.ts | 16 ++- 10 files changed, 224 insertions(+), 30 deletions(-) diff --git a/backend/benefit/applications/api/v1/search_views.py b/backend/benefit/applications/api/v1/search_views.py index 8b1e27f09a..c49b94dd3d 100755 --- a/backend/benefit/applications/api/v1/search_views.py +++ b/backend/benefit/applications/api/v1/search_views.py @@ -59,6 +59,7 @@ def get(self, request): archived = request.query_params.get("archived") == "1" or False search_from_archival = request.query_params.get("archival") == "1" or False + application_number = request.query_params.get("app_no") subsidy_in_effect = request.query_params.get("subsidy_in_effect") @@ -98,6 +99,7 @@ def get(self, request): search_string, in_memory_filter_str, detected_pattern, + application_number, search_from_archival, ) @@ -160,8 +162,16 @@ def search_applications( search_string, in_memory_filter_str, detected_pattern, + application_number=None, search_from_archival=False, ) -> Response: + if application_number: + querysets = _query_by_application_number( + application_queryset, archival_application_queryset, application_number + ) + application_queryset = querysets["application_queryset"] + archival_application_queryset = querysets["archival_application_queryset"] + if search_string == "" and in_memory_filter_str == "": return _query_and_respond_to_empty_search( application_queryset, archival_application_queryset @@ -290,6 +300,37 @@ def _get_filter_combinations(app): ] +def _query_by_application_number( + application_queryset, archival_application_queryset, application_number +): + app = Application.objects.filter(application_number=application_number).first() + + # Get year of birth from SSN, assume no-one is seeking for a job before 1900's + year_suffix = app.employee.social_security_number[4:6] + ssn_separator = app.employee.social_security_number[6:7] + ssn_separators_born_in_2000s = ["A", "B", "C", "D", "E", "F"] + year_prefix = 20 if ssn_separator in ssn_separators_born_in_2000s else 19 + + return { + "application_queryset": application_queryset.filter( + employee__social_security_number=app.employee.social_security_number, + company__business_id=app.company.business_id, + ), + "archival_application_queryset": archival_application_queryset.filter( + Q( + employee_first_name=app.employee.first_name, + employee_last_name=app.employee.last_name, + ) + & ( + Q(year_of_birth=f"{year_prefix}{year_suffix}") + | Q( + year_of_birth="1900" + ) # A few ArchivalApplication do not have birth year and is marked as 1900 + ), + ), + } + + def _query_and_respond_to_empty_search( application_queryset, archival_application_queryset ): diff --git a/backend/benefit/applications/tests/test_application_search.py b/backend/benefit/applications/tests/test_application_search.py index 88c387deb7..4eff023442 100755 --- a/backend/benefit/applications/tests/test_application_search.py +++ b/backend/benefit/applications/tests/test_application_search.py @@ -313,3 +313,61 @@ def test_search_archival_application(handler_api_client, q, detected_pattern): assert match["employee"]["last_name"] == searched_app.employee_last_name assert match["company"]["business_id"] == searched_app.company.business_id assert match["company"]["name"] == searched_app.company.name + + +@pytest.mark.parametrize( + "app_no", + [ + (123456), + ], +) +def test_search_related_applications(handler_api_client, app_no, application): + ImportArchivalApplicationsTestUtility.create_companies_for_archival_applications() + call_command("import_archival_applications", filename="test.xlsx", production=True) + archival_application = ArchivalApplication.objects.get(application_number="R001") + archival_application.refresh_from_db() + + application.employee.first_name = str(archival_application.employee_first_name) + application.employee.last_name = str(archival_application.employee_last_name) + application.employee.social_security_number = "010192-9906" + application.employee.save() + application.application_number = app_no + application.status = ApplicationStatus.ACCEPTED + application.archived = True + application.save() + application.refresh_from_db() + + params = urlencode( + {"q": "", "archived": 1, "archival": 1, "app_no": app_no}, + ) + + response = handler_api_client.get(f"{api_url}?{params}") + data = response.json() + assert len(data["matches"]) == 2 + for found_application in data["matches"]: + assert ( + found_application["application_number"] == app_no + or archival_application.application_number + ) + assert ( + found_application["employee"]["first_name"] + == archival_application.employee_first_name + ) + assert ( + found_application["employee"]["last_name"] + == archival_application.employee_last_name + ) + + # Should find everyone with unspecified year_of_birth + archival_application.year_of_birth = "1900" + archival_application.save() + response = handler_api_client.get(f"{api_url}?{params}") + data = response.json() + assert len(data["matches"]) == 2 + + # Should not find anyone with wrong year_of_birth + archival_application.year_of_birth = "1991" + archival_application.save() + response = handler_api_client.get(f"{api_url}?{params}") + data = response.json() + assert len(data["matches"]) == 1 diff --git a/backend/benefit/applications/tests/test_command_import_archival_applications.py b/backend/benefit/applications/tests/test_command_import_archival_applications.py index 7c2462ceb5..0fa11e41c3 100644 --- a/backend/benefit/applications/tests/test_command_import_archival_applications.py +++ b/backend/benefit/applications/tests/test_command_import_archival_applications.py @@ -142,7 +142,7 @@ def create_companies_for_archival_applications(): company.save() -def test_decision_proposal_drafting(): +def test_import_archival_applications(): assert ArchivalApplication.objects.all().count() == 0 ImportArchivalApplicationsTestUtility.create_companies_for_archival_applications() diff --git a/frontend/benefit/handler/public/locales/en/common.json b/frontend/benefit/handler/public/locales/en/common.json index f41c1d2999..449abe4fea 100644 --- a/frontend/benefit/handler/public/locales/en/common.json +++ b/frontend/benefit/handler/public/locales/en/common.json @@ -1009,6 +1009,7 @@ "saveAndContinue": "Tallenna ja sulje", "backToHandling": "Palauta käsittelyyn", "handlingPanel": "Käsittelypaneeli", + "search": "Hae arkistosta", "cancel": "Peruuta hakemus", "addAttachment": "Liitä uusi tiedosto", "addPreviouslyGrantedBenefit": "Lisää aikaisempi lisä", @@ -1035,7 +1036,8 @@ "description": "Perustelu", "acceptedSubsidy": "Myönnettävä tuki", "eurosTotal": "{{total}} euroa yhteensä", - "eurosPerMonth": "{{euros}} euroa kuukaudessa {{dateRange}}" + "eurosPerMonth": "{{euros}} euroa kuukaudessa {{dateRange}}", + "searchPriorApplications": "Hae työllistettävän aiempia tukia" }, "headings": { "heading1": "Työnantajan tiedot", diff --git a/frontend/benefit/handler/public/locales/fi/common.json b/frontend/benefit/handler/public/locales/fi/common.json index da5e173deb..441628c448 100644 --- a/frontend/benefit/handler/public/locales/fi/common.json +++ b/frontend/benefit/handler/public/locales/fi/common.json @@ -1009,6 +1009,7 @@ "saveAndContinue": "Tallenna ja sulje", "backToHandling": "Palauta käsittelyyn", "handlingPanel": "Käsittelypaneeli", + "search": "Hae arkistosta", "cancel": "Peruuta hakemus", "addAttachment": "Liitä uusi tiedosto", "addPreviouslyGrantedBenefit": "Lisää aikaisempi lisä", @@ -1035,7 +1036,8 @@ "description": "Perustelu", "acceptedSubsidy": "Myönnettävä tuki", "eurosTotal": "{{total}} euroa yhteensä", - "eurosPerMonth": "{{euros}} euroa kuukaudessa {{dateRange}}" + "eurosPerMonth": "{{euros}} euroa kuukaudessa {{dateRange}}", + "searchPriorApplications": "Hae työllistettävän aiempia tukia" }, "headings": { "heading1": "Työnantajan tiedot", diff --git a/frontend/benefit/handler/public/locales/sv/common.json b/frontend/benefit/handler/public/locales/sv/common.json index f41c1d2999..449abe4fea 100644 --- a/frontend/benefit/handler/public/locales/sv/common.json +++ b/frontend/benefit/handler/public/locales/sv/common.json @@ -1009,6 +1009,7 @@ "saveAndContinue": "Tallenna ja sulje", "backToHandling": "Palauta käsittelyyn", "handlingPanel": "Käsittelypaneeli", + "search": "Hae arkistosta", "cancel": "Peruuta hakemus", "addAttachment": "Liitä uusi tiedosto", "addPreviouslyGrantedBenefit": "Lisää aikaisempi lisä", @@ -1035,7 +1036,8 @@ "description": "Perustelu", "acceptedSubsidy": "Myönnettävä tuki", "eurosTotal": "{{total}} euroa yhteensä", - "eurosPerMonth": "{{euros}} euroa kuukaudessa {{dateRange}}" + "eurosPerMonth": "{{euros}} euroa kuukaudessa {{dateRange}}", + "searchPriorApplications": "Hae työllistettävän aiempia tukia" }, "headings": { "heading1": "Työnantajan tiedot", diff --git a/frontend/benefit/handler/src/components/applicationReview/employeeView/EmployeeView.tsx b/frontend/benefit/handler/src/components/applicationReview/employeeView/EmployeeView.tsx index 4a63766a2c..49f6db56c8 100644 --- a/frontend/benefit/handler/src/components/applicationReview/employeeView/EmployeeView.tsx +++ b/frontend/benefit/handler/src/components/applicationReview/employeeView/EmployeeView.tsx @@ -6,6 +6,7 @@ import ReviewSection from 'benefit/handler/components/reviewSection/ReviewSectio import { ACTIONLESS_STATUSES } from 'benefit/handler/constants'; import { ApplicationReviewViewProps } from 'benefit/handler/types/application'; import { ATTACHMENT_TYPES, ORGANIZATION_TYPES } from 'benefit-shared/constants'; +import { Button, IconSearch } from 'hds-react'; import { useTranslation } from 'next-i18next'; import * as React from 'react'; import { $GridCell } from 'shared/components/forms/section/FormSection.sc'; @@ -13,6 +14,11 @@ import { getFullName } from 'shared/utils/application.utils'; import AttachmentsListView from '../../attachmentsListView/AttachmentsListView'; +const openSearchPage = (id: string) => (): void => { + // eslint-disable-next-line security/detect-non-literal-fs-filename + window.open(`/archive/?appNo=${id}`); +}; + const EmployeeView: React.FC = ({ data }) => { const translationsBase = 'common:review'; const { t } = useTranslation(); @@ -63,6 +69,17 @@ const EmployeeView: React.FC = ({ data }) => { attachments={data.attachments || []} /> + + <$GridCell $colSpan={6} $colStart={1}> + + ); }; diff --git a/frontend/benefit/handler/src/components/applicationsArchive/ApplicationsArchive.tsx b/frontend/benefit/handler/src/components/applicationsArchive/ApplicationsArchive.tsx index b167cd6e98..b70ab47606 100644 --- a/frontend/benefit/handler/src/components/applicationsArchive/ApplicationsArchive.tsx +++ b/frontend/benefit/handler/src/components/applicationsArchive/ApplicationsArchive.tsx @@ -1,4 +1,13 @@ -import { RadioButton, SearchInput, SelectionGroup } from 'hds-react'; +import { ROUTES } from 'benefit/handler/constants'; +import { + IconCross, + RadioButton, + SearchInput, + SelectionGroup, + StatusLabel, +} from 'hds-react'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; import * as React from 'react'; import Container from 'shared/components/container/Container'; import Heading from 'shared/components/forms/heading/Heading'; @@ -6,6 +15,7 @@ import { $Grid, $GridCell, } from 'shared/components/forms/section/FormSection.sc'; +import styled from 'styled-components'; import ApplicationArchiveList from './ApplicationArchiveList'; import { $Heading } from './ApplicationsArchive.sc'; @@ -16,9 +26,25 @@ import { useApplicationsArchive, } from './useApplicationsArchive'; +const $SearchInputArea = styled.div` + max-width: 630px; + .custom-status-label { + font-size: 18px; + padding: 0.5em 1em; + margin-bottom: 2em; + display: flex; + align-items: center; + + a { + display: flex; + align-items: center; + } + } +`; + const ApplicationsArchive: React.FC = () => { const [searchString, setSearchString] = React.useState(''); - + const [initialQuery, setInitialQuery] = React.useState(true); const [subsidyInEffect, setSubsidyInEffect] = React.useState( SUBSIDY_IN_EFFECT.RANGE_THREE_YEARS @@ -30,13 +56,16 @@ const ApplicationsArchive: React.FC = () => { FILTER_SELECTION.SUBSIDY_IN_EFFECT_RANGE_THREE_YEARS ); + const router = useRouter(); + const applicationNum = router?.query?.appNo || null; const { t, isSearchLoading, searchResults, submitSearch } = useApplicationsArchive( searchString, true, true, subsidyInEffect, - decisionRange + decisionRange, + applicationNum ? applicationNum.toString() : null ); const onSearch = (value: string): void => { @@ -44,11 +73,6 @@ const ApplicationsArchive: React.FC = () => { submitSearch(value); }; - React.useEffect(() => { - submitSearch(searchString); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filterSelection]); - const handleSubsidyFilterChange = ( selection: FILTER_SELECTION, value?: SUBSIDY_IN_EFFECT @@ -71,27 +95,61 @@ const ApplicationsArchive: React.FC = () => { setFilterSelection(FILTER_SELECTION.NO_FILTER); }; + React.useEffect(() => { + if (!router || !router.isReady) return; + if (applicationNum && initialQuery) { + handleFiltersOff(); + setInitialQuery(false); + } else if (!isSearchLoading) { + submitSearch(searchString); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filterSelection, applicationNum, router, initialQuery]); + return ( <$Heading as="h1" data-testid="main-ingress">{`${t( 'common:header.navigation.archive' )}`} <> -
- setSearchString(value)} - onSubmit={(value) => onSearch(value)} - css="margin-bottom: var(--spacing-m);" - /> -
+ <$SearchInputArea> + {!applicationNum && ( + setSearchString(value)} + onSubmit={(value) => onSearch(value)} + css="margin-bottom: var(--spacing-m);" + /> + )} + {applicationNum && ( +
+
+ +
+ Haetaan aiempia työsuhteita hakemuksen{' '} + {applicationNum} perusteella +
+ + + +
+
+
+ )} + <$Grid> <$GridCell $colSpan={6}> { const { t } = useTranslation(); @@ -93,7 +94,8 @@ const useApplicationsArchive = ( archived, includeArchivalApplications, subsidyInEffect, - decisionRange + decisionRange, + applicationNum ); const shouldHideList = diff --git a/frontend/benefit/handler/src/hooks/useSearchApplicationQuery.ts b/frontend/benefit/handler/src/hooks/useSearchApplicationQuery.ts index 1560d13a7b..8d99c8c7b3 100644 --- a/frontend/benefit/handler/src/hooks/useSearchApplicationQuery.ts +++ b/frontend/benefit/handler/src/hooks/useSearchApplicationQuery.ts @@ -15,12 +15,20 @@ const useSearchApplicationQuery = ( archived = false, includeArchivalApplications = false, subsidyInEffect?: SUBSIDY_IN_EFFECT, - decisionRange?: DECISION_RANGE + decisionRange?: DECISION_RANGE, + applicationNum?: string ): UseMutationResult => { const { axios, handleResponse } = useBackendAPI(); const { t } = useTranslation(); - const params = { + const params: { + q: string; + archived?: string; + archival?: string; + subsidy_in_effect?: SUBSIDY_IN_EFFECT; + years_since_decision?: DECISION_RANGE; + app_no?: string; + } = { q, ...(archived && { archived: '1' }), ...(includeArchivalApplications && { archival: '1' }), @@ -28,6 +36,10 @@ const useSearchApplicationQuery = ( ...(decisionRange && { years_since_decision: decisionRange }), }; + if (applicationNum) { + params.app_no = applicationNum; + } + const handleError = (): void => { showErrorToast( t('common:applications.list.errors.fetch.label'),