diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index c8eb3d452c..7a9b75d3d3 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -17,7 +17,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v3 with: - node-version: '16' + node-version: "18" - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn config get cacheFolder)" diff --git a/.github/workflows/bf-applicant-frontend-tests.yml b/.github/workflows/bf-applicant-frontend-tests.yml index 251c268d96..6c3326332f 100644 --- a/.github/workflows/bf-applicant-frontend-tests.yml +++ b/.github/workflows/bf-applicant-frontend-tests.yml @@ -25,7 +25,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v3 with: - node-version: "16" + node-version: "18" - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn config get cacheFolder)" @@ -52,7 +52,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v3 with: - node-version: "16" + node-version: "18" - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn config get cacheFolder)" @@ -68,4 +68,4 @@ jobs: - name: Check that building dev application works env: NEXTJS_DISABLE_SENTRY: true - run: yarn build + run: NODE_OPTIONS=--openssl-legacy-provider yarn build diff --git a/.github/workflows/bf-handler-frontend-tests.yml b/.github/workflows/bf-handler-frontend-tests.yml index 0c50506891..9596a69797 100644 --- a/.github/workflows/bf-handler-frontend-tests.yml +++ b/.github/workflows/bf-handler-frontend-tests.yml @@ -25,7 +25,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v3 with: - node-version: "16" + node-version: "18" - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn config get cacheFolder)" @@ -52,7 +52,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v3 with: - node-version: "16" + node-version: "18" - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn config get cacheFolder)" @@ -68,4 +68,4 @@ jobs: - name: Check that building dev application works env: NEXTJS_DISABLE_SENTRY: true - run: yarn build + run: NODE_OPTIONS=--openssl-legacy-provider yarn build diff --git a/.github/workflows/bf-review.yml b/.github/workflows/bf-review.yml index 8ef45cf3a7..bcb6647e5e 100644 --- a/.github/workflows/bf-review.yml +++ b/.github/workflows/bf-review.yml @@ -238,7 +238,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v3 with: - node-version: "16" + node-version: "18" - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn config get cacheFolder)" diff --git a/.github/workflows/bf-shared-frontend-tests.yml b/.github/workflows/bf-shared-frontend-tests.yml index 03f5dfa07d..6cfa4d0f96 100644 --- a/.github/workflows/bf-shared-frontend-tests.yml +++ b/.github/workflows/bf-shared-frontend-tests.yml @@ -24,7 +24,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v3 with: - node-version: "16" + node-version: "18" - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn config get cacheFolder)" diff --git a/.github/workflows/ks-empl-frontend-tests.yml b/.github/workflows/ks-empl-frontend-tests.yml index dfb9bc36fb..b6e75f0e5a 100644 --- a/.github/workflows/ks-empl-frontend-tests.yml +++ b/.github/workflows/ks-empl-frontend-tests.yml @@ -2,14 +2,14 @@ name: (KS Employer) Frontend Lint, Unit and Component tests on: push: - branches: [ develop, main ] + branches: [develop, main] pull_request: paths: - - 'frontend/shared/**' - - 'frontend/kesaseteli/shared/**' - - 'frontend/kesaseteli/employer/**' - - 'frontend/*' - - '.github/workflows/ks-empl-frontend-tests.yml' + - "frontend/shared/**" + - "frontend/kesaseteli/shared/**" + - "frontend/kesaseteli/employer/**" + - "frontend/*" + - ".github/workflows/ks-empl-frontend-tests.yml" - "!**/browser-tests/**" - "!**/README.md" workflow_dispatch: @@ -25,7 +25,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v3 with: - node-version: '16' + node-version: "18" - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn config get cacheFolder)" @@ -63,7 +63,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v3 with: - node-version: '16' + node-version: "18" - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn config get cacheFolder)" @@ -79,7 +79,7 @@ jobs: - name: Check that building dev application works env: NEXTJS_DISABLE_SENTRY: true - run: yarn build + run: NODE_OPTIONS=--openssl-legacy-provider yarn build - name: Frontend build failure slack notification uses: rtCamp/action-slack-notify@v2 env: diff --git a/.github/workflows/ks-handler-frontend-tests.yml b/.github/workflows/ks-handler-frontend-tests.yml index d15e7da6a3..aae9af617d 100644 --- a/.github/workflows/ks-handler-frontend-tests.yml +++ b/.github/workflows/ks-handler-frontend-tests.yml @@ -2,14 +2,14 @@ name: (KS Handler) Frontend Lint, Unit and Component tests on: push: - branches: [ develop, main ] + branches: [develop, main] pull_request: paths: - - 'frontend/shared/**' - - 'frontend/kesaseteli/shared/**' - - 'frontend/kesaseteli/handler/**' - - 'frontend/*' - - '.github/workflows/ks-handler-frontend-tests.yml' + - "frontend/shared/**" + - "frontend/kesaseteli/shared/**" + - "frontend/kesaseteli/handler/**" + - "frontend/*" + - ".github/workflows/ks-handler-frontend-tests.yml" - "!**/browser-tests/**" - "!**/README.md" workflow_dispatch: @@ -25,7 +25,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v3 with: - node-version: '16' + node-version: "18" - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn config get cacheFolder)" @@ -63,7 +63,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v3 with: - node-version: '16' + node-version: "18" - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn config get cacheFolder)" @@ -79,7 +79,7 @@ jobs: - name: Check that building dev application works env: NEXTJS_DISABLE_SENTRY: true - run: yarn build + run: NODE_OPTIONS=--openssl-legacy-provider yarn build - name: Frontend build failure slack notification uses: rtCamp/action-slack-notify@v2 env: diff --git a/.github/workflows/ks-review.yml b/.github/workflows/ks-review.yml index 49151e01d6..a4588457b2 100644 --- a/.github/workflows/ks-review.yml +++ b/.github/workflows/ks-review.yml @@ -256,7 +256,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v3 with: - node-version: '16' + node-version: "18" - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn config get cacheFolder)" diff --git a/.github/workflows/ks-shared-frontend-tests.yml b/.github/workflows/ks-shared-frontend-tests.yml index 272e4af157..39341ccd38 100644 --- a/.github/workflows/ks-shared-frontend-tests.yml +++ b/.github/workflows/ks-shared-frontend-tests.yml @@ -24,7 +24,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v3 with: - node-version: '16' + node-version: "18" - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn config get cacheFolder)" diff --git a/.github/workflows/ks-youth-frontend-tests.yml b/.github/workflows/ks-youth-frontend-tests.yml index 1bc22fb856..8ba667154d 100644 --- a/.github/workflows/ks-youth-frontend-tests.yml +++ b/.github/workflows/ks-youth-frontend-tests.yml @@ -2,14 +2,14 @@ name: (KS Youth) Frontend Lint, Unit and Component tests on: push: - branches: [ develop, main ] + branches: [develop, main] pull_request: paths: - - 'frontend/shared/**' - - 'frontend/kesaseteli/shared/**' - - 'frontend/kesaseteli/youth/**' - - 'frontend/*' - - '.github/workflows/ks-youth-frontend-tests.yml' + - "frontend/shared/**" + - "frontend/kesaseteli/shared/**" + - "frontend/kesaseteli/youth/**" + - "frontend/*" + - ".github/workflows/ks-youth-frontend-tests.yml" - "!**/browser-tests/**" - "!**/README.md" workflow_dispatch: @@ -25,7 +25,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v3 with: - node-version: '16' + node-version: "18" - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn config get cacheFolder)" @@ -63,7 +63,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v3 with: - node-version: '16' + node-version: "18" - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn config get cacheFolder)" @@ -79,7 +79,7 @@ jobs: - name: Check that building dev application works env: NEXTJS_DISABLE_SENTRY: true - run: yarn build + run: NODE_OPTIONS=--openssl-legacy-provider yarn build - name: Frontend build failure slack notification uses: rtCamp/action-slack-notify@v2 env: diff --git a/.github/workflows/shared-frontend-tests.yml b/.github/workflows/shared-frontend-tests.yml index 1b6faeeaf0..45317f81bc 100644 --- a/.github/workflows/shared-frontend-tests.yml +++ b/.github/workflows/shared-frontend-tests.yml @@ -23,7 +23,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v3 with: - node-version: '16' + node-version: "18" - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn config get cacheFolder)" diff --git a/.github/workflows/te-admn-frontend-tests.yml b/.github/workflows/te-admn-frontend-tests.yml index 227d992fcf..9b8b1a4143 100644 --- a/.github/workflows/te-admn-frontend-tests.yml +++ b/.github/workflows/te-admn-frontend-tests.yml @@ -2,13 +2,13 @@ name: (TET) Admin Frontend Lint, Unit and Component tests on: push: - branches: [ develop, main ] + branches: [develop, main] pull_request: paths: - - 'frontend/shared/**' - - 'frontend/tet/admin/**' - - 'frontend/*' - - '.github/workflows/te-admn-frontend-tests.yml' + - "frontend/shared/**" + - "frontend/tet/admin/**" + - "frontend/*" + - ".github/workflows/te-admn-frontend-tests.yml" - "!**/browser-tests/**" - "!**/README.md" workflow_dispatch: @@ -24,7 +24,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v3 with: - node-version: '16' + node-version: "18" - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn config get cacheFolder)" @@ -62,7 +62,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v3 with: - node-version: '16' + node-version: "18" - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn config get cacheFolder)" @@ -78,7 +78,7 @@ jobs: - name: Check that building dev application works env: NEXTJS_DISABLE_SENTRY: true - run: yarn build + run: NODE_OPTIONS=--openssl-legacy-provider yarn build - name: Frontend build failure slack notification uses: rtCamp/action-slack-notify@v2 env: diff --git a/.github/workflows/te-review.yml b/.github/workflows/te-review.yml index a1fee5245a..da23cec1e2 100644 --- a/.github/workflows/te-review.yml +++ b/.github/workflows/te-review.yml @@ -247,7 +247,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v3 with: - node-version: '16' + node-version: "18" - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn config get cacheFolder)" diff --git a/.github/workflows/te-shared-frontend-tests.yml b/.github/workflows/te-shared-frontend-tests.yml index 1a38d7038e..6700d9c690 100644 --- a/.github/workflows/te-shared-frontend-tests.yml +++ b/.github/workflows/te-shared-frontend-tests.yml @@ -24,7 +24,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v3 with: - node-version: '16' + node-version: "18" - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn config get cacheFolder)" diff --git a/.github/workflows/te-yout-frontend-tests.yml b/.github/workflows/te-yout-frontend-tests.yml index 65592e4390..989b9b13ff 100644 --- a/.github/workflows/te-yout-frontend-tests.yml +++ b/.github/workflows/te-yout-frontend-tests.yml @@ -2,13 +2,13 @@ name: (TET) Youth Frontend Lint, Unit and Component tests on: push: - branches: [ develop, main ] + branches: [develop, main] pull_request: paths: - - 'frontend/shared/**' - - 'frontend/*' - - 'frontend/tet/**' - - '.github/workflows/te-yout-frontend-tests.yml' + - "frontend/shared/**" + - "frontend/*" + - "frontend/tet/**" + - ".github/workflows/te-yout-frontend-tests.yml" - "!**/browser-tests/**" - "!**/README.md" workflow_dispatch: @@ -24,7 +24,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v3 with: - node-version: '16' + node-version: "18" - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn config get cacheFolder)" @@ -62,7 +62,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v3 with: - node-version: '16' + node-version: "18" - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn config get cacheFolder)" @@ -78,7 +78,7 @@ jobs: - name: Check that building dev application works env: NEXTJS_DISABLE_SENTRY: true - run: yarn build + run: NODE_OPTIONS=--openssl-legacy-provider yarn build - name: Frontend build failure slack notification uses: rtCamp/action-slack-notify@v2 env: diff --git a/.github/workflows/yarn-audit-scheduled.yml b/.github/workflows/yarn-audit-scheduled.yml index 0c567a606c..ad180fb4b5 100644 --- a/.github/workflows/yarn-audit-scheduled.yml +++ b/.github/workflows/yarn-audit-scheduled.yml @@ -20,7 +20,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v3 with: - node-version: '16' + node-version: "18" - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn config get cacheFolder)" diff --git a/README.md b/README.md index 069b3659cf..d0c542523d 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ This monorepo contains code for four different employment services: ## Requirements * Docker@^19.03.0 (or higher) -* NodeJS@^16.19.0 +* NodeJS@^18.16.0 * Yarn@^1.22 --- diff --git a/backend/benefit/applications/api/v1/review_state_views.py b/backend/benefit/applications/api/v1/review_state_views.py new file mode 100644 index 0000000000..348f5cb0d5 --- /dev/null +++ b/backend/benefit/applications/api/v1/review_state_views.py @@ -0,0 +1,34 @@ +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from applications.api.v1.serializers.review_state import ReviewStateSerializer +from applications.models import Application, ReviewState +from common.permissions import BFIsHandler + + +class ReviewStateView(APIView): + permission_classes = [BFIsHandler] + + def get(self, _, application_id): + try: + review_state = ReviewState.objects.get(application=application_id) + except ReviewState.DoesNotExist: + application = Application.objects.get(id=application_id) + if application: + review_state = ReviewState.objects.create(application=application) + else: + return Response(status=status.HTTP_404_NOT_FOUND) + serializer = ReviewStateSerializer(review_state) + return Response(serializer.data) + + def put(self, request, application_id): + try: + review_state = ReviewState.objects.get(application=application_id) + except ReviewState.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + serializer = ReviewStateSerializer(review_state, data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/backend/benefit/applications/api/v1/serializers/review_state.py b/backend/benefit/applications/api/v1/serializers/review_state.py new file mode 100644 index 0000000000..2518e1b1b1 --- /dev/null +++ b/backend/benefit/applications/api/v1/serializers/review_state.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from applications.models import ReviewState + + +class ReviewStateSerializer(serializers.ModelSerializer): + class Meta: + model = ReviewState + fields = "__all__" diff --git a/backend/benefit/applications/migrations/0036_alter_employee_working_hours.py b/backend/benefit/applications/migrations/0036_alter_employee_working_hours.py new file mode 100644 index 0000000000..4d16ce4848 --- /dev/null +++ b/backend/benefit/applications/migrations/0036_alter_employee_working_hours.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("applications", "0035_alter_applicationbatch_handler"), + ] + + operations = [ + migrations.AlterField( + model_name="employee", + name="working_hours", + field=models.DecimalField( + blank=True, + decimal_places=2, + max_digits=5, + null=True, + verbose_name="working hour", + ), + ), + ] diff --git a/backend/benefit/applications/migrations/0037_reviewstate.py b/backend/benefit/applications/migrations/0037_reviewstate.py new file mode 100644 index 0000000000..fa9b8e46d1 --- /dev/null +++ b/backend/benefit/applications/migrations/0037_reviewstate.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.18 on 2023-07-19 20:52 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('applications', '0036_alter_employee_working_hours'), + ] + + operations = [ + migrations.CreateModel( + name='ReviewState', + fields=[ + ('application', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='applications.application', verbose_name='application')), + ('company', models.BooleanField(default=False, verbose_name='company')), + ('company_contact_person', models.BooleanField(default=False, verbose_name='company contact person')), + ('de_minimis_aids', models.BooleanField(default=False, verbose_name='de minimis aids')), + ('co_operation_negotiations', models.BooleanField(default=False, verbose_name='co-operation negotiations')), + ('employee', models.BooleanField(default=False, verbose_name='employee')), + ('pay_subsidy', models.BooleanField(default=False, verbose_name='pay subsidy')), + ('benefit', models.BooleanField(default=False, verbose_name='benefit')), + ('employment', models.BooleanField(default=False, verbose_name='employment')), + ], + ), + ] diff --git a/backend/benefit/applications/migrations/0038_reviewstate_approval.py b/backend/benefit/applications/migrations/0038_reviewstate_approval.py new file mode 100644 index 0000000000..75181c6d73 --- /dev/null +++ b/backend/benefit/applications/migrations/0038_reviewstate_approval.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.18 on 2023-08-16 16:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('applications', '0037_reviewstate'), + ] + + operations = [ + migrations.AddField( + model_name='reviewstate', + name='approval', + field=models.BooleanField(default=False, verbose_name='approval'), + ), + ] diff --git a/backend/benefit/applications/migrations/0039_alter_paysubsidy_percentages.py b/backend/benefit/applications/migrations/0039_alter_paysubsidy_percentages.py new file mode 100644 index 0000000000..9339abf11f --- /dev/null +++ b/backend/benefit/applications/migrations/0039_alter_paysubsidy_percentages.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.18 on 2023-08-24 11:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('applications', '0038_reviewstate_approval'), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='additional_pay_subsidy_percent', + field=models.IntegerField(blank=True, choices=[(50, '50%'), (70, '70%'), (100, '100%')], null=True, verbose_name='Pay subsidy percent for second pay subsidy grant'), + ), + migrations.AlterField( + model_name='application', + name='pay_subsidy_percent', + field=models.IntegerField(blank=True, choices=[(50, '50%'), (70, '70%'), (100, '100%')], null=True, verbose_name='Pay subsidy percent'), + ), + migrations.AlterField( + model_name='historicalapplication', + name='additional_pay_subsidy_percent', + field=models.IntegerField(blank=True, choices=[(50, '50%'), (70, '70%'), (100, '100%')], null=True, verbose_name='Pay subsidy percent for second pay subsidy grant'), + ), + migrations.AlterField( + model_name='historicalapplication', + name='pay_subsidy_percent', + field=models.IntegerField(blank=True, choices=[(50, '50%'), (70, '70%'), (100, '100%')], null=True, verbose_name='Pay subsidy percent'), + ), + ] diff --git a/backend/benefit/applications/models.py b/backend/benefit/applications/models.py index 6aa2f676c2..2375d33364 100755 --- a/backend/benefit/applications/models.py +++ b/backend/benefit/applications/models.py @@ -38,9 +38,8 @@ ) PAY_SUBSIDY_PERCENT_CHOICES = ( - (30, "30%"), - (40, "40%"), (50, "50%"), + (70, "70%"), (100, "100%"), ) @@ -580,18 +579,24 @@ def raise_error(): self.decision_maker_name, self.section_of_the_law, validate_decision_date(self.decision_date), + ] + + required_fields_accepted_ahjo = required_fields_rejected + [ self.expert_inspector_name, self.expert_inspector_title, + self.p2p_checker_name, ] - required_fields_accepted = required_fields_rejected + [ + required_fields_accepted_p2p = required_fields_rejected + [ self.p2p_inspector_name, self.p2p_inspector_email, self.p2p_checker_name, ] - if self.status == ApplicationBatchStatus.DECIDED_ACCEPTED and not all( - required_fields_accepted + if ( + self.status == ApplicationBatchStatus.DECIDED_ACCEPTED + and not all(required_fields_accepted_ahjo) + and not all(required_fields_accepted_p2p) ): raise_error() if self.status == ApplicationBatchStatus.DECIDED_REJECTED and not all( @@ -753,8 +758,8 @@ class Employee(UUIDModel, TimeStampedModel): ) working_hours = models.DecimalField( verbose_name=_("working hour"), - decimal_places=1, - max_digits=4, + decimal_places=2, + max_digits=5, blank=True, null=True, ) @@ -827,3 +832,27 @@ class Meta: def __str__(self): return "{} {}".format(self.attachment_type, self.attachment_file.name) + + +class ReviewState(models.Model): + application = models.OneToOneField( + Application, + on_delete=models.CASCADE, + primary_key=True, + verbose_name=_("application"), + ) + company = models.BooleanField(default=False, verbose_name=_("company")) + company_contact_person = models.BooleanField( + default=False, verbose_name=_("company contact person") + ) + de_minimis_aids = models.BooleanField( + default=False, verbose_name=_("de minimis aids") + ) + co_operation_negotiations = models.BooleanField( + default=False, verbose_name=_("co-operation negotiations") + ) + employee = models.BooleanField(default=False, verbose_name=_("employee")) + pay_subsidy = models.BooleanField(default=False, verbose_name=_("pay subsidy")) + benefit = models.BooleanField(default=False, verbose_name=_("benefit")) + employment = models.BooleanField(default=False, verbose_name=_("employment")) + approval = models.BooleanField(default=False, verbose_name=_("approval")) diff --git a/backend/benefit/applications/tests/test_ahjo_integration.py b/backend/benefit/applications/tests/test_ahjo_integration.py index ebceac356b..5de4fdae45 100644 --- a/backend/benefit/applications/tests/test_ahjo_integration.py +++ b/backend/benefit/applications/tests/test_ahjo_integration.py @@ -248,7 +248,7 @@ def test_multiple_benefit_per_application(mock_pdf_convert): override_monthly_benefit_amount=None, ) pay_subsidy = PaySubsidyFactory( - pay_subsidy_percent=40, start_date=date(2021, 7, 10), end_date=date(2021, 9, 10) + pay_subsidy_percent=50, start_date=date(2021, 7, 10), end_date=date(2021, 9, 10) ) application.pay_subsidies.add(pay_subsidy) application.save() @@ -262,15 +262,17 @@ def test_multiple_benefit_per_application(mock_pdf_convert): assert ( html.count(application.ahjo_application_number) == 2 ) # Make sure there are two rows in the report + print(html) _assert_html_content( html, ( application.ahjo_application_number, application.employee.first_name, application.employee.last_name, - "691", - "340", + "440", + "893", "1600", "800", + "2493", ), ) diff --git a/backend/benefit/applications/tests/test_application_batch_api.py b/backend/benefit/applications/tests/test_application_batch_api.py index f53db2ec46..dc16718543 100755 --- a/backend/benefit/applications/tests/test_application_batch_api.py +++ b/backend/benefit/applications/tests/test_application_batch_api.py @@ -39,6 +39,30 @@ def get_valid_batch_completion_data(): } +def get_valid_p2p_batch_completion_data(): + return { + "decision_maker_title": get_faker().job(), + "decision_maker_name": get_faker().name(), + "section_of_the_law": "$1234", + "decision_date": date.today(), + "p2p_inspector_name": get_faker().name(), + "p2p_inspector_email": get_faker().email(), + "p2p_checker_name": get_faker().name(), + } + + +def get_valid_ahjo_batch_completion_data(): + return { + "decision_maker_title": get_faker().job(), + "decision_maker_name": get_faker().name(), + "section_of_the_law": "$1234", + "decision_date": date.today(), + "expert_inspector_name": get_faker().name(), + "expert_inspector_title": get_faker().job(), + "p2p_checker_name": get_faker().name(), + } + + def fill_as_valid_batch_completion_and_save( batch: ApplicationBatch, status: ApplicationBatchStatus = None ): @@ -314,6 +338,14 @@ def test_batch_too_many_drafts(application_batch): def test_batch_status_decided( handler_api_client, application_batch, batch_status, delta_months, delta_days ): + def remove_inspection_data_from_batch(batch): + batch.p2p_inspector_name = "" + batch.p2p_inspector_email = "" + batch.p2p_checker_name = "" + batch.expert_inspector_name = "" + batch.expert_inspector_title = "" + batch.save() + url = get_batch_detail_url(application_batch, "status/") payload = get_valid_batch_completion_data() payload["status"] = batch_status @@ -330,6 +362,22 @@ def test_batch_status_decided( response = handler_api_client.patch(url, payload) assert response.status_code == 200 + # Use Ahjo inspector + remove_inspection_data_from_batch(application_batch) + payload = get_valid_ahjo_batch_completion_data() + payload["decision_date"] = date.today() + relativedelta(months=(delta_months)) + payload["status"] = batch_status + response = handler_api_client.patch(url, payload) + assert response.status_code == 200 + + # Use Talpa/P2P inspector + remove_inspection_data_from_batch(application_batch) + payload = get_valid_p2p_batch_completion_data() + payload["status"] = batch_status + payload["decision_date"] = date.today() + relativedelta(months=(delta_months)) + response = handler_api_client.patch(url, payload) + assert response.status_code == 200 + @pytest.mark.parametrize( "status,batch_status,expected_decision", diff --git a/backend/benefit/applications/tests/test_applications_api.py b/backend/benefit/applications/tests/test_applications_api.py index 27782c5474..0dedc0f3b3 100755 --- a/backend/benefit/applications/tests/test_applications_api.py +++ b/backend/benefit/applications/tests/test_applications_api.py @@ -1455,7 +1455,7 @@ def test_application_modified_at_non_draft(api_client, application, status): [ (None, None, None, 200), # empty application (True, 50, None, 200), # one pay subsidy - (True, 100, 30, 200), # two pay subsidies + (True, 100, 30, 400), # two pay subsidies (None, 100, None, 400), # invalid (True, None, 50, 400), # invalid percent (True, 99, None, 400), # invalid choice diff --git a/backend/benefit/calculator/migrations/0013_alter_paysubsidy_percentages.py b/backend/benefit/calculator/migrations/0013_alter_paysubsidy_percentages.py new file mode 100644 index 0000000000..60a3da5799 --- /dev/null +++ b/backend/benefit/calculator/migrations/0013_alter_paysubsidy_percentages.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.18 on 2023-08-24 11:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('calculator', '0012_history_date_format_changes'), + ] + + operations = [ + migrations.AlterField( + model_name='calculation', + name='state_aid_max_percentage', + field=models.IntegerField(blank=True, choices=[(50, '50%'), (70, '70%'), (100, '100%')], default=None, null=True, verbose_name='State aid maximum %'), + ), + migrations.AlterField( + model_name='historicalcalculation', + name='state_aid_max_percentage', + field=models.IntegerField(blank=True, choices=[(50, '50%'), (70, '70%'), (100, '100%')], default=None, null=True, verbose_name='State aid maximum %'), + ), + migrations.AlterField( + model_name='historicalpaysubsidy', + name='pay_subsidy_percent', + field=models.IntegerField(choices=[(50, '50%'), (70, '70%'), (100, '100%')], verbose_name='Pay subsidy percent'), + ), + migrations.AlterField( + model_name='paysubsidy', + name='pay_subsidy_percent', + field=models.IntegerField(choices=[(50, '50%'), (70, '70%'), (100, '100%')], verbose_name='Pay subsidy percent'), + ), + ] diff --git a/backend/benefit/calculator/models.py b/backend/benefit/calculator/models.py index e05a070422..a42afa84eb 100644 --- a/backend/benefit/calculator/models.py +++ b/backend/benefit/calculator/models.py @@ -24,6 +24,7 @@ STATE_AID_MAX_PERCENTAGE_CHOICES = ( (50, "50%"), + (70, "70%"), (100, "100%"), ) @@ -527,10 +528,13 @@ class SalaryCostsRow(CalculationRow): proxy_row_type = RowType.SALARY_COSTS_EUR description_fi_template = "Palkkakustannukset / kk" + """Calculate the amount of salary costs for the application. + Notice that the vacation money is reported per month by the applicant.""" + def calculate_amount(self): return ( self.calculation.monthly_pay - + self.calculation.vacation_money / self.calculation.duration_in_months + + self.calculation.vacation_money + self.calculation.other_expenses ) @@ -555,7 +559,7 @@ class Meta: class PaySubsidyMonthlyRow(CalculationRow): proxy_row_type = RowType.PAY_SUBSIDY_MONTHLY_EUR - description_fi_template = "Palkkatuki (enintään {row.max_subsidy} €)" + description_fi_template = "Palkkatuki" """ Special rule regarding a 100% pay subsidy. The 100% subsidy is limited so, that it's only possible @@ -566,9 +570,10 @@ class PaySubsidyMonthlyRow(CalculationRow): * vacation money = 498,85 * 100% pay subsidy has been granted for 6 months * Pay subsidy is calcuated using formula: - min(1800, (monthly_pay+additional_expenses)/0.8*0.65) + vacation_money/6/0.8*0.65 + min(2020, (monthly_pay / work_time_fraction * 0.65) * 1.23 """ MAX_WORK_TIME_FRACTION_FOR_FULL_PAY_SUBSIDY = decimal.Decimal("0.65") + GROSS_WAGE_COEFFICIENT_FOR_FULL_PAY_SUBSIDY = decimal.Decimal("1.23") def __init__(self, *args, **kwargs): self.pay_subsidy = kwargs.pop("pay_subsidy", None) @@ -577,10 +582,7 @@ def __init__(self, *args, **kwargs): def calculate_amount(self): """ - Rule regarding the vacation money: - "Palkkatuen enimmäismäärä yritykselle vuonna 2021 on 1400 €/kk, jonka lisäksi maksetaan enintään - palkkatukipäätöksen mukainen prosenttiosuus lomarahasta." - Therefore, the pay subsidy limit does not apply to the vacation_money + 1.7.2023 voimaantulevan lain mukaan lomarahaa ja sivukuluja ei oteta enää huomioon palkkatuen määrää laskiessa """ assert self.max_subsidy is not None assert self.pay_subsidy is not None @@ -592,39 +594,23 @@ def calculate_amount(self): pay_subsidy_fraction = self.pay_subsidy.pay_subsidy_percent * decimal.Decimal( "0.01" ) - - if ( - pay_subsidy_fraction == 1 - and work_time_fraction > self.MAX_WORK_TIME_FRACTION_FOR_FULL_PAY_SUBSIDY - ): - full_time_salary_cost_excluding_vacation_money = ( - self.calculation.monthly_pay + self.calculation.other_expenses - ) / work_time_fraction - full_time_vacation_money = ( - self.calculation.vacation_money / work_time_fraction - ) / self.calculation.duration_in_months - subsidy_amount = ( - min( - self.max_subsidy, - full_time_salary_cost_excluding_vacation_money - * self.MAX_WORK_TIME_FRACTION_FOR_FULL_PAY_SUBSIDY, + # Pay subsidy max is 100%: + if pay_subsidy_fraction == 1: + full_time_salary_cost = (self.calculation.monthly_pay) / work_time_fraction + + subsidy_amount = min( + self.max_subsidy, + ( + full_time_salary_cost + * self.MAX_WORK_TIME_FRACTION_FOR_FULL_PAY_SUBSIDY ) - + full_time_vacation_money - * self.MAX_WORK_TIME_FRACTION_FOR_FULL_PAY_SUBSIDY + * self.GROSS_WAGE_COEFFICIENT_FOR_FULL_PAY_SUBSIDY, ) + # Pay subsidy max is less than 100% (50% or 70%): else: - salary_cost_excluding_vacation_money = ( - self.calculation.monthly_pay + self.calculation.other_expenses - ) - monthly_vacation_money = ( - self.calculation.vacation_money / self.calculation.duration_in_months - ) - subsidy_amount = ( - min( - self.max_subsidy, - pay_subsidy_fraction * salary_cost_excluding_vacation_money, - ) - + monthly_vacation_money * pay_subsidy_fraction + subsidy_amount = min( + self.max_subsidy, + pay_subsidy_fraction * self.calculation.monthly_pay, ) return subsidy_amount @@ -674,7 +660,7 @@ class Meta: class SalaryBenefitMonthlyRow(CalculationRow): proxy_row_type = RowType.HELSINKI_BENEFIT_MONTHLY_EUR - description_fi_template = "Helsinki-lisä / kk (enintään {row.max_benefit} €)" + description_fi_template = "Helsinki-lisä / kk" def __init__(self, *args, **kwargs): self.max_benefit = kwargs.pop("max_benefit", None) @@ -733,7 +719,7 @@ class SalaryBenefitTotalRow(CalculationRow, TotalRowMixin): def calculate_amount(self): return to_decimal( - self.calculation.duration_in_months + self.calculation.duration_in_months_rounded * self.calculation.calculator.get_amount( RowType.HELSINKI_BENEFIT_MONTHLY_EUR ), @@ -753,7 +739,7 @@ def __init__(self, *args, **kwargs): def calculate_amount(self): return to_decimal( - duration_in_months(self.start_date, self.end_date) + duration_in_months(self.start_date, self.end_date, 2) * self.calculation.calculator.get_amount( RowType.HELSINKI_BENEFIT_MONTHLY_EUR ), diff --git a/backend/benefit/calculator/rules.py b/backend/benefit/calculator/rules.py index cecf369e45..386d86eb00 100644 --- a/backend/benefit/calculator/rules.py +++ b/backend/benefit/calculator/rules.py @@ -2,12 +2,15 @@ import datetime import decimal import logging +from typing import Union from django.db import transaction from applications.enums import ApplicationStatus, BenefitType from calculator.enums import RowType from calculator.models import ( + Calculation, + CalculationRow, DateRangeDescriptionRow, DescriptionRow, EmployeeBenefitMonthlyRow, @@ -34,17 +37,17 @@ class HelsinkiBenefitCalculator: - def __init__(self, calculation): + def __init__(self, calculation: Calculation): self.calculation = calculation self._row_counter = 0 @staticmethod - def get_calculator(calculation): + def get_calculator(calculation: Calculation): # in future, one might use e.g. application date to determine the correct calculator if calculation.override_monthly_benefit_amount is not None: return ManualOverrideCalculator(calculation) elif calculation.application.benefit_type == BenefitType.SALARY_BENEFIT: - return SalaryBenefitCalculator2021(calculation) + return SalaryBenefitCalculator2023(calculation) elif calculation.application.benefit_type == BenefitType.EMPLOYMENT_BENEFIT: return EmployeeBenefitCalculator2021(calculation) else: @@ -77,7 +80,12 @@ def get_sub_total_ranges(self): # change day is the day after end_date change_days.add(item.end_date + datetime.timedelta(days=1)) - def get_item_in_effect(items, day): + def get_item_in_effect( + items: list[PaySubsidy], day: datetime.date + ) -> Union[PaySubsidy, None]: + # Return the first item in the list whose start date is less than or equal to the given day, + # and whose end date is greater than or equal to the given day. + # If no such item is found, it returns None. for item in items: if item.start_date <= day <= item.end_date: return item @@ -102,7 +110,7 @@ def get_item_in_effect(items, day): assert ranges[-1].end_date == self.calculation.end_date return ranges - def get_amount(self, row_type, default=None): + def get_amount(self, row_type: RowType, default=None): # This function is used by the various CalculationRow to retrieve a previously calculated value row = ( self.calculation.rows.order_by("-ordering") @@ -146,7 +154,7 @@ def calculate(self): self.calculation.calculated_benefit_amount = None self.calculation.save() - def _create_row(self, row_class, **kwargs): + def _create_row(self, row_class: CalculationRow, **kwargs): row = row_class( calculation=self.calculation, ordering=self._row_counter, **kwargs ) @@ -169,7 +177,7 @@ def create_rows(self): SalaryBenefitTotalRow, ) - def get_amount(self, row_type, default=None): + def get_amount(self, row_type: RowType, default=None): return decimal.Decimal(0) @@ -182,14 +190,15 @@ def create_rows(self): ) -class SalaryBenefitCalculator2021(HelsinkiBenefitCalculator): +class SalaryBenefitCalculator2023(HelsinkiBenefitCalculator): """ - Calculation of salary benefit, according to rules in effect 2021 (and possibly onwards) + Calculation of salary benefit, according to rules in effect starting from 1.7.2023 """ # The maximum amount of pay subsidy depends on the pay subsidy percent in the pay subsidy decision. - PAY_SUBSIDY_MAX_FOR_100_PERCENT = 1800 - DEFAULT_PAY_SUBSIDY_MAX = 1400 + PAY_SUBSIDY_MAX_FOR_100_PERCENT = 2020 + PAY_SUBSIDY_MAX_FOR_70_PERCENT = 1770 + PAY_SUBSIDY_MAX_FOR_50_PERCENT = 1260 SALARY_BENEFIT_MAX = 800 def can_calculate(self): @@ -209,10 +218,14 @@ def can_calculate(self): def get_maximum_monthly_pay_subsidy(self, pay_subsidy): if pay_subsidy.pay_subsidy_percent == 100: return self.PAY_SUBSIDY_MAX_FOR_100_PERCENT + elif pay_subsidy.pay_subsidy_percent == 70: + return self.PAY_SUBSIDY_MAX_FOR_70_PERCENT else: - return self.DEFAULT_PAY_SUBSIDY_MAX + return self.PAY_SUBSIDY_MAX_FOR_50_PERCENT def create_deduction_rows(self, benefit_sub_range): + # Create the rows for the calculation + # that display the deduction amounts for pay subsidy and training compensation if benefit_sub_range.pay_subsidy or benefit_sub_range.training_compensation: self._create_row( DescriptionRow, diff --git a/backend/benefit/calculator/tests/Helsinki-lisa laskurin testitapaukset.xlsx b/backend/benefit/calculator/tests/Helsinki-lisa laskurin testitapaukset.xlsx index b33f7eb204..cbb0ee9de7 100644 Binary files a/backend/benefit/calculator/tests/Helsinki-lisa laskurin testitapaukset.xlsx and b/backend/benefit/calculator/tests/Helsinki-lisa laskurin testitapaukset.xlsx differ diff --git a/backend/benefit/calculator/tests/test_calculator_api.py b/backend/benefit/calculator/tests/test_calculator_api.py index 5a770d1354..0b0fe6b065 100644 --- a/backend/benefit/calculator/tests/test_calculator_api.py +++ b/backend/benefit/calculator/tests/test_calculator_api.py @@ -35,7 +35,7 @@ def _set_two_pay_subsidies_with_empty_dates(data: dict) -> dict: { "start_date": None, "end_date": None, - "pay_subsidy_percent": 40, + "pay_subsidy_percent": 70, "work_time_percent": 40, }, ] @@ -249,7 +249,7 @@ def test_modify_calculation_invalid_status( { "start_date": str(handling_application.start_date), "end_date": str(handling_application.end_date), - "pay_subsidy_percent": 40, + "pay_subsidy_percent": 50, "work_time_percent": "50.00", } ] @@ -315,7 +315,7 @@ def test_application_edit_pay_subsidy(handler_api_client, handling_application): # edit fields data["pay_subsidies"][0]["start_date"] = "2021-06-01" - data["pay_subsidies"][0]["pay_subsidy_percent"] = 40 + data["pay_subsidies"][0]["pay_subsidy_percent"] = 70 # swap order data["pay_subsidies"][0], data["pay_subsidies"][1] = ( data["pay_subsidies"][1], @@ -328,7 +328,7 @@ def test_application_edit_pay_subsidy(handler_api_client, handling_application): assert response.status_code == 200 assert len(response.data["pay_subsidies"]) == 2 assert response.data["pay_subsidies"][1]["start_date"] == "2021-06-01" - assert response.data["pay_subsidies"][1]["pay_subsidy_percent"] == 40 + assert response.data["pay_subsidies"][1]["pay_subsidy_percent"] == 70 def test_application_delete_pay_subsidy(handler_api_client, handling_application): @@ -472,7 +472,7 @@ def test_pay_subsidies_validation_in_handling( company=mock_get_organisation_roles_and_create_company, pay_subsidy_granted=True, pay_subsidy_percent=100, - additional_pay_subsidy_percent=40, + additional_pay_subsidy_percent=70, ) data = HandlerApplicationSerializer(handling_application).data _set_two_pay_subsidies_with_empty_dates(data) diff --git a/backend/benefit/calculator/tests/test_models.py b/backend/benefit/calculator/tests/test_models.py index ca9d78ca03..6d85eaf90a 100644 --- a/backend/benefit/calculator/tests/test_models.py +++ b/backend/benefit/calculator/tests/test_models.py @@ -74,7 +74,7 @@ def test_create_for_application_fail(received_application): @pytest.mark.parametrize( - "pay_subsidy_percent, max_subsidy", [(40, 1400), (50, 1400), (100, 1800)] + "pay_subsidy_percent, max_subsidy", [(70, 1770), (50, 1260), (100, 2020)] ) def test_pay_subsidy_maximum(handling_application, pay_subsidy_percent, max_subsidy): assert handling_application.pay_subsidies.count() == 1 diff --git a/backend/benefit/common/utils.py b/backend/benefit/common/utils.py index 20e272be1d..23397d3065 100644 --- a/backend/benefit/common/utils.py +++ b/backend/benefit/common/utils.py @@ -2,6 +2,7 @@ import functools import itertools from datetime import date, timedelta +from typing import Iterator, Tuple, Union from dateutil.relativedelta import relativedelta from phonenumber_field.serializerfields import ( @@ -9,7 +10,27 @@ ) -def update_object(obj, data, limit_to_fields=None): +def update_object(obj: object, data: dict, limit_to_fields=None): + """ + Updates the fields of an object with the provided data. + + Args: + obj: The object to be updated. + data: A dictionary containing field-value pairs to update the object. + limit_to_fields (optional): A list of fields to limit the update to. Default is None. + + Raises: + ValueError: If a field in the data is set to None but is not nullable according to the object's model. + + Example: + user = User.objects.get(id=1) + data = {'name': 'John', 'age': 25} + update_object(user, data) + + In the example above, the update_object function is used to update the 'name' and 'age' fields of a User object. + The function takes the User object, a data dictionary with field-value pairs, and updates the object accordingly. + The updated object is then saved. + """ if not data: return for k, v in data.items(): @@ -21,7 +42,33 @@ def update_object(obj, data, limit_to_fields=None): obj.save() -def xgroup(iter, n=2, check_length=False): +def xgroup(iter, n=2, check_length=False) -> Iterator[Tuple]: + """ + Groups elements from an iterable into tuples of size n. + + Args: + iterable: The iterable to be grouped. + n (optional): The size of each group. Default is 2. + check_length (optional): Whether to check if the length of the iterable is divisible by n. Default is False. + + Yields: + Tuples of size n containing elements from the iterable. + + Raises: + AssertionError: If check_length is True and the length of the iterable is not divisible by n. + + Example: + items = [1, 2, 3, 4, 5, 6] + for group in xgroup(items, 3): + print(group) + + # Output: + # (1, 2, 3) + # (4, 5, 6) + + In the example above, the xgroup function is used to group elements from a list into tuples of size 3. + Each tuple is then printed, demonstrating how the function can be used to iterate over groups of elements. + """ """ adapted from: comp.lang.python Thu Jun 5 22:58:05 CEST 2003 @@ -40,6 +87,30 @@ def xgroup(iter, n=2, check_length=False): def pairwise(iterable): + """ + Iterates over an iterable, returning pairs of consecutive elements. + + Args: + iterable: An iterable object. + + Returns: + An iterator that generates tuples containing consecutive pairs of elements from the iterable. + + Example: + numbers = [1, 2, 3, 4, 5] + pairs = pairwise(numbers) + for pair in pairs: + print(pair) + + Output: + (1, 2) + (2, 3) + (3, 4) + (4, 5) + + In the example above, the pairwise function takes a list of numbers and returns an iterator that generates pairs + of consecutive numbers. The resulting pairs are then printed one by one. + """ "s -> (s0,s1), (s1,s2), (s2, s3), ..." a, b = itertools.tee(iterable) for unused in b: @@ -47,22 +118,81 @@ def pairwise(iterable): return zip(a, b) -def nested_setattr(obj, attr, val): +def nested_setattr(obj: object, attr, val): """ - Support dotted access to nested objects + Sets the value of a nested attribute in an object using dotted notation. + + Args: + obj: The object to set the attribute on. + attr: The attribute name or dotted path to the nested attribute. + val: The value to set for the attribute. + + Returns: + None + + Example: + user = User() + nested_setattr(user, 'profile.name', 'John') + nested_setattr(user, 'profile.age', 25) + + In the example above, the nested_setattr function is used to set the 'name' and 'age' attributes of the 'profile' + nested object within a User object. The function takes the User object, the attribute name or dotted path, and the + corresponding value, and sets the attributes accordingly. """ pre, _, post = attr.rpartition(".") return setattr(nested_getattr(obj, pre) if pre else obj, post, val) def nested_getattr(obj, attr, *args): + """ + Retrieves the value of a nested attribute from an object using dot notation. + + Args: + obj: The object from which to retrieve the attribute. + attr: A string representing the nested attribute using dot notation. + *args: Additional arguments to be passed to the getattr function. + + Returns: + The value of the nested attribute if it exists, or None if any intermediate attribute is missing. + + Example: + class Person: + def __init__(self, name): + self.name = name + + person = Person("John") + print(nested_getattr(person, "name")) # Output: "John" + """ + def _getattr(obj, attr): return getattr(obj, attr, *args) return functools.reduce(_getattr, [obj] + attr.split(".")) -def to_decimal(numeric_value, decimal_places=None, allow_null=True): +def to_decimal(numeric_value, decimal_places: Union[int, None] = None, allow_null=True): + """ + Converts a numeric value to a decimal. + + Args: + numeric_value: The numeric value to be converted. + decimal_places: The number of decimal places to round the converted value to. + If None, no rounding will be performed. + allow_null: Whether to allow a None value as input. + If True and numeric_value is None, the function returns None. + + Returns: + The numeric value converted to a decimal, with optional rounding if decimal_places is provided. + If numeric_value is None and allow_null is True, the function returns None. + + Example: + num = 3.14159 + dec = to_decimal(num, 2) + print(dec) # Output: 3.14 + + In the example above, the to_decimal function converts the numeric value 3.14159 to a decimal with 2 decimal places + and assigns it to the variable dec. The resulting decimal value is then printed. + """ if numeric_value is None and allow_null: return None value = decimal.Decimal(numeric_value) @@ -80,7 +210,34 @@ def to_representation(self, value): return "0{}".format(value.national_number) -def date_range_overlap(start_1, end_1, start_2, end_2): +def date_range_overlap(start_1: date, end_1: date, start_2: date, end_2: date): + """ + Calculates the overlap between two date ranges. + + Args: + start_1: The start date of the first date range. + end_1: The end date of the first date range. + start_2: The start date of the second date range. + end_2: The end date of the second date range. + + Returns: + The number of overlapping days between the two date ranges. If there is no overlap, the function returns 0. + + Raises: + ValueError: If any of the start or end dates are None. + + Example: + start_1 = date(2022, 1, 1) + end_1 = date(2022, 1, 15) + start_2 = date(2022, 1, 10) + end_2 = date(2022, 1, 20) + overlap = date_range_overlap(start_1, end_1, start_2, end_2) + print(overlap) # Output: 6 + + In the example above, the date_range_overlap function calculates the overlap between two date ranges. + The first date range spans from January 1st to January 15th, and the second date range spans from + January 10th to January 20th. The function returns the number of overlapping days, which is 6. + """ """ Based on: https://stackoverflow.com/questions/9044084/efficient-date-range-overlap-calculation-in-python """ @@ -92,11 +249,30 @@ def date_range_overlap(start_1, end_1, start_2, end_2): return max(0, delta) -METHOD_EU = True +def days360(start_date: date, end_date: date): + """ + Calculates the number of days between two dates using the 360-day method. + Args: + start_date: The starting date. + end_date: The ending date. + + Returns: + The number of days between the two dates using the 360-day method. + + Raises: + ValueError: If start_date or end_date is not a date object. + + Example: + start = date(2022, 1, 1) + end = date(2022, 2, 15) + days = days360(start, end) + print(days) # Output: 46 + + In the example above, the days360 function calculates the number of days between January 1, 2022, + and February 15, 2022, + using the 360-day method. The result, 46, is then printed. -def days360(start_date, end_date): - """ Calculation of the number of months in Helsinki Benefit calculator excel file used the days360 function. This is a Python implementation. source: https://stackoverflow.com/questions/51832672/pandas-excel-days360-equivalent @@ -104,6 +280,8 @@ def days360(start_date, end_date): * Added unit tests * forced method_eu to always be True. """ + METHOD_EU = True + if not isinstance(start_date, date) or not isinstance(end_date, date): raise ValueError("date object needed") @@ -143,7 +321,9 @@ def days360(start_date, end_date): ) -def duration_in_months(start_date, end_date, decimal_places=None): +def duration_in_months( + start_date: date, end_date: date, decimal_places: Union[int, None] = None +): # This is the formula used in the application calculator Excel file 2021-09 return to_decimal( decimal.Decimal(days360(start_date, end_date + timedelta(days=1))) / 30, @@ -169,7 +349,7 @@ def duration_in_months_rounded(self): # in many calculations return self._get_duration_in_months(decimal_places=2) - def _get_duration_in_months(self, decimal_places=None): + def _get_duration_in_months(self, decimal_places: Union[int, None] = None): if self.start_date and self.end_date: return duration_in_months( self.start_date, self.end_date, decimal_places=decimal_places @@ -182,7 +362,7 @@ def _get_duration_in_months(self, decimal_places=None): DATE_RANGE_MAX_ITERATIONS = 7 -def get_date_range_end_with_days360(start_date, n_months): +def get_date_range_end_with_days360(start_date: date, n_months: int): """ Given a start date and a duration in months, return the end_date for a date range that most closely matches the given duration. diff --git a/backend/benefit/helsinkibenefit/settings.py b/backend/benefit/helsinkibenefit/settings.py index bb927ad891..b9c5a95b5c 100644 --- a/backend/benefit/helsinkibenefit/settings.py +++ b/backend/benefit/helsinkibenefit/settings.py @@ -292,6 +292,7 @@ CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS") CSRF_COOKIE_NAME = env.str("CSRF_COOKIE_NAME") CSRF_COOKIE_SECURE = True +CSRF_USE_SESSIONS = True # Audit logging AUDIT_LOG_ORIGIN = env.str("AUDIT_LOG_ORIGIN") diff --git a/backend/benefit/helsinkibenefit/urls.py b/backend/benefit/helsinkibenefit/urls.py index 0e970d750a..c6751f9790 100644 --- a/backend/benefit/helsinkibenefit/urls.py +++ b/backend/benefit/helsinkibenefit/urls.py @@ -12,6 +12,7 @@ from rest_framework_nested import routers from applications.api.v1 import application_batch_views, views as application_views +from applications.api.v1.review_state_views import ReviewStateView from calculator.api.v1 import views as calculator_views from common.debug_util import debug_env from companies.api.v1.views import ( @@ -25,7 +26,7 @@ HandlerNoteViewSet, ) from terms.api.v1.views import ApproveTermsOfServiceView -from users.api.v1.views import CurrentUserView, UserUuidGDPRAPIView +from users.api.v1.views import CurrentUserView, UserOptionsView, UserUuidGDPRAPIView router = routers.DefaultRouter() router.register( @@ -68,7 +69,11 @@ path("v1/company/", GetUsersOrganizationView.as_view()), path("v1/company/search//", SearchOrganisationsView.as_view()), path("v1/company/get//", GetOrganisationByIdView.as_view()), - path("v1/users/me/", CurrentUserView.as_view()), + path("v1/users/me/", CurrentUserView.as_view(), name="users-me"), + path("v1/users/options/", UserOptionsView.as_view()), + path( + "v1/handlerapplications//review/", ReviewStateView.as_view() + ), path("oidc/", include("shared.oidc.urls")), path("oauth2/", include("shared.azure_adfs.urls")), path("api-auth/", include("rest_framework.urls", namespace="rest_framework")), diff --git a/backend/benefit/users/api/v1/serializers.py b/backend/benefit/users/api/v1/serializers.py index 28efb82dee..762a115fb7 100644 --- a/backend/benefit/users/api/v1/serializers.py +++ b/backend/benefit/users/api/v1/serializers.py @@ -26,6 +26,7 @@ class Meta: "terms_of_service_approvals", "terms_of_service_approval_needed", "terms_of_service_in_effect", + "is_staff", ] read_only_fields = [ "id", @@ -34,6 +35,7 @@ class Meta: "terms_of_service_approvals", "terms_of_service_approval_needed", "terms_of_service_in_effect", + "is_staff", ] terms_of_service_in_effect = serializers.SerializerMethodField( diff --git a/backend/benefit/users/api/v1/views.py b/backend/benefit/users/api/v1/views.py index 230c6e37d9..d205a477d3 100644 --- a/backend/benefit/users/api/v1/views.py +++ b/backend/benefit/users/api/v1/views.py @@ -1,7 +1,9 @@ +from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.base_user import AbstractBaseUser from django.core.exceptions import PermissionDenied from django.db import DatabaseError, transaction +from django.middleware.csrf import get_token from django.shortcuts import get_object_or_404 from drf_spectacular.utils import extend_schema from helsinki_gdpr.views import DeletionNotAllowed, DryRunException, GDPRAPIView @@ -29,7 +31,9 @@ def get(self, request): serializer = UserSerializer( self._get_current_user(request), context={"request": request} ) - return Response(serializer.data) + response = serializer.data + response["csrf_token"] = get_token(request) + return Response(response) def _get_current_user(self, request): if not request.user.is_authenticated: @@ -37,6 +41,25 @@ def _get_current_user(self, request): return request.user +@extend_schema(description="API for setting currently logged in user's language.") +class UserOptionsView(APIView): + permission_classes = [BFIsAuthenticated] + + def get(self, request): + lang = request.GET.get("lang") + + if lang in ["fi", "en", "sv"]: + token = request.COOKIES.get("yjdhcsrftoken") + + response = Response( + {"lang": lang, "token": token}, status=status.HTTP_200_OK + ) + response.set_cookie(settings.LANGUAGE_COOKIE_NAME, lang, httponly=True) + return response + + return Response(status=status.HTTP_400_BAD_REQUEST) + + class UserUuidGDPRAPIView(GDPRAPIView): """GDPR API view that is used from Helsinki profile to query and delete user data.""" diff --git a/backend/benefit/users/tests/conftest.py b/backend/benefit/users/tests/conftest.py index cf687ed857..7b11e94c3a 100644 --- a/backend/benefit/users/tests/conftest.py +++ b/backend/benefit/users/tests/conftest.py @@ -1,10 +1,23 @@ +import factory import pytest from rest_framework.test import APIClient +from applications.tests.factories import ApplicationFactory from common.tests.conftest import * # noqa +from companies.tests.conftest import * # noqa from helsinkibenefit.tests.conftest import * # noqa @pytest.fixture def gdpr_api_client(): return APIClient() + + +@pytest.fixture +def application(mock_get_organisation_roles_and_create_company): + # Application which belongs to logged in user company + with factory.Faker.override_default_locale("fi_FI"): + app = ApplicationFactory() + app.company = mock_get_organisation_roles_and_create_company + app.save() + return app diff --git a/backend/benefit/users/tests/test_user_api.py b/backend/benefit/users/tests/test_user_api.py new file mode 100644 index 0000000000..87e381f526 --- /dev/null +++ b/backend/benefit/users/tests/test_user_api.py @@ -0,0 +1,11 @@ +from rest_framework.reverse import reverse + +from common.tests.conftest import get_client_user + + +def test_applications_unauthorized(api_client, application): + response = api_client.get(reverse("users-me")) + user = get_client_user(api_client) + assert response.status_code == 200 + assert response.data["id"] == str(user.id) + assert len(response.data["csrf_token"]) > 0 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index e3efeefc75..1bb73acc6e 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,5 +1,5 @@ # ======================================= -FROM helsinkitest/node:16-slim as appbase +FROM helsinkitest/node:18-slim as appbase # ======================================= # Install ca-certificates so that Sentry can upload source maps @@ -38,7 +38,7 @@ FROM appbase as development # Set V8 max heap size to 2GB (default is 512MB) # This prevents Docker Compose from crashing due to out of memory errors -ENV NODE_OPTIONS="--max_old_space_size=2048" +ENV NODE_OPTIONS="--max_old_space_size=2048 --openssl-legacy-provider" ARG PROJECT ARG FOLDER @@ -92,16 +92,18 @@ COPY --chown=appuser:appuser . . # Build application WORKDIR /app/$PROJECT/$FOLDER/ -RUN yarn build +RUN NODE_OPTIONS=--openssl-legacy-provider yarn build # Clean all dependencies (this should avoid caching + missing /pages directory problem) RUN rm -rf node_modules RUN yarn cache clean # ========================================== -FROM helsinkitest/node:16-slim AS production +FROM helsinkitest/node:18-slim AS production # ========================================== +ENV NODE_OPTIONS="--openssl-legacy-provider" + ARG PORT ARG PROJECT ARG FOLDER diff --git a/frontend/README.md b/frontend/README.md index 4449b17cdd..bdb33d3ef5 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -9,14 +9,14 @@ Project is automatically deployed to testing environment when pushing to develop ## Requirements -- Node 16.x (match with dockerfile: helsinkitest/node:16-slim) +- Node 18.x (match with dockerfile: helsinkitest/node:18-slim) - Yarn - Git - Docker ### install node with nvm - nvm install 16 --lts + nvm install 18 --lts ## Available Scripts diff --git a/frontend/babel.config.js b/frontend/babel.config.js deleted file mode 100644 index 9f3abffca2..0000000000 --- a/frontend/babel.config.js +++ /dev/null @@ -1,11 +0,0 @@ -module.exports = { - presets: ['next/babel', ['@babel/preset-typescript', { isTSX: true, allExtensions: true }]], - plugins: [ - ['styled-components', { ssr: true }], - ['import', { - 'libraryName': 'react-use', - 'libraryDirectory': 'lib', - 'camel2DashComponentName': false - } - ]], -}; diff --git a/frontend/benefit/README.md b/frontend/benefit/README.md index 1195083cea..8c5e20efe5 100644 --- a/frontend/benefit/README.md +++ b/frontend/benefit/README.md @@ -9,7 +9,7 @@ Project is automatically deployed to testing environment when pushing to develop ## Requirements -- Node 16.x +- Node 18.x - Lerna - Yarn - Git @@ -18,8 +18,8 @@ Project is automatically deployed to testing environment when pushing to develop ### Install NodeJS # Use node manager (n or nvm, for example) - n 16 - nvm install 16 --lts + n 18 + nvm install 18 --lts # Alternative methods https://nodejs.org/dist/ https://nodejs.org/en/download/package-manager diff --git a/frontend/benefit/applicant/jest.config.js b/frontend/benefit/applicant/jest.config.js index e5f8d6f3f6..6984bf0727 100644 --- a/frontend/benefit/applicant/jest.config.js +++ b/frontend/benefit/applicant/jest.config.js @@ -1,11 +1,12 @@ const sharedConfig = require('../../jest.config.js'); -module.exports = { +const nextJest = require('next/jest'); + +const createJestConfig = nextJest({ + dir: './', +}); + +const config = { ...sharedConfig, - globals: { - 'ts-jest': { - tsconfig: '/tsconfig.jest.json', - }, - }, moduleNameMapper: { [`^shared\/(.*)$`]: '/../../shared/src/$1', [`^benefit-shared\/(.*)$`]: '../shared/src/$1', @@ -20,4 +21,8 @@ module.exports = { '/../../shared/src/server/next-server.js', '/../../shared/src/test/', ], + moduleDirectories: ['node_modules', '/'], + testEnvironment: 'jest-environment-jsdom', }; + +module.exports = createJestConfig(config); diff --git a/frontend/benefit/applicant/next-env.d.ts b/frontend/benefit/applicant/next-env.d.ts index 9bc3dd46b9..4f11a03dc6 100644 --- a/frontend/benefit/applicant/next-env.d.ts +++ b/frontend/benefit/applicant/next-env.d.ts @@ -1,5 +1,4 @@ /// -/// /// // NOTE: This file should not be edited diff --git a/frontend/benefit/applicant/next.config.js b/frontend/benefit/applicant/next.config.js index 03069a2105..fd7fd19ec1 100644 --- a/frontend/benefit/applicant/next.config.js +++ b/frontend/benefit/applicant/next.config.js @@ -6,5 +6,4 @@ const { parsed: env } = require('dotenv').config({ module.exports = nextConfig({ i18n, env, - poweredByHeader: false, }); diff --git a/frontend/benefit/applicant/package.json b/frontend/benefit/applicant/package.json index 417fbf2f2e..d87de1caef 100644 --- a/frontend/benefit/applicant/package.json +++ b/frontend/benefit/applicant/package.json @@ -11,8 +11,8 @@ "test": "jest --passWithNoTests", "test:staged": "yarn test --watchAll=false --findRelatedTests", "test:coverage": "yarn test --verbose --coverage", - "browser-test": "testcafe 'chrome --allow-insecure-localhost --ignore-certificate-errors --ignore-urlfetcher-cert-requests --window-size=\"1249,720\"' browser-tests/newApplication/company.testcafe.ts", - "browser-test:ci": "testcafe 'chrome:headless --disable-gpu --window-size=\"1249,720\" --ignore-certificate-errors-spki-list=\"8sg/cl7YabrOFqSqH+Bu0e+P27Av33gWgi8Lq28DW1I=,gJt+wt/T3afCRkxtMMSjXcl/99sgzWc2kk1c1PC9tG0=,zrQI2/1q8i2SRPmMZ1sMntIkG+lMW0legPFokDo3nrY=\"' --screenshots path=report --video report --reporter spec,custom,html:report/index.html browser-tests/newApplication/company.testcafe.ts" + "browser-test": "testcafe 'chrome --allow-insecure-localhost --ignore-certificate-errors --ignore-urlfetcher-cert-requests --window-size=\"1249,720\"' browser-tests/newApplication/company.testcafe.ts --experimental-proxyless", + "browser-test:ci": "testcafe 'chrome:headless --disable-gpu --window-size=\"1249,720\" --ignore-certificate-errors-spki-list=\"8sg/cl7YabrOFqSqH+Bu0e+P27Av33gWgi8Lq28DW1I=,gJt+wt/T3afCRkxtMMSjXcl/99sgzWc2kk1c1PC9tG0=,zrQI2/1q8i2SRPmMZ1sMntIkG+lMW0legPFokDo3nrY=\"' --screenshots path=report --video report --reporter spec,custom,html:report/index.html browser-tests/newApplication/company.testcafe.ts --experimental-proxyless" }, "dependencies": { "@frontend/shared": "*", @@ -21,7 +21,7 @@ "@sentry/browser": "^7.16.0", "@sentry/nextjs": "^7.16.0", "axios": "^0.27.2", - "babel-plugin-import": "^1.13.3", + "babel-plugin-import": "^1.13.6", "camelcase-keys": "^7.0.2", "date-fns": "^2.24.0", "dotenv": "^16.0.0", @@ -29,11 +29,11 @@ "formik": "^2.2.9", "hds-react": "^2.10.0", "lodash": "^4.17.21", - "next": "^11.1.4", + "next": "^12.3.4", "next-compose-plugins": "^2.2.1", - "next-i18next": "^10.5.0", + "next-i18next": "^13.0.3", "next-plugin-custom-babel-config": "^1.0.5", - "next-transpile-modules": "^9.0.0", + "next-transpile-modules": "^9.1.0", "pdfjs-dist": "3.6.172", "react": "^18.0.0", "react-dom": "^18.0.0", diff --git a/frontend/benefit/applicant/public/favicons/manifest.webmanifest b/frontend/benefit/applicant/public/favicons/manifest.webmanifest index 5e0299b4af..267bb8ff36 100644 --- a/frontend/benefit/applicant/public/favicons/manifest.webmanifest +++ b/frontend/benefit/applicant/public/favicons/manifest.webmanifest @@ -1,6 +1,14 @@ { - "icons": [ - { "src": "/favicon-192x192.png", "type": "image/png", "sizes": "192x192" }, - { "src": "/favicon-512x512.png", "type": "image/png", "sizes": "512x512" } - ] -} \ No newline at end of file + "icons": [ + { + "src": "/favicons/favicon-192x192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "/favicons/favicon-512x512.png", + "type": "image/png", + "sizes": "512x512" + } + ] +} diff --git a/frontend/benefit/applicant/public/locales/en/common.json b/frontend/benefit/applicant/public/locales/en/common.json index 6b3ce28ee3..4610a048fa 100644 --- a/frontend/benefit/applicant/public/locales/en/common.json +++ b/frontend/benefit/applicant/public/locales/en/common.json @@ -86,6 +86,10 @@ "deMinimisAidMaxAmount": { "label": "Maximum amount exceeded", "content": "The maximum amount of de minimis aid has been exceeded. Under the EU Regulation, a company may receive a maximum EUR 200,000 of de minimis aid during the current year and the previous two tax years. All forms of de minimis aid granted by various authorities during this period will be taken into account in the maximum amount." + }, + "deMinimisUnfinished": { + "label": "Missing de minimis aid information", + "content": "Please fill any missing de minimis aid information and press 'Add' button." } }, "tooltips": { @@ -186,7 +190,7 @@ "salaryExpensesExplanation": "The gross salary and indirect labour costs are indicated in EUR per month, holiday bonus as a lump sum", "tooltips": { "heading5Employment": "Collective agreement applied: e.g. Collective Agreement for the Commercial Sector. If there is no binding collective agreement in the sector, put in a dash.", - "heading5EmploymentSub1": "The gross salary is the salary paid to the subsidised employee before the deduction of the employee’s statutory contributions (the employee’s unemployment insurance and pension insurance contributions) and taxes. If the subsidised employee is paid remuneration bonuses (e.g. evening, night or shift work bonus), take the estimated amount into account in the gross salary. The employers’ statutory indirect labour costs include social security expenses, pension insurance, accident insurance and unemployment insurance premiums as well as the mandatory group life insurance premium. Indirect labour costs refer to the amount of the employer’s statutory indirect labour costs paid for the salary per month. The holiday bonus is a salary cost to be covered by the subsidy when it is paid for holiday pay during the subsidy period. Estimate the amount of holiday bonus to be paid during the subsidy period. Holiday compensation is not covered by the Helsinki benefit.", + "heading5EmploymentSub1": "The gross salary is the salary paid to the subsidised employee before the deduction of the employee’s statutory contributions (the employee’s unemployment insurance and pension insurance contributions) and taxes. If the subsidised employee is paid remuneration bonuses (e.g. evening, night or shift work bonus), take the estimated amount into account in the gross salary. The employers’ statutory indirect labour costs include social security expenses, pension insurance, accident insurance and unemployment insurance premiums as well as the mandatory group life insurance premium.\r\n\r\nIndirect labour costs refer to the amount of the employer’s statutory indirect labour costs paid for the salary per month.\r\n\r\nThe holiday bonus is a salary cost to be covered by the subsidy when it is paid for holiday pay during the subsidy period. Estimate the amount of holiday bonus to be paid during the subsidy period. Holiday compensation is not covered by the Helsinki benefit.", "heading5Assignment": "The gross salary is the salary paid to the subsidised employee before the deduction of the employee's statutory contributions (the employee's unemployment insurance and pension insurance contributions) and taxes. If the subsidised employee is paid remuneration bonuses (e.g. evening, night or shift work bonus), take the estimated amount into account in the gross salary. The employers' statutory indirect labour costs include social security expenses, pension insurance, accident insurance and unemployment insurance premiums as well as the mandatory group life insurance premium. Indirect labour costs refer to the amount of the employer's statutory indirect labour costs paid for the salary per month. The holiday bonus is a salary cost to be covered by the subsidy when it is paid for holiday pay during the subsidy period. Estimate the amount of holiday bonus to be paid during the subsidy period. Holiday compensation is not covered by the Helsinki benefit.", "heading2": "Pay subsidy is a financial subsidy intended to promote the employment of an unemployed jobseeker, which TE Services can grant to the employer for salary costs.", "heading3": "The Helsinki benefit for employment is intended for guidance, orientation, tools, work clothing and workspace costs when no other support is paid for these. The Helsinki benefit for salary is intended for the cost of employing a subsidised employee (= gross salary, statutory indirect labour costs and holiday bonus). The Helsinki benefit for a commission is intended for the performance of an individual job or project." @@ -374,7 +378,8 @@ "drawer": { "title": "Application’s messages" }, - "languageMenuButtonAriaLabel": "Select language" + "languageMenuButtonAriaLabel": "Select language", + "menuToggleAriaLabel": "Menu" }, "footer": { "copyrightText": "Copyright", @@ -421,7 +426,6 @@ "credentialsIngress": { "text": "A person to be employed with the Helsinki benefit must be informed of the processing of their personal data. Print the notification and request a signature from the person to be employed." }, - "menuToggleAriaLabel": "Menu", "form": { "validation": { "required": "This field is required", @@ -657,5 +661,8 @@ "heading1": "Palaute ja yhteystiedot" } } + }, + "toast": { + "closeToast": "Close notification" } } diff --git a/frontend/benefit/applicant/public/locales/fi/common.json b/frontend/benefit/applicant/public/locales/fi/common.json index 7020b7519b..109e59c4bb 100644 --- a/frontend/benefit/applicant/public/locales/fi/common.json +++ b/frontend/benefit/applicant/public/locales/fi/common.json @@ -86,6 +86,10 @@ "deMinimisAidMaxAmount": { "label": "Enimmäismäärä ylitetty", "content": "De minimis-tuen enimmäismäärä on ylitetty. Tuki voi olla enintään 200 000 euroa, joka myönnetään yritykselle kuluvan vuoden ja kahden edellisen verovuoden kuluessa. Enimmäismäärässä huomioidaan kaikkien eri viranomaisten kyseisenä ajanjaksona de minimis -tukena myöntämä rahoitus." + }, + "deMinimisUnfinished": { + "label": "Puuttuvia de minimis-tuen tietoja", + "content": "Täytä puuttuvat de minimis -kentät ja paina 'Lisää' painiketta." } }, "tooltips": { @@ -186,7 +190,7 @@ "salaryExpensesExplanation": "Bruttopalkka ja sivukulut ilmoitetaan euroina kuukaudessa, lomaraha kertasummana", "tooltips": { "heading5Employment": "Sovellettava työehtosopimus: esim. Kaupan työehtosopimus. Jos alalla ei ole velvoittavaa työehtosopimusta, merkitse viiva.", - "heading5EmploymentSub1": "Bruttopalkalla tarkoitetaan tuella palkattavalle maksettavaa palkkaa ennen työntekijän lakisääteisten maksujen (työntekijän työttömyysvakuutus- ja eläkevakuutusmaksu) ja verojen pidätystä. Jos tuella palkattavalle maksetaan palkan lisiä (esim. ilta-, yö- tai vuorotyölisä), ota niiden arvioitu määrä huomioon bruttopalkassa. Työnantajan lakisääteisiin sivukuluihin luetaan sosiaaliturva-, työeläkevakuutus-, tapaturmavakuutus- ja työttömyysvakuutusmaksu sekä pakollinen ryhmähenkivakuutusmaksu. Sivukulut tarkoittavat palkasta maksettavien työnantajan lakisääteisten sivukulujen määrä kuukaudessa. Lomaraha on tuella katettava palkkauskustannus silloin, kun se maksetaan tukijakson aikana pidetystä vuosilomapalkasta. Arvioi tukijakson aikana maksettavan lomarahan määrä. Lomakorvaus ei ole Helsinki-lisällä katettava korvaus.", + "heading5EmploymentSub1": "Bruttopalkalla tarkoitetaan tuella palkattavalle maksettavaa palkkaa ennen työntekijän lakisääteisten maksujen (työntekijän työttömyysvakuutus- ja eläkevakuutusmaksu) ja verojen pidätystä. Jos tuella palkattavalle maksetaan palkan lisiä (esim. ilta-, yö- tai vuorotyölisä), ota niiden arvioitu määrä huomioon bruttopalkassa. Työnantajan lakisääteisiin sivukuluihin luetaan sosiaaliturva-, työeläkevakuutus-, tapaturmavakuutus- ja työttömyysvakuutusmaksu sekä pakollinen ryhmähenkivakuutusmaksu.\r\n\r\nSivukulut tarkoittavat palkasta maksettavien työnantajan lakisääteisten sivukulujen määrä kuukaudessa.\r\n\r\nLomaraha on tuella katettava palkkauskustannus silloin, kun se maksetaan tukijakson aikana pidetystä vuosilomapalkasta. Arvioi tukijakson aikana maksettavan lomarahan määrä. Lomakorvaus ei ole Helsinki-lisällä katettava korvaus.", "heading5Assignment": "Palkkauskustannukset tooltip text", "heading2": "Palkkatuki on työttömän työnhakijan työllistymisen edistämiseksi tarkoitettu rahallinen tuki, jonka TE-palvelut voi myöntää työnantajalle palkkauskustannuksiin.", "heading3": "Työllistämisen Helsinki-lisä on tarkoitettu ohjaus-, perehdytys-, työväline-, työvaate- ja työtilakustannuksiin silloin, kun niihin ei makseta muuta tukea. Palkan Helsinki-lisä on tarkoitettu työllistetyn henkilön palkkauskustannuksiin (=bruttopalkka, lakisääteiset sivukulut ja lomaraha). Toimeksiannon Helsinki-lisä on tarkoitettu yksittäisen työn tai projektin suorittamiseen." @@ -374,7 +378,8 @@ "drawer": { "title": "Hakemuksen viestit" }, - "languageMenuButtonAriaLabel": "Valitse kieli" + "languageMenuButtonAriaLabel": "Valitse kieli", + "menuToggleAriaLabel": "Valikko" }, "footer": { "copyrightText": "Copyright", @@ -421,7 +426,6 @@ "credentialsIngress": { "text": "Helsinki-lisällä työllistettävää henkilöä on tiedotettava hänen henkilötietojensa käsittelystä. Tulosta tiedoksianto ja pyydä siihen allekirjoitus työllistettävältä." }, - "menuToggleAriaLabel": "Valikko", "form": { "validation": { "required": "Tämä kenttä on pakollinen", @@ -657,5 +661,8 @@ "heading1": "Palaute ja yhteystiedot" } } + }, + "toast": { + "closeToast": "Sulje ilmoitus" } } diff --git a/frontend/benefit/applicant/public/locales/sv/common.json b/frontend/benefit/applicant/public/locales/sv/common.json index ac3f6fcb2c..c69435c9f5 100644 --- a/frontend/benefit/applicant/public/locales/sv/common.json +++ b/frontend/benefit/applicant/public/locales/sv/common.json @@ -86,6 +86,10 @@ "deMinimisAidMaxAmount": { "label": "Maximibeloppet har överskridits", "content": "Maximibeloppet för de minimis-stöd har överskridits. Enligt EU:s förordning kan ett företag få de minimis-stöd till ett belopp om högst 200 000 euro under det innevarandet året och under de två föregående skatteåren. I maximibeloppet beaktas samtliga de minimis-stöd som olika myndigheter har beviljat under denna tidsperiod." + }, + "deMinimisUnfinished": { + "label": "Information saknar om de minimis stöd", + "content": "Vänligen fyll i eventuell saknad de minimis-stöd information och tryck på knappen 'Lägg till'." } }, "tooltips": { @@ -186,7 +190,7 @@ "salaryExpensesExplanation": "Bruttolön och bikostnader anges i euro per månad, semesterpenning som ett engångsbelopp", "tooltips": { "heading5Employment": "Kollektivavtal som tillämpas: t.ex. kollektivavtal för handeln. Sätt streck om branschen inte har ett bindande kollektivavtal.", - "heading5EmploymentSub1": "Med bruttolön avses lönen till den person som anställs med subventionen före innehållande av arbetsgivarens lagstadgade kostnader (arbetslöshets- och pensionsförsäkringspremier för arbetstagaren) och skatter. Om den person som anställs med subventionen får lönetillägg (t.ex. kvälls-, natt- eller skiftarbetstillägg), ska det uppskattade beloppet från dessa beaktas i bruttolönen. Till arbetsgivarens lagstadgade bikostnader räknas socialskyddsavgifterna och premierna för arbetspensionsförsäkringen, olycksfallsförsäkringen och arbetslöshetsförsäkringen samt den obligatoriska grupplivförsäkringen. Med bikostnader avses arbetsgivarens lagstadgade bikostnader per månad. Semesterpenning är en lönekostnad som kan täckas med subventionen till den del som semesterpenning betalas för semesterlön som används under subventionsperioden. Uppskatta semesterpenningen som betalas under subventionsperioden. Semesterpenning kan inte täckas med Helsingforstillägg.", + "heading5EmploymentSub1": "Med bruttolön avses lönen till den person som anställs med subventionen före innehållande av arbetsgivarens lagstadgade kostnader (arbetslöshets- och pensionsförsäkringspremier för arbetstagaren) och skatter. Om den person som anställs med subventionen får lönetillägg (t.ex. kvälls-, natt- eller skiftarbetstillägg), ska det uppskattade beloppet från dessa beaktas i bruttolönen. Till arbetsgivarens lagstadgade bikostnader räknas socialskyddsavgifterna och premierna för arbetspensionsförsäkringen, olycksfallsförsäkringen och arbetslöshetsförsäkringen samt den obligatoriska grupplivförsäkringen.\r\n\r\nMed bikostnader avses arbetsgivarens lagstadgade bikostnader per månad.\r\n\r\nSemesterpenning är en lönekostnad som kan täckas med subventionen till den del som semesterpenning betalas för semesterlön som används under subventionsperioden. Uppskatta semesterpenningen som betalas under subventionsperioden. Semesterpenning kan inte täckas med Helsingforstillägg.", "heading5Assignment": "Med bruttolön avses lönen till den person som anställs med subventionen före innehållande av arbetsgivarens lagstadgade kostnader (arbetslöshets- och pensionsförsäkringspremier för arbetstagaren) och skatter. Om den person som anställs med subventionen får lönetillägg (t.ex. kvälls-, natt- eller skiftarbetstillägg), ska det uppskattade beloppet från dessa beaktas i bruttolönen. Till arbetsgivarens lagstadgade bikostnader räknas socialskyddsavgifterna och premierna för arbetspensionsförsäkringen, olycksfallsförsäkringen och arbetslöshetsförsäkringen samt den obligatoriska grupplivförsäkringen. Med bikostnader avses arbetsgivarens lagstadgade bikostnader per månad. Semesterpenning är en lönekostnad som kan täckas med subventionen till den del som semesterpenning betalas för semesterlön som används under subventionsperioden. Uppskatta semesterpenningen som betalas under subventionsperioden. Semesterpenning kan inte täckas med Helsingforstillägg.", "heading2": "Lönesubvention är ett ekonomiskt stöd för främjande av sysselsättningen av en arbetslös arbetssökande som arbets- och näringslivstjänsterna kan bevilja till arbetsgivaren för lönekostnader.", "heading3": "Helsingforstillägg för sysselsättning är avsett för kostnaderna för handledning, inskolning, arbetsredskap, arbetskläder och arbetslokaler då inga andra understöd utbetalas för dessa. Helsingforstillägg för lön är avsett för lönekostnaderna för den anställda (=bruttolön, lagstadgade bikostnader och semesterpenning). Helsingforstillägg för uppdrag är avsett för utförande av ett enskilt jobb eller projekt." @@ -374,7 +378,8 @@ "drawer": { "title": "Meddelanden om ansökan" }, - "languageMenuButtonAriaLabel": "Ändra språk" + "languageMenuButtonAriaLabel": "Ändra språk", + "menuToggleAriaLabel": "Menu" }, "footer": { "copyrightText": "Copyright", @@ -421,7 +426,6 @@ "credentialsIngress": { "text": "Den person som anställs med Helsingforstillägg ska informeras om behandlingen av hens personuppgifter. Skriv ut informationen och be den anställda om underskrift." }, - "menuToggleAriaLabel": "Menu", "form": { "validation": { "required": "Detta är ett obligatoriskt fält", @@ -657,5 +661,8 @@ "heading1": "Palaute ja yhteystiedot" } } + }, + "toast": { + "closeToast": "Stäng avisering" } } diff --git a/frontend/benefit/applicant/src/components/applications/Applications.sc.ts b/frontend/benefit/applicant/src/components/applications/Applications.sc.ts index acdfffbe64..896b402afc 100644 --- a/frontend/benefit/applicant/src/components/applications/Applications.sc.ts +++ b/frontend/benefit/applicant/src/components/applications/Applications.sc.ts @@ -1,14 +1,21 @@ +import { respondAbove } from 'shared/styles/mediaQueries'; import styled from 'styled-components'; export const $PageHeader = styled.div` - display: flex; - justify-content: space-between; - align-items: start; + display: block; margin-bottom: ${(props) => props.theme.spacing.s}; margin-top: ${(props) => props.theme.spacing.m}; - & > div { - flex: 1 0 50%; + + & > * { + margin-bottom: ${(props) => props.theme.spacing.m}; } + + ${respondAbove('md')` + flex: 1 0 100%; + display: flex; + justify-content: space-between; + align-items: start; + `}; `; export const $HeaderItem = styled.div``; diff --git a/frontend/benefit/applicant/src/components/applications/forms/application/deMinimisAid/DeMinimisAidForm.tsx b/frontend/benefit/applicant/src/components/applications/forms/application/deMinimisAid/DeMinimisAidForm.tsx index 95dd9dae9a..95f1558b8a 100644 --- a/frontend/benefit/applicant/src/components/applications/forms/application/deMinimisAid/DeMinimisAidForm.tsx +++ b/frontend/benefit/applicant/src/components/applications/forms/application/deMinimisAid/DeMinimisAidForm.tsx @@ -23,9 +23,13 @@ import { useDeminimisAid } from './useDeminimisAid'; interface DeMinimisAidFormProps { data: DeMinimisAid[]; + setUnfinishedDeMinimisAid: React.Dispatch>; } -const DeMinimisAidForm: React.FC = ({ data }) => { +const DeMinimisAidForm: React.FC = ({ + data, + setUnfinishedDeMinimisAid, +}) => { const { t, language, @@ -38,6 +42,26 @@ const DeMinimisAidForm: React.FC = ({ data }) => { } = useDeminimisAid(data); const theme = useTheme(); + const onSubmit = ( + event: React.MouseEvent + ): void => { + setUnfinishedDeMinimisAid(false); + return handleSubmit(event); + }; + + const handleBlur = ( + event: React.FocusEvent + ): void => { + setUnfinishedDeMinimisAid(false); + const grantValuesAsString = Object.values(formik.values).reduce( + (acc, val) => acc + val + ); + if (grantValuesAsString !== '') { + setUnfinishedDeMinimisAid(true); + } + formik.handleBlur(event); + }; + return ( <$GridCell $colStart={3} @@ -65,7 +89,7 @@ const DeMinimisAidForm: React.FC = ({ data }) => { name={fields.granter.name} label={fields.granter.label} placeholder={fields.granter.placeholder} - onBlur={formik.handleBlur} + onBlur={(event) => handleBlur(event)} onChange={formik.handleChange} value={formik.values.granter} invalid={!!getErrorMessage(DE_MINIMIS_AID_KEYS.GRANTER)} @@ -80,7 +104,7 @@ const DeMinimisAidForm: React.FC = ({ data }) => { name={fields.amount.name} label={fields.amount.label || ''} placeholder={fields.amount.placeholder} - onBlur={formik.handleBlur} + onBlur={(event) => handleBlur(event)} onChange={(e) => formik.setFieldValue( fields.amount.name, @@ -101,7 +125,7 @@ const DeMinimisAidForm: React.FC = ({ data }) => { label={fields.grantedAt.label} placeholder={fields.grantedAt.placeholder} language={language} - onBlur={formik.handleBlur} + onBlur={(event) => handleBlur(event)} onChange={(value) => formik.setFieldValue(fields.grantedAt.name, value) } @@ -133,7 +157,7 @@ const DeMinimisAidForm: React.FC = ({ data }) => { !formik.isValid || sumBy(grants, 'amount') > MAX_DEMINIMIS_AID_TOTAL_AMOUNT } - onClick={(e) => handleSubmit(e)} + onClick={(e) => onSubmit(e)} iconLeft={} fullWidth > diff --git a/frontend/benefit/applicant/src/components/applications/forms/application/step1/ApplicationFormStep1.tsx b/frontend/benefit/applicant/src/components/applications/forms/application/step1/ApplicationFormStep1.tsx index 8344d8b03a..2a77e86bf3 100644 --- a/frontend/benefit/applicant/src/components/applications/forms/application/step1/ApplicationFormStep1.tsx +++ b/frontend/benefit/applicant/src/components/applications/forms/application/step1/ApplicationFormStep1.tsx @@ -20,6 +20,9 @@ import { useApplicationFormStep1 } from './useApplicationFormStep1'; const ApplicationFormStep1: React.FC = ({ data, }) => { + const [isUnfinishedDeMinimisAid, setUnfinishedDeMinimisAid] = + React.useState(false); + const { t, handleSubmit, @@ -34,7 +37,7 @@ const ApplicationFormStep1: React.FC = ({ translationsBase, formik, deMinimisAidSet, - } = useApplicationFormStep1(data); + } = useApplicationFormStep1(data, Boolean(isUnfinishedDeMinimisAid)); useAlertBeforeLeaving(formik.dirty); @@ -161,56 +164,59 @@ const ApplicationFormStep1: React.FC = ({ /> - {showDeminimisSection && ( - - <$GridCell $colSpan={8}> - - <$RadioButton - id={`${fields.deMinimisAid.name}False`} - name={fields.deMinimisAid.name} - value="false" - label={t( - `${translationsBase}.fields.${APPLICATION_FIELDS_STEP1_KEYS.DE_MINIMIS_AID}.no` - )} - onChange={() => { - formik.setFieldValue(fields.deMinimisAid.name, false); - setDeMinimisAids([]); - }} - // 3 states: null (none is selected), true, false - checked={formik.values.deMinimisAid === false} - /> - <$RadioButton - id={`${fields.deMinimisAid.name}True`} - name={fields.deMinimisAid.name} - value="true" - label={t( - `${translationsBase}.fields.${APPLICATION_FIELDS_STEP1_KEYS.DE_MINIMIS_AID}.yes` - )} - onChange={() => - formik.setFieldValue(fields.deMinimisAid.name, true) - } - checked={formik.values.deMinimisAid === true} - /> - - + {showDeminimisSection && ( + + <$GridCell $colSpan={8}> + + <$RadioButton + id={`${fields.deMinimisAid.name}False`} + name={fields.deMinimisAid.name} + value="false" + label={t( + `${translationsBase}.fields.${APPLICATION_FIELDS_STEP1_KEYS.DE_MINIMIS_AID}.no` + )} + onChange={() => { + formik.setFieldValue(fields.deMinimisAid.name, false); + setDeMinimisAids([]); + }} + // 3 states: null (none is selected), true, false + checked={formik.values.deMinimisAid === false} + /> + <$RadioButton + id={`${fields.deMinimisAid.name}True`} + name={fields.deMinimisAid.name} + value="true" + label={t( + `${translationsBase}.fields.${APPLICATION_FIELDS_STEP1_KEYS.DE_MINIMIS_AID}.yes` + )} + onChange={() => + formik.setFieldValue(fields.deMinimisAid.name, true) + } + checked={formik.values.deMinimisAid === true} + /> + + - {formik.values.deMinimisAid && ( - <> - - - - )} - - )} + {formik.values.deMinimisAid && ( + <> + + + + )} + + )} <$GridCell $colSpan={8}> + application: Partial, + isUnfinishedDeminimisAid: boolean ): ExtendedComponentProps => { const { t } = useTranslation(); - const { setDeMinimisAids } = React.useContext(DeMinimisContext); + const { deMinimisAids, setDeMinimisAids } = + React.useContext(DeMinimisContext); const { onNext, onSave, onDelete } = useFormActions(application); const translationsBase = 'common:applications.sections.company'; @@ -103,6 +106,20 @@ const useApplicationFormStep1 = ( return focusAndScroll(errorFieldKey); } + if (isUnfinishedDeminimisAid) { + showErrorToast( + t(`${translationsBase}.deMinimisUnfinished.label`), + t(`${translationsBase}.deMinimisUnfinished.content`) + ); + return false; + } + + // de minimis fields are empty, set radio to false + if (deMinimisAids.length === 0) { + void formik.setFieldValue(fields.deMinimisAidSet.name, []); + void formik.setFieldValue(fields.deMinimisAid.name, false); + } + return formik.submitForm(); }); }; @@ -114,8 +131,8 @@ const useApplicationFormStep1 = ( const applicationId = values?.id; const handleDelete = applicationId ? () => { - void onDelete(applicationId); - } + void onDelete(applicationId); + } : undefined; const clearDeminimisAids = React.useCallback((): void => { diff --git a/frontend/benefit/applicant/src/components/applications/forms/application/step4/ApplicationFormStep4.tsx b/frontend/benefit/applicant/src/components/applications/forms/application/step4/ApplicationFormStep4.tsx index d254c886a0..12580d7bfb 100644 --- a/frontend/benefit/applicant/src/components/applications/forms/application/step4/ApplicationFormStep4.tsx +++ b/frontend/benefit/applicant/src/components/applications/forms/application/step4/ApplicationFormStep4.tsx @@ -75,7 +75,7 @@ const ApplicationFormStep4: React.FC = ({ /> )} - <$GridCell $colSpan={6}> + <$GridCell $colSpan={12}> = ({ {t(`${translationsBase}.uploadPowerOfAttorney.action1`)} - <$GridCell $colSpan={6}> + <$GridCell $colSpan={8}> { } return ( - + <$PageHeader> <$HeaderItem> <$PageHeading> diff --git a/frontend/benefit/applicant/src/components/header/useHeader.ts b/frontend/benefit/applicant/src/components/header/useHeader.ts index 986f2bfebb..9877f076b9 100644 --- a/frontend/benefit/applicant/src/components/header/useHeader.ts +++ b/frontend/benefit/applicant/src/components/header/useHeader.ts @@ -1,10 +1,12 @@ import useApplicationQuery from 'benefit/applicant/hooks/useApplicationQuery'; import { useTranslation } from 'benefit/applicant/i18n'; import { getLanguageOptions } from 'benefit/applicant/utils/common'; +import { BackendEndpoint } from 'benefit-shared/backend-api/backend-api'; import { APPLICATION_STATUSES } from 'benefit-shared/constants'; import { useRouter } from 'next/router'; import { TFunction } from 'next-i18next'; import React, { useEffect, useState } from 'react'; +import useBackendAPI from 'shared/hooks/useBackendAPI'; import useToggle from 'shared/hooks/useToggle'; import { NavigationItem, OptionType } from 'shared/types/common'; @@ -27,6 +29,8 @@ const useHeader = (): ExtendedComponentProps => { const { t } = useTranslation(); const router = useRouter(); const id = router?.query?.id?.toString() ?? ''; + const { axios } = useBackendAPI(); + const [hasMessenger, setHasMessenger] = useState(false); const [unreadMessagesCount, setUnredMessagesCount] = useState< number | undefined | null @@ -75,6 +79,10 @@ const useHeader = (): ExtendedComponentProps => { ): void => { e.preventDefault(); + void axios.get(BackendEndpoint.USER_OPTIONS, { + params: { lang: newLanguage.value }, + }); + void router.push({ pathname, query }, asPath, { locale: newLanguage.value, }); diff --git a/frontend/benefit/applicant/src/components/messenger/Messages.tsx b/frontend/benefit/applicant/src/components/messenger/Messages.tsx index 488a367495..97a255236e 100644 --- a/frontend/benefit/applicant/src/components/messenger/Messages.tsx +++ b/frontend/benefit/applicant/src/components/messenger/Messages.tsx @@ -38,6 +38,8 @@ const Messages: React.FC = ({ data, variant, withScroll }) => { date={message.modifiedAt || ''} text={message.content} isPrimary={message.messageType === MESSAGE_TYPES.APPLICANT_MESSAGE} + wrapAsColumn + alignRight={message.messageType === MESSAGE_TYPES.APPLICANT_MESSAGE} variant={variant} /> ))} diff --git a/frontend/benefit/applicant/src/constants.ts b/frontend/benefit/applicant/src/constants.ts index ad14473e56..99fbb5e75e 100644 --- a/frontend/benefit/applicant/src/constants.ts +++ b/frontend/benefit/applicant/src/constants.ts @@ -113,4 +113,5 @@ export const SUBMITTED_STATUSES = [ export enum LOCAL_STORAGE_KEYS { IS_TERMS_OF_SERVICE_APPROVED = 'isTermsOfServiceApproved', + CSRF_TOKEN = 'csrfToken', } diff --git a/frontend/benefit/applicant/src/hooks/useUserQuery.ts b/frontend/benefit/applicant/src/hooks/useUserQuery.ts index ace1549ef8..c3958671ca 100644 --- a/frontend/benefit/applicant/src/hooks/useUserQuery.ts +++ b/frontend/benefit/applicant/src/hooks/useUserQuery.ts @@ -7,6 +7,7 @@ import { useQuery, UseQueryResult } from 'react-query'; import showErrorToast from 'shared/components/toast/show-error-toast'; import useBackendAPI from 'shared/hooks/useBackendAPI'; import useLocale from 'shared/hooks/useLocale'; +import { setLocalStorageItem } from 'shared/utils/localstorage.utils'; import { LOCAL_STORAGE_KEYS } from '../constants'; @@ -51,9 +52,9 @@ const useUserQuery = ( select: (data) => camelcaseKeys(data, { deep: true }), onError: (error) => handleError(error), onSuccess: (data) => { + setLocalStorageItem(LOCAL_STORAGE_KEYS.CSRF_TOKEN, data.csrfToken); if (data.id && data.termsOfServiceApprovalNeeded) - // eslint-disable-next-line scanjs-rules/identifier_localStorage - localStorage.setItem( + setLocalStorageItem( LOCAL_STORAGE_KEYS.IS_TERMS_OF_SERVICE_APPROVED, 'false' ); diff --git a/frontend/benefit/applicant/src/pages/_app.tsx b/frontend/benefit/applicant/src/pages/_app.tsx index 97c401d21d..e0be75f92d 100644 --- a/frontend/benefit/applicant/src/pages/_app.tsx +++ b/frontend/benefit/applicant/src/pages/_app.tsx @@ -13,8 +13,8 @@ import { import { setAppLoaded } from 'benefit-shared/utils/common'; import { AppProps } from 'next/app'; import { useRouter } from 'next/router'; +import { useTranslation } from 'next-i18next'; import React, { useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; import { QueryClient, QueryClientProvider } from 'react-query'; import BackendAPIProvider from 'shared/backend-api/BackendAPIProvider'; import BaseApp from 'shared/components/app/BaseApp'; @@ -56,6 +56,7 @@ const App: React.FC = (appProps) => { diff --git a/frontend/benefit/applicant/src/pages/login.tsx b/frontend/benefit/applicant/src/pages/login.tsx index d86b1e0012..9be28bea44 100644 --- a/frontend/benefit/applicant/src/pages/login.tsx +++ b/frontend/benefit/applicant/src/pages/login.tsx @@ -23,8 +23,9 @@ import { $GridCell, } from 'shared/components/forms/section/FormSection.sc'; import getServerSideTranslations from 'shared/i18n/get-server-side-translations'; +import { removeLocalStorageItem } from 'shared/utils/localstorage.utils'; -import { IS_CLIENT, LOCAL_STORAGE_KEYS } from '../constants'; +import { LOCAL_STORAGE_KEYS } from '../constants'; type NotificationProps = | (Pick & { @@ -60,9 +61,8 @@ const Login: NextPage = () => { }, [logout, queryClient]); useEffect(() => { - if (IS_CLIENT) - // eslint-disable-next-line scanjs-rules/identifier_localStorage - localStorage.removeItem(LOCAL_STORAGE_KEYS.IS_TERMS_OF_SERVICE_APPROVED); + removeLocalStorageItem(LOCAL_STORAGE_KEYS.IS_TERMS_OF_SERVICE_APPROVED); + removeLocalStorageItem(LOCAL_STORAGE_KEYS.CSRF_TOKEN); }, []); return ( diff --git a/frontend/benefit/applicant/src/styles/globals.css b/frontend/benefit/applicant/src/styles/globals.css index cf05c00a9d..555a33b824 100644 --- a/frontend/benefit/applicant/src/styles/globals.css +++ b/frontend/benefit/applicant/src/styles/globals.css @@ -17,3 +17,8 @@ li { .app-load-wrapper > div { display: none; } + +/* Allow HDS tooltip's content to break new line on every \r\n */ +[class*='Tooltip-module'] section[class*='Tooltip-module_tooltip'] { + white-space: pre-line; +} diff --git a/frontend/benefit/handler/jest.config.js b/frontend/benefit/handler/jest.config.js index 0c71ada50a..da39fc59f5 100644 --- a/frontend/benefit/handler/jest.config.js +++ b/frontend/benefit/handler/jest.config.js @@ -1,11 +1,12 @@ const sharedConfig = require('../../jest.config.js'); -module.exports = { +const nextJest = require('next/jest'); + +const createJestConfig = nextJest({ + dir: './', +}); + +const config = { ...sharedConfig, - globals: { - 'ts-jest': { - tsconfig: '/tsconfig.jest.json', - }, - }, moduleNameMapper: { [`^shared\/(.*)$`]: '/../../shared/src/$1', [`^benefit-shared\/(.*)$`]: '../shared/src/$1', @@ -21,3 +22,5 @@ module.exports = { '/../../shared/src/test/', ], }; + +module.exports = createJestConfig(config); diff --git a/frontend/benefit/handler/next-env.d.ts b/frontend/benefit/handler/next-env.d.ts index 9bc3dd46b9..4f11a03dc6 100644 --- a/frontend/benefit/handler/next-env.d.ts +++ b/frontend/benefit/handler/next-env.d.ts @@ -1,5 +1,4 @@ /// -/// /// // NOTE: This file should not be edited diff --git a/frontend/benefit/handler/package.json b/frontend/benefit/handler/package.json index 9c463613e7..fbdfaa174f 100644 --- a/frontend/benefit/handler/package.json +++ b/frontend/benefit/handler/package.json @@ -11,15 +11,15 @@ "test": "jest --passWithNoTests", "test:staged": "yarn test --watchAll=false --findRelatedTests", "test:coverage": "yarn test --verbose --coverage", - "browser-test": "testcafe 'chrome --allow-insecure-localhost --disable-web-security --ignore-certificate-errors --ignore-urlfetcher-cert-requests --window-size=\"1249,720\"' browser-tests/pages/", - "browser-test:ci": "testcafe 'chrome:headless --allow-insecure-localhost --disable-web-security --ignore-certificate-errors --ignore-urlfetcher-cert-requests --window-size=\"1249,720\"' --screenshots path=report --video report --reporter spec,custom,html:report/index.html browser-tests/pages/" + "browser-test": "testcafe 'chrome --allow-insecure-localhost --disable-web-security --ignore-certificate-errors --ignore-urlfetcher-cert-requests --window-size=\"1249,720\"' browser-tests/pages/ --experimental-proxyless", + "browser-test:ci": "testcafe 'chrome:headless --allow-insecure-localhost --disable-web-security --ignore-certificate-errors --ignore-urlfetcher-cert-requests --window-size=\"1249,720\"' --screenshots path=report --video report --reporter spec,custom,html:report/index.html browser-tests/pages/ --experimental-proxyless" }, "dependencies": { "@frontend/benefit-shared": "*", "@frontend/shared": "*", "@sentry/browser": "^7.16.0", "axios": "^0.27.2", - "babel-plugin-import": "^1.13.3", + "babel-plugin-import": "^1.13.6", "camelcase-keys": "^7.0.2", "date-fns": "^2.24.0", "dotenv": "^16.0.0", @@ -29,11 +29,11 @@ "fuse.js": "^6.6.2", "hds-react": "^2.10.0", "lodash": "^4.17.21", - "next": "^11.1.4", + "next": "^12.3.4", "next-compose-plugins": "^2.2.1", - "next-i18next": "^10.5.0", + "next-i18next": "^13.0.3", "next-plugin-custom-babel-config": "^1.0.5", - "next-transpile-modules": "^9.0.0", + "next-transpile-modules": "^9.1.0", "react": "^18.0.0", "react-dom": "^18.0.0", "react-input-mask": "^2.0.4", diff --git a/frontend/benefit/handler/public/favicons/manifest.webmanifest b/frontend/benefit/handler/public/favicons/manifest.webmanifest index 5e0299b4af..267bb8ff36 100644 --- a/frontend/benefit/handler/public/favicons/manifest.webmanifest +++ b/frontend/benefit/handler/public/favicons/manifest.webmanifest @@ -1,6 +1,14 @@ { - "icons": [ - { "src": "/favicon-192x192.png", "type": "image/png", "sizes": "192x192" }, - { "src": "/favicon-512x512.png", "type": "image/png", "sizes": "512x512" } - ] -} \ No newline at end of file + "icons": [ + { + "src": "/favicons/favicon-192x192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "/favicons/favicon-512x512.png", + "type": "image/png", + "sizes": "512x512" + } + ] +} diff --git a/frontend/benefit/handler/public/locales/en/common.json b/frontend/benefit/handler/public/locales/en/common.json index 487d9beb95..07c5643ccc 100644 --- a/frontend/benefit/handler/public/locales/en/common.json +++ b/frontend/benefit/handler/public/locales/en/common.json @@ -32,6 +32,11 @@ } } }, + "titles": { + "applicantMessage": "Hakija", + "handlerMessage": "Käsittelijä", + "note": "" + }, "showEveryone": "Nämä viestit näkyvät hakijalle", "showToHanlderOnly": "Näkyy vain käsittelijöille", "compose": "Kirjoita viesti", @@ -640,7 +645,7 @@ "workingHours": "Työaika: {{workingHours}} tuntia viikossa", "monthlyPay": "Bruttopalkka: {{monthlyPay}} € / kk", "otherExpenses": "Sivukulut: {{otherExpenses}} € / kk", - "vacationMoney": "Lomaraha: {{vacationMoney}} €", + "vacationMoney": "Lomaraha: {{vacationMoney}} € / kk", "noSupport": "Ei puolleta", "support": "Puolletaan", "reason": "Perustelu", @@ -680,7 +685,10 @@ "label": "Alkaen", "placeholder": "Alkaen" }, - "endDate": { "label": "Päättyen", "placeholder": "Päättyen" }, + "endDate": { + "label": "Päättyen", + "placeholder": "Päättyen" + }, "monthlyPay": { "label": "Bruttopalkka € / kk", "placeholder": "Bruttopalkka" @@ -690,7 +698,7 @@ "placeholder": "Sivukulut" }, "vacationMoney": { - "label": "Lomaraha €", + "label": "Lomaraha € / kk", "placeholder": "Lomaraha" }, "stateAidMaxPercentage": { @@ -741,7 +749,7 @@ "header": "Palkan Helsinki-lisää haettu ajalle", "monthlyPay": "Bruttopalkka € / kk", "otherExpenses": "Sivukulut € / kk", - "vacationMoney": "Lomaraha €", + "vacationMoney": "Lomaraha € / kk", "startEndDates": "{{startDate}} - {{endDate}} ({{period}} kk)", "maximumAid": "Valtiotukimaksimi", "salarySubsidyPercentage": "Palkkatukiprosentti", @@ -822,7 +830,8 @@ "number": { "invalid": "Virheellinen arvo, ilmoita vain numeroita", "max": "Arvon tulee olla enintään {{max}}", - "min": "Arvon tulee olla vähintään {{min}}" + "min": "Arvon tulee olla vähintään {{min}}", + "twoDecimals": "Arvon tulee sisältää enintään kaksi desimaalia" }, "string": { "max": "Tämä kenttä voi olla korkeintaan {{max}} merkkiä pitkä", @@ -1012,6 +1021,10 @@ "p2pInspectorEmail": "Tarkastajan sähköposti, P2P", "p2pCheckerName": "Hyväksyjän nimi, P2P" }, + "headings": { + "decisionDetails": "Päätöksen tiedot", + "inspectionDetails": "Tarkastuksen tiedot" + }, "errors": { "decision_maker_name": "Pakollinen tieto", "decision_maker_title": "Pakollinen tieto", @@ -1034,4 +1047,4 @@ }, "keyword": "Hakusana" } -} +} \ No newline at end of file diff --git a/frontend/benefit/handler/public/locales/fi/common.json b/frontend/benefit/handler/public/locales/fi/common.json index af510b57d3..04cf8c7bf2 100644 --- a/frontend/benefit/handler/public/locales/fi/common.json +++ b/frontend/benefit/handler/public/locales/fi/common.json @@ -32,6 +32,11 @@ } } }, + "titles": { + "applicantMessage": "Hakija", + "handlerMessage": "Käsittelijä", + "note": "" + }, "showEveryone": "Nämä viestit näkyvät hakijalle", "showToHanlderOnly": "Näkyy vain käsittelijöille", "compose": "Kirjoita viesti", @@ -640,7 +645,7 @@ "workingHours": "Työaika: {{workingHours}} tuntia viikossa", "monthlyPay": "Bruttopalkka: {{monthlyPay}} € / kk", "otherExpenses": "Sivukulut: {{otherExpenses}} € / kk", - "vacationMoney": "Lomaraha: {{vacationMoney}} €", + "vacationMoney": "Lomaraha: {{vacationMoney}} € / kk", "noSupport": "Ei puolleta", "support": "Puolletaan", "reason": "Perustelu", @@ -680,7 +685,10 @@ "label": "Alkaen", "placeholder": "Alkaen" }, - "endDate": { "label": "Päättyen", "placeholder": "Päättyen" }, + "endDate": { + "label": "Päättyen", + "placeholder": "Päättyen" + }, "monthlyPay": { "label": "Bruttopalkka € / kk", "placeholder": "Bruttopalkka" @@ -690,7 +698,7 @@ "placeholder": "Sivukulut" }, "vacationMoney": { - "label": "Lomaraha €", + "label": "Lomaraha € / kk", "placeholder": "Lomaraha" }, "stateAidMaxPercentage": { @@ -741,7 +749,7 @@ "header": "Palkan Helsinki-lisää haettu ajalle", "monthlyPay": "Bruttopalkka € / kk", "otherExpenses": "Sivukulut € / kk", - "vacationMoney": "Lomaraha €", + "vacationMoney": "Lomaraha € / kk", "startEndDates": "{{startDate}} - {{endDate}} ({{period}} kk)", "maximumAid": "Valtiotukimaksimi", "salarySubsidyPercentage": "Palkkatukiprosentti", @@ -822,7 +830,8 @@ "number": { "invalid": "Virheellinen arvo, ilmoita vain numeroita", "max": "Arvon tulee olla enintään {{max}}", - "min": "Arvon tulee olla vähintään {{min}}" + "min": "Arvon tulee olla vähintään {{min}}", + "twoDecimals": "Arvon tulee sisältää enintään kaksi desimaalia" }, "string": { "max": "Tämä kenttä voi olla korkeintaan {{max}} merkkiä pitkä", @@ -1012,6 +1021,10 @@ "p2pInspectorEmail": "Tarkastajan sähköposti, P2P", "p2pCheckerName": "Hyväksyjän nimi, P2P" }, + "headings": { + "decisionDetails": "Päätöksen tiedot", + "inspectionDetails": "Tarkastuksen tiedot" + }, "errors": { "decision_maker_name": "Pakollinen tieto", "decision_maker_title": "Pakollinen tieto", @@ -1034,4 +1047,4 @@ }, "keyword": "Hakusana" } -} +} \ No newline at end of file diff --git a/frontend/benefit/handler/public/locales/sv/common.json b/frontend/benefit/handler/public/locales/sv/common.json index 07d47919da..85ff27375d 100644 --- a/frontend/benefit/handler/public/locales/sv/common.json +++ b/frontend/benefit/handler/public/locales/sv/common.json @@ -32,6 +32,11 @@ } } }, + "titles": { + "applicantMessage": "Hakija", + "handlerMessage": "Käsittelijä", + "note": "" + }, "showEveryone": "Nämä viestit näkyvät hakijalle", "showToHanlderOnly": "Näkyy vain käsittelijöille", "compose": "Kirjoita viesti", @@ -646,7 +651,7 @@ "workingHours": "Työaika: {{workingHours}} tuntia viikossa", "monthlyPay": "Bruttopalkka: {{monthlyPay}} € / kk", "otherExpenses": "Sivukulut: {{otherExpenses}} € / kk", - "vacationMoney": "Lomaraha: {{vacationMoney}} €", + "vacationMoney": "Lomaraha: {{vacationMoney}} € / kk", "noSupport": "Ei puolleta", "support": "Puolletaan", "reason": "Perustelu", @@ -686,7 +691,10 @@ "label": "Alkaen", "placeholder": "Alkaen" }, - "endDate": { "label": "Päättyen", "placeholder": "Päättyen" }, + "endDate": { + "label": "Päättyen", + "placeholder": "Päättyen" + }, "monthlyPay": { "label": "Bruttopalkka € / kk", "placeholder": "Bruttopalkka" @@ -696,7 +704,7 @@ "placeholder": "Sivukulut" }, "vacationMoney": { - "label": "Lomaraha €", + "label": "Lomaraha € / kk", "placeholder": "Lomaraha" }, "stateAidMaxPercentage": { @@ -747,7 +755,7 @@ "header": "Palkan Helsinki-lisää haettu ajalle", "monthlyPay": "Bruttopalkka € / kk", "otherExpenses": "Sivukulut € / kk", - "vacationMoney": "Lomaraha €", + "vacationMoney": "Lomaraha € / kk", "startEndDates": "{{startDate}} - {{endDate}} ({{period}} kk)", "maximumAid": "Valtiotukimaksimi", "salarySubsidyPercentage": "Palkkatukiprosentti", @@ -828,7 +836,8 @@ "number": { "invalid": "Virheellinen arvo, ilmoita vain numeroita", "max": "Arvon tulee olla enintään {{max}}", - "min": "Arvon tulee olla vähintään {{min}}" + "min": "Arvon tulee olla vähintään {{min}}", + "twoDecimals": "Arvon tulee sisältää enintään kaksi desimaalia" }, "string": { "max": "Tämä kenttä voi olla korkeintaan {{max}} merkkiä pitkä", @@ -1018,6 +1027,10 @@ "p2pInspectorEmail": "Tarkastajan sähköposti, P2P", "p2pCheckerName": "Hyväksyjän nimi, P2P" }, + "headings": { + "decisionDetails": "Päätöksen tiedot", + "inspectionDetails": "Tarkastuksen tiedot" + }, "errors": { "decision_maker_name": "Pakollinen tieto", "decision_maker_title": "Pakollinen tieto", @@ -1040,4 +1053,4 @@ }, "keyword": "Hakusana" } -} +} \ No newline at end of file diff --git a/frontend/benefit/handler/src/auth/AuthProvider.tsx b/frontend/benefit/handler/src/auth/AuthProvider.tsx index 14583505e9..103a2b400b 100644 --- a/frontend/benefit/handler/src/auth/AuthProvider.tsx +++ b/frontend/benefit/handler/src/auth/AuthProvider.tsx @@ -5,11 +5,11 @@ import AuthContext from 'shared/auth/AuthContext'; const AuthProvider = ({ children, }: React.PropsWithChildren

): JSX.Element => { - const userQuery = useUserQuery((user) => Boolean(user)); + const userQuery = useUserQuery((user) => user); return ( { const { application, @@ -41,6 +43,8 @@ const ApplicationReview: React.FC = () => { t, isUploading, handleUpload, + reviewState, + handleUpdateReviewState, } = useApplicationReview(); const theme = useTheme(); @@ -92,45 +96,52 @@ const ApplicationReview: React.FC = () => { })} )} - - - - - - - - - {application.applicationOrigin === APPLICATION_ORIGINS.HANDLER ? ( - - ) : ( - + + + + + - )} - {application.status === APPLICATION_STATUSES.HANDLING && ( - <> - - - - )} - {application.status && - HANDLED_STATUSES.includes(application.status) && ( - + + + + {application.applicationOrigin === APPLICATION_ORIGINS.HANDLER ? ( + + ) : ( + + )} + {application.status === APPLICATION_STATUSES.HANDLING && ( + <> + + + )} + {application.status && + HANDLED_STATUSES.includes(application.status) && ( + + )} + {application.status === APPLICATION_STATUSES.RECEIVED && ( diff --git a/frontend/benefit/handler/src/components/applicationReview/actions/handlingApplicationActions/HandlingApplicationActions.tsx b/frontend/benefit/handler/src/components/applicationReview/actions/handlingApplicationActions/HandlingApplicationActions.tsx index 964edece1d..adfc291336 100644 --- a/frontend/benefit/handler/src/components/applicationReview/actions/handlingApplicationActions/HandlingApplicationActions.tsx +++ b/frontend/benefit/handler/src/components/applicationReview/actions/handlingApplicationActions/HandlingApplicationActions.tsx @@ -63,18 +63,21 @@ const HandlingApplicationActions: React.FC = ({ }` )} - {(application.status === APPLICATION_STATUSES.ACCEPTED || - application.status === APPLICATION_STATUSES.REJECTED) && ( - - )} + {[ + APPLICATION_STATUSES.ACCEPTED, + APPLICATION_STATUSES.REJECTED, + ].includes(application.status) && + !application.batch && + !application.archived && ( + + )} - {application.status !== APPLICATION_STATUSES.CANCELLED && ( - <$Column> - - - )} + {application.status !== APPLICATION_STATUSES.CANCELLED && + !application.batch && + !application.archived && ( + <$Column> + + + )} {isConfirmationModalOpen && ( = ({ data }) => { withoutDivider header={t(`${translationsBase}.headings.heading7`)} action={data.status !== APPLICATION_STATUSES.RECEIVED ? : null} + section='benefit' > <$GridCell $colSpan={6}> <$ViewField> diff --git a/frontend/benefit/handler/src/components/applicationReview/coOperationNegotiationsView/CoOperationNegotiationsView.tsx b/frontend/benefit/handler/src/components/applicationReview/coOperationNegotiationsView/CoOperationNegotiationsView.tsx index a27c2464e2..1271fdce1a 100644 --- a/frontend/benefit/handler/src/components/applicationReview/coOperationNegotiationsView/CoOperationNegotiationsView.tsx +++ b/frontend/benefit/handler/src/components/applicationReview/coOperationNegotiationsView/CoOperationNegotiationsView.tsx @@ -18,6 +18,7 @@ const CoOperationNegotiationsView: React.FC = ({ : null} + section='coOperationNegotiations' > <$GridCell $colSpan={12}> <$ViewField> diff --git a/frontend/benefit/handler/src/components/applicationReview/companyInfoView/CompanyInfoView.tsx b/frontend/benefit/handler/src/components/applicationReview/companyInfoView/CompanyInfoView.tsx index b879800627..3329b7d19b 100644 --- a/frontend/benefit/handler/src/components/applicationReview/companyInfoView/CompanyInfoView.tsx +++ b/frontend/benefit/handler/src/components/applicationReview/companyInfoView/CompanyInfoView.tsx @@ -21,6 +21,7 @@ const CompanyInfoView: React.FC = ({ data }) => { : null} + section='company' > <$GridCell $colSpan={3}> <$ViewField>{data.company?.name} diff --git a/frontend/benefit/handler/src/components/applicationReview/consentView/ConsentView.tsx b/frontend/benefit/handler/src/components/applicationReview/consentView/ConsentView.tsx index 4a99b27af6..d9f601a4ef 100644 --- a/frontend/benefit/handler/src/components/applicationReview/consentView/ConsentView.tsx +++ b/frontend/benefit/handler/src/components/applicationReview/consentView/ConsentView.tsx @@ -21,6 +21,7 @@ const ConsentView: React.FC = ({ return ( = ({ data }) => { : null} + section='companyContactPerson' > <$GridCell $colSpan={6}> <$ViewField> diff --git a/frontend/benefit/handler/src/components/applicationReview/deminimisView/DeminimisView.tsx b/frontend/benefit/handler/src/components/applicationReview/deminimisView/DeminimisView.tsx index 7a3a2d1581..475bc71862 100644 --- a/frontend/benefit/handler/src/components/applicationReview/deminimisView/DeminimisView.tsx +++ b/frontend/benefit/handler/src/components/applicationReview/deminimisView/DeminimisView.tsx @@ -21,6 +21,7 @@ const DeminimisView: React.FC = ({ data }) => { : null} + section='deMinimisAids' > {data.deMinimisAidSet && data.deMinimisAidSet?.length > 0 ? ( <> diff --git a/frontend/benefit/handler/src/components/applicationReview/employeeView/EmployeeView.tsx b/frontend/benefit/handler/src/components/applicationReview/employeeView/EmployeeView.tsx index f57f42944c..1ca44308e0 100644 --- a/frontend/benefit/handler/src/components/applicationReview/employeeView/EmployeeView.tsx +++ b/frontend/benefit/handler/src/components/applicationReview/employeeView/EmployeeView.tsx @@ -26,6 +26,7 @@ const EmployeeView: React.FC = ({ return ( => Yup.object().shape({ [CALCULATION_EMPLOYMENT_KEYS.START_DATE]: Yup.string().typeError( VALIDATION_MESSAGE_KEYS.DATE_FORMAT - ), + ).required(VALIDATION_MESSAGE_KEYS.REQUIRED), [CALCULATION_EMPLOYMENT_KEYS.END_DATE]: Yup.string().typeError( VALIDATION_MESSAGE_KEYS.DATE_FORMAT - ), + ).required(VALIDATION_MESSAGE_KEYS.REQUIRED), }); diff --git a/frontend/benefit/handler/src/components/applicationReview/employmentView/EmpoymentView.tsx b/frontend/benefit/handler/src/components/applicationReview/employmentView/EmpoymentView.tsx index 149fc2ca52..db0b56fb52 100644 --- a/frontend/benefit/handler/src/components/applicationReview/employmentView/EmpoymentView.tsx +++ b/frontend/benefit/handler/src/components/applicationReview/employmentView/EmpoymentView.tsx @@ -23,6 +23,7 @@ const EmploymentView: React.FC = ({ return ( = ({ return ( - <$GridCell $colStart={1} $colSpan={2}> - <$GridCell $colStart={3} $colSpan={3}> - @@ -517,16 +516,16 @@ const SalaryBenefitCalculatorView: React.FC< })) } value={convertToUIDateFormat(newTrainingCompensation.startDate)} - invalid={!!getErrorMessage(fields.startDate.name)} - aria-invalid={!!getErrorMessage(fields.startDate.name)} - errorText={getErrorMessage(fields.startDate.name)} + invalid={!!getErrorMessage(fields.trainingCompensationStartDate.name)} + aria-invalid={!!getErrorMessage(fields.trainingCompensationStartDate.name)} + errorText={getErrorMessage(fields.trainingCompensationStartDate.name)} /> <$GridCell $colStart={6} $colSpan={3}> @@ -538,9 +537,9 @@ const SalaryBenefitCalculatorView: React.FC< })) } value={convertToUIDateFormat(newTrainingCompensation.endDate)} - invalid={!!getErrorMessage(fields.endDate.name)} - aria-invalid={!!getErrorMessage(fields.endDate.name)} - errorText={getErrorMessage(fields.endDate.name)} + invalid={!!getErrorMessage(fields.trainingCompensationEndDate.name)} + aria-invalid={!!getErrorMessage(fields.trainingCompensationEndDate.name)} + errorText={getErrorMessage(fields.trainingCompensationEndDate.name)} style={{ paddingRight: `${theme.spacing.s}` }} /> diff --git a/frontend/benefit/handler/src/components/applicationReview/useApplicationReview.ts b/frontend/benefit/handler/src/components/applicationReview/useApplicationReview.ts index 8611c9d158..1e225a9f36 100644 --- a/frontend/benefit/handler/src/components/applicationReview/useApplicationReview.ts +++ b/frontend/benefit/handler/src/components/applicationReview/useApplicationReview.ts @@ -1,9 +1,13 @@ import AppContext from 'benefit/handler/context/AppContext'; import useApplicationQuery from 'benefit/handler/hooks/useApplicationQuery'; +import useReviewStateQuery from 'benefit/handler/hooks/useReviewStateQuery'; +import useUpdateReviewStateQuery from 'benefit/handler/hooks/useUpdateReviewStateQuery'; import useUploadAttachmentQuery from 'benefit/handler/hooks/useUploadAttachmentQuery'; import { Application, HandledAplication, + ReviewState, + ReviewStateData, } from 'benefit/handler/types/application'; import camelcaseKeys from 'camelcase-keys'; import { useRouter } from 'next/router'; @@ -11,6 +15,7 @@ import { TFunction, useTranslation } from 'next-i18next'; import React, { useEffect, useState } from 'react'; import showErrorToast from 'shared/components/toast/show-error-toast'; import hdsToast from 'shared/components/toast/Toast'; +import snakecaseKeys from 'snakecase-keys'; type ExtendedComponentProps = { t: TFunction; @@ -21,6 +26,8 @@ type ExtendedComponentProps = { isLoading: boolean; isUploading: boolean; handleUpload: (attachment: FormData) => void; + reviewState: ReviewState; + handleUpdateReviewState: (reviewState: ReviewState) => void; }; const useApplicationReview = (): ExtendedComponentProps => { @@ -42,6 +49,17 @@ const useApplicationReview = (): ExtendedComponentProps => { isError: isUploadingError, } = useUploadAttachmentQuery(); + const { + status: reviewStateDataStatus, + data: reviewStateData, + error: reviewStateDataError, + } = useReviewStateQuery(id); + + const { + mutate: updateReviewState, + isError: isUpdatingReviewStateError, + } = useUpdateReviewStateQuery(); + const handleUpload = (attachment: FormData): void => { uploadAttachment({ applicationId: id, @@ -49,6 +67,11 @@ const useApplicationReview = (): ExtendedComponentProps => { }); }; + const handleUpdateReviewState = (reviewState: ReviewState): void => { + const data: ReviewStateData = snakecaseKeys(reviewState); + updateReviewState(data); + }; + useEffect(() => { if (isUploadingError) { showErrorToast( @@ -59,7 +82,11 @@ const useApplicationReview = (): ExtendedComponentProps => { }, [isUploadingError, t]); useEffect(() => { - if (applicationDataError) { + if ( + applicationDataError || + reviewStateDataError || + isUpdatingReviewStateError + ) { hdsToast({ autoDismissTime: 5000, type: 'error', @@ -67,17 +94,24 @@ const useApplicationReview = (): ExtendedComponentProps => { text: t('common:error.generic.text'), }); } - }, [t, applicationDataError]); + }, [t, applicationDataError, reviewStateDataError, isUpdatingReviewStateError]); useEffect(() => { + const loadingDataStatuses = new Set(['idle', 'loading']); if ( id && - applicationDataStatus !== 'idle' && - applicationDataStatus !== 'loading' + !loadingDataStatuses.has(applicationDataStatus) && + !loadingDataStatuses.has(reviewStateDataStatus) ) { setIsLoading(false); } - }, [applicationDataStatus, id, applicationData]); + }, [ + id, + applicationDataStatus, + applicationData, + reviewStateDataStatus, + reviewStateData, + ]); useEffect(() => { if (router.isReady && !router.query.id) { @@ -89,6 +123,10 @@ const useApplicationReview = (): ExtendedComponentProps => { deep: true, }); + const reviewState: ReviewState = camelcaseKeys(reviewStateData || {}, { + deep: true, + }); + return { t, id, @@ -98,6 +136,8 @@ const useApplicationReview = (): ExtendedComponentProps => { isError: Boolean(id && applicationDataError), isUploading, handleUpload, + reviewState, + handleUpdateReviewState, }; }; diff --git a/frontend/benefit/handler/src/components/batchProcessing/BatchActionsInspectionForm.tsx b/frontend/benefit/handler/src/components/batchProcessing/BatchActionsInspectionForm.tsx index 5c9f06d3cc..cc4573e24c 100644 --- a/frontend/benefit/handler/src/components/batchProcessing/BatchActionsInspectionForm.tsx +++ b/frontend/benefit/handler/src/components/batchProcessing/BatchActionsInspectionForm.tsx @@ -5,14 +5,23 @@ import { PROPOSALS_FOR_DECISION, } from 'benefit-shared/constants'; import { BatchProposal } from 'benefit-shared/types/application'; -import { Button, DateInput, IconArrowUndo, TextInput } from 'hds-react'; +import { + Button, + DateInput, + IconArrowUndo, + RadioButton, + TextInput, +} from 'hds-react'; import noop from 'lodash/noop'; import { useTranslation } from 'next-i18next'; -import React from 'react'; +import React, { ChangeEvent } from 'react'; import { $GridCell } from 'shared/components/forms/section/FormSection.sc'; import Modal from 'shared/components/modal/Modal'; +import theme from 'shared/styles/theme'; +import { CSSProperties } from 'styled-components'; import ConfirmModalContent from '../applicationReview/actions/ConfirmModalContent/confirm'; +import { $InspectionTypeContainer } from '../table/BatchCompletion.sc'; import { $FormSection } from '../table/TableExtras.sc'; import { useBatchActionsInspected } from './useBatchActionsInspected'; @@ -33,7 +42,8 @@ const BatchActionsInspectionForm: React.FC = ({ isInspectionFormSent, setInspectionFormSent, setBatchCloseAnimation, -}: BatchProps) => { +}: // eslint-disable-next-line sonarjs/cognitive-complexity +BatchProps) => { const { id, proposal_for_decision: proposalForDecision } = batch; const { t } = useTranslation(); const { formik, yearFromNow, isSuccess, isError } = useBatchActionsInspected( @@ -46,6 +56,8 @@ const BatchActionsInspectionForm: React.FC = ({ const [isModalBatchToCompletion, setModalBatchToCompletion] = React.useState(false); + const [inspectorMode, setInspectorMode] = React.useState('ahjo'); + React.useEffect(() => { if (isError) { setInspectionFormSent(false); @@ -82,6 +94,11 @@ const BatchActionsInspectionForm: React.FC = ({ return false; }); + const handleRadioButton = (event: ChangeEvent): void => { + setInspectorMode(event.target.value); + formik.handleChange(event); + }; + const handleSubmit = (e: React.FormEvent): void => { e.preventDefault(); formik @@ -152,6 +169,7 @@ const BatchActionsInspectionForm: React.FC = ({ } />

+

{t('common:batches.form.headings.decisionDetails')}

<$FormSection> <$GridCell $colSpan={3}> = ({ - <$FormSection> - <$GridCell $colSpan={3}> - - + {proposalForDecision === PROPOSALS_FOR_DECISION.ACCEPTED ? ( + <> +

{t('common:batches.form.headings.inspectionDetails')}

+ <$InspectionTypeContainer> + <$FormSection> + <$GridCell $colSpan={12}> + + + {inspectorMode === 'ahjo' ? ( + <$GridCell $colSpan={12}> +
+ + ) : null} + - <$GridCell $colSpan={3}> - - - + {inspectorMode === 'ahjo' ? ( + <$FormSection> + <$GridCell $colSpan={3}> + + - {proposalForDecision === PROPOSALS_FOR_DECISION.ACCEPTED ? ( - <$FormSection css="border-top: 1pox solid #000;"> - <$GridCell $colSpan={3}> - - + <$GridCell $colSpan={3}> + + + <$GridCell $colSpan={3}> + + + + ) : null} + - <$GridCell $colSpan={3}> - - - <$GridCell $colSpan={3}> - - - - ) : null} + <$InspectionTypeContainer> + <$FormSection> + <$GridCell $colSpan={12}> + + + {inspectorMode === 'p2p' ? ( + <$GridCell $colSpan={12}> +
+ + ) : null} + + {inspectorMode === 'p2p' ? ( + <$FormSection css="border-top: 1pox solid #000;"> + <$GridCell $colSpan={3}> + + + <$GridCell $colSpan={3}> + + + <$GridCell $colSpan={3}> + + + + ) : null} + + + ) : null} <$FormSection> <$GridCell $colSpan={3}>