From 1f11f0f66a815b646baf40f06f72a2831d2219e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emilia=20M=C3=A4kel=C3=A4?= Date: Wed, 8 May 2024 09:35:53 +0300 Subject: [PATCH] feat: implement the app page components for alterations for handler Includes the following features and related fixes: - Added cancellation fields in the alteration database model - Decision information box, partially shared with the applicant interface - Decision calculation accordion component - Alteration accordion component for handler with expanded details compared to those in the applicant view - Alteration deletion in the "received" state (similar to applicant view) - Alteration cancellation in the "handled" state - Sorting by application number in the pending alteration list view - Added link to the application in the pending alteration list view - Fixed state filter for application alteration list endpoint - Fixed alteration count bubble appearing in the navigation even if the number of pending alterations is zero - Fixed cancelled alterations showing up in the applicant interface - Fixed patching a handled alteration reporting an overlapping alteration with itself --- backend/benefit/applications/admin.py | 15 +- .../applications/api/v1/application_views.py | 56 ++- .../api/v1/serializers/application.py | 2 +- .../v1/serializers/application_alteration.py | 78 +++- .../migrations/0070_auto_20240507_0226.py | 120 ++++++ backend/benefit/applications/models.py | 53 ++- .../applications/tests/test_alteration_api.py | 53 ++- .../benefit/locale/en/LC_MESSAGES/django.po | 53 +-- .../benefit/locale/fi/LC_MESSAGES/django.po | 52 ++- .../benefit/locale/sv/LC_MESSAGES/django.po | 60 +-- .../applications/Applications.sc.ts | 32 -- .../alteration/AlterationAccordionItem.tsx | 10 +- .../applicationList/listItem/ListItem.tsx | 2 +- .../alteration/useAlterationForm.tsx | 2 +- .../applications/pageContent/PageContent.tsx | 63 ++- .../handler/public/locales/en/common.json | 155 +++++++- .../handler/public/locales/fi/common.json | 155 +++++++- .../handler/public/locales/sv/common.json | 155 +++++++- .../alterationList/AlterationList.sc.ts | 6 + .../alterationList/AlterationList.tsx | 9 +- .../AlterationAccordionItem.sc.ts | 94 +++++ .../handlingView/AlterationAccordionItem.tsx | 365 ++++++++++++++++++ .../handlingView/AlterationCancelModal.tsx | 85 ++++ .../handlingView/AlterationDeleteModal.tsx | 77 ++++ .../DecisionCalculationAccordion.sc.ts | 31 ++ .../DecisionCalculationAccordion.tsx | 133 +++++++ .../handlingView/HandlingStep1.tsx | 64 ++- .../useApplicationsArchive.ts | 7 +- .../src/components/header/useHeader.tsx | 2 +- .../hooks/useApplicationAlterationsQuery.ts | 10 +- .../useDeleteApplicationAlterationQuery.ts | 29 ++ .../useUpdateApplicationAlterationQuery.ts | 34 ++ .../benefit/handler/src/utils/calculator.ts | 41 ++ frontend/benefit/shared/.eslintrc.js | 5 +- frontend/benefit/shared/next-env.d.ts | 5 + frontend/benefit/shared/package.json | 1 + .../decisionSummary/DecisionSummary.sc.ts} | 9 +- .../decisionSummary}/DecisionSummary.tsx | 73 +--- .../components/statusIcon/StatusIcon.sc.ts | 33 ++ .../src/components/statusIcon}/StatusIcon.tsx | 2 +- frontend/benefit/shared/src/constants.ts | 1 + .../benefit/shared/src/types/application.d.ts | 26 +- frontend/benefit/shared/tsconfig.json | 8 +- frontend/shared/src/styles/globalStyling.ts | 8 + frontend/shared/src/styles/theme.ts | 5 + .../shared/src/types/styled-components.d.ts | 3 + 46 files changed, 2039 insertions(+), 243 deletions(-) create mode 100644 backend/benefit/applications/migrations/0070_auto_20240507_0226.py create mode 100644 frontend/benefit/handler/src/components/applicationReview/handlingView/AlterationAccordionItem.sc.ts create mode 100644 frontend/benefit/handler/src/components/applicationReview/handlingView/AlterationAccordionItem.tsx create mode 100644 frontend/benefit/handler/src/components/applicationReview/handlingView/AlterationCancelModal.tsx create mode 100644 frontend/benefit/handler/src/components/applicationReview/handlingView/AlterationDeleteModal.tsx create mode 100644 frontend/benefit/handler/src/components/applicationReview/handlingView/DecisionCalculationAccordion.sc.ts create mode 100644 frontend/benefit/handler/src/components/applicationReview/handlingView/DecisionCalculationAccordion.tsx create mode 100644 frontend/benefit/handler/src/hooks/useDeleteApplicationAlterationQuery.ts create mode 100644 frontend/benefit/handler/src/hooks/useUpdateApplicationAlterationQuery.ts create mode 100644 frontend/benefit/shared/next-env.d.ts rename frontend/benefit/{applicant/src/components/applications/pageContent/PageContent.sc.ts => shared/src/components/decisionSummary/DecisionSummary.sc.ts} (90%) rename frontend/benefit/{applicant/src/components/applications/pageContent => shared/src/components/decisionSummary}/DecisionSummary.tsx (53%) create mode 100644 frontend/benefit/shared/src/components/statusIcon/StatusIcon.sc.ts rename frontend/benefit/{applicant/src/components/applications => shared/src/components/statusIcon}/StatusIcon.tsx (92%) diff --git a/backend/benefit/applications/admin.py b/backend/benefit/applications/admin.py index 80d34a9e2b..0eb72f79a9 100644 --- a/backend/benefit/applications/admin.py +++ b/backend/benefit/applications/admin.py @@ -59,19 +59,28 @@ class ApplicationAlterationInline(admin.StackedInline): fk_name = "application" extra = 0 fields = ( + # Common state "state", "alteration_type", + # Applicant-provided data "end_date", "resume_date", "reason", "contact_person_name", - "recovery_start_date", - "recovery_end_date", - "recovery_amount", "use_einvoice", "einvoice_provider_name", "einvoice_provider_identifier", "einvoice_address", + # Handler-provided data + "is_recoverable", + "handled_at", + "handled_by", + "recovery_start_date", + "recovery_end_date", + "recovery_amount", + "recovery_justification", + "cancelled_at", + "cancelled_by", ) diff --git a/backend/benefit/applications/api/v1/application_views.py b/backend/benefit/applications/api/v1/application_views.py index efb5bf9fe4..5b51015389 100755 --- a/backend/benefit/applications/api/v1/application_views.py +++ b/backend/benefit/applications/api/v1/application_views.py @@ -7,7 +7,7 @@ from django.conf import settings from django.core import exceptions from django.db import transaction -from django.db.models import Q, QuerySet +from django.db.models import Prefetch, Q, QuerySet from django.http import FileResponse, HttpResponse, StreamingHttpResponse from django.shortcuts import get_object_or_404 from django.utils import timezone @@ -163,9 +163,17 @@ class Meta: class HandlerApplicationAlterationFilter(filters.FilterSet): class Meta: model = ApplicationAlteration - fields = { - "state": ["exact"], - } + fields = [] + + state = filters.MultipleChoiceFilter( + field_name="state", + widget=CSVWidget, + choices=ApplicationAlterationState.choices, + help_text=( + "Filter by alteration state. Multiple states may be specified as a" + " comma-separated list, such as 'state=handled,cancelled'", + ), + ) class BaseApplicationViewSet(AuditLoggingModelViewSet): @@ -427,21 +435,37 @@ class HandlerApplicationAlterationViewSet(BaseApplicationAlterationViewSet): filters.DjangoFilterBackend, ] + FROZEN_STATUSES = [ + ApplicationAlterationState.HANDLED, + ApplicationAlterationState.CANCELLED, + ] + serializer_class = HandlerApplicationAlterationSerializer permission_classes = [BFIsHandler] http_method_names = BaseApplicationAlterationViewSet.http_method_names + ["get"] filterset_class = HandlerApplicationAlterationFilter def update(self, request, *args, **kwargs): - if "state" in request.data.keys(): - current_state = self.get_object().state - if ( - current_state == ApplicationAlterationState.HANDLED - and current_state != request.data["state"] + allowed = True + current_state = self.get_object().state + + # If the alteration has been handled, the only allowed edit is to cancel it. + # If the alteration has been cancelled, it cannot be modified in any way anymore. + if current_state == ApplicationAlterationState.CANCELLED: + allowed = False + elif current_state == ApplicationAlterationState.HANDLED: + if not ( + "state" in request.data.keys() + and len(request.data.keys()) == 1 + and request.data["state"] + in HandlerApplicationAlterationViewSet.FROZEN_STATUSES ): - raise PermissionDenied(_("You are not allowed to do this action")) + allowed = False - return super().update(request, *args, **kwargs) + if allowed: + return super().update(request, *args, **kwargs) + else: + raise PermissionDenied(_("You are not allowed to do this action")) @extend_schema( @@ -467,6 +491,16 @@ def _annotate_unread_messages_count(self, qs): def get_queryset(self): qs = super().get_queryset() + qs = qs.prefetch_related( + Prefetch( + "alteration_set", + queryset=ApplicationAlteration.objects.filter( + ~Q(state__in=[ApplicationAlterationState.CANCELLED]) + ), + to_attr="visible_alterations", + ) + ) + if settings.NEXT_PUBLIC_MOCK_FLAG: return qs company = get_company_from_request(self.request) diff --git a/backend/benefit/applications/api/v1/serializers/application.py b/backend/benefit/applications/api/v1/serializers/application.py index 4d9c0098bc..3309bd464b 100755 --- a/backend/benefit/applications/api/v1/serializers/application.py +++ b/backend/benefit/applications/api/v1/serializers/application.py @@ -1491,7 +1491,7 @@ def get_company_for_new_application(self, _): return self.get_logged_in_user_company() alterations = ApplicantApplicationAlterationSerializer( - source="alteration_set", + source="visible_alterations", read_only=True, many=True, ) diff --git a/backend/benefit/applications/api/v1/serializers/application_alteration.py b/backend/benefit/applications/api/v1/serializers/application_alteration.py index ca224f00b5..5bb54022cc 100644 --- a/backend/benefit/applications/api/v1/serializers/application_alteration.py +++ b/backend/benefit/applications/api/v1/serializers/application_alteration.py @@ -1,5 +1,8 @@ +from datetime import date + from django.conf import settings from django.core.exceptions import ValidationError +from django.db import transaction from django.utils.text import format_lazy from django.utils.translation import gettext_lazy as _ from rest_framework import serializers @@ -8,6 +11,7 @@ from applications.api.v1.serializers.utils import DynamicFieldsModelSerializer from applications.enums import ApplicationAlterationState, ApplicationAlterationType from applications.models import ApplicationAlteration +from users.api.v1.serializers import UserSerializer from users.utils import get_company_from_request, get_request_user_from_context @@ -25,6 +29,8 @@ class Meta: "reason", "handled_at", "handled_by", + "cancelled_at", + "cancelled_by", "recovery_start_date", "recovery_end_date", "recovery_amount", @@ -45,6 +51,10 @@ class Meta: "application_number", "application_employee_first_name", "application_employee_last_name", + "handled_at", + "handled_by", + "cancelled_at", + "cancelled_by", ] ALLOWED_APPLICANT_EDIT_STATES = [ @@ -54,6 +64,7 @@ class Meta: ALLOWED_HANDLER_EDIT_STATES = [ ApplicationAlterationState.RECEIVED, ApplicationAlterationState.OPENED, + ApplicationAlterationState.HANDLED, ] application_company_name = serializers.SerializerMethodField( @@ -69,6 +80,14 @@ class Meta: "get_application_employee_last_name" ) + handled_by = UserSerializer( + read_only=True, help_text="Handler of this alteration, if any" + ) + cancelled_by = UserSerializer( + read_only=True, + help_text="The handler responsible for the cancellation of this alteration, if any", + ) + def get_application_company_name(self, obj): return obj.application.company.name @@ -162,10 +181,13 @@ def _validate_date_range_within_application_date_range( return errors - def _validate_date_range_overlaps(self, application, start_date, end_date): + def _validate_date_range_overlaps(self, self_id, application, start_date, end_date): errors = [] for alteration in application.alteration_set.all(): + if self_id is not None and alteration.id == self_id: + continue + if ( alteration.recovery_start_date is None or alteration.recovery_end_date is None @@ -189,6 +211,7 @@ def _validate_date_range_overlaps(self, application, start_date, end_date): def validate(self, data): merged_data = self._get_merged_object_for_validation(data) + self_id = "id" in merged_data and merged_data["id"] or None # Verify that the user is allowed to make the request user = get_request_user_from_context(self) @@ -234,7 +257,7 @@ def validate(self, data): if alteration_end_date is not None: errors += self._validate_date_range_overlaps( - application, alteration_start_date, alteration_end_date + self_id, application, alteration_start_date, alteration_end_date ) if len(errors) > 0: @@ -245,17 +268,62 @@ def validate(self, data): class ApplicantApplicationAlterationSerializer(BaseApplicationAlterationSerializer): class Meta(BaseApplicationAlterationSerializer.Meta): - read_only_fields = BaseApplicationAlterationSerializer.Meta.read_only_fields + [ + fields = [ + "id", + "created_at", + "application", + "alteration_type", + "state", + "end_date", + "resume_date", + "reason", "handled_at", + "handled_by", + "recovery_start_date", + "recovery_end_date", + "recovery_amount", + "is_recoverable", + "use_einvoice", + "einvoice_provider_name", + "einvoice_provider_identifier", + "einvoice_address", + "contact_person_name", + "application_company_name", + "application_number", + "application_employee_first_name", + "application_employee_last_name", + ] + read_only_fields = BaseApplicationAlterationSerializer.Meta.read_only_fields + [ "recovery_amount", "state", "recovery_start_date", "recovery_end_date", "recovery_justification", "is_recoverable", - "handled_by", ] class HandlerApplicationAlterationSerializer(BaseApplicationAlterationSerializer): - pass + @transaction.atomic + def update(self, instance, validated_data): + # Add handler/canceller information on state update. + # The transition itself is validated in the viewset and is one-way + # (only received -> handled -> cancelled allowed), so we don't need to + # care about the present values in those fields. + + new_state = ( + validated_data["state"] if "state" in validated_data else instance.state + ) + if instance.state != new_state: + user = get_request_user_from_context(self) + + if new_state == ApplicationAlterationState.HANDLED: + instance.handled_at = date.today() + instance.handled_by = user + elif new_state == ApplicationAlterationState.CANCELLED: + instance.cancelled_at = date.today() + instance.cancelled_by = user + + instance.save() + + return super().update(instance, validated_data) diff --git a/backend/benefit/applications/migrations/0070_auto_20240507_0226.py b/backend/benefit/applications/migrations/0070_auto_20240507_0226.py new file mode 100644 index 0000000000..1898dcaaa5 --- /dev/null +++ b/backend/benefit/applications/migrations/0070_auto_20240507_0226.py @@ -0,0 +1,120 @@ +# Generated by Django 3.2.23 on 2024-05-08 06:47 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ('applications', '0069_auto_20240422_1906'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='applicationalteration', + name='handled_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='applicationalteration', + name='alteration_type', + field=models.TextField(choices=[('termination', 'Termination'), ('suspension', 'Suspension')], verbose_name='Type of alteration'), + ), + migrations.AlterField( + model_name='applicationalteration', + name='application', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='alteration_set', to='applications.application', verbose_name='Alteration of application'), + ), + migrations.AddField( + model_name='applicationalteration', + name='cancelled_at', + field=models.DateField(blank=True, null=True, verbose_name='The date the alteration was cancelled after it had been handled'), + ), + migrations.AddField( + model_name='applicationalteration', + name='cancelled_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Cancelled by'), + ), + migrations.AlterField( + model_name='applicationalteration', + name='contact_person_name', + field=models.TextField(verbose_name='Contact person'), + ), + migrations.AlterField( + model_name='applicationalteration', + name='einvoice_address', + field=models.TextField(blank=True, verbose_name='E-invoice address'), + ), + migrations.AlterField( + model_name='applicationalteration', + name='einvoice_provider_identifier', + field=models.TextField(blank=True, verbose_name='Identifier of the e-invoice provider'), + ), + migrations.AlterField( + model_name='applicationalteration', + name='einvoice_provider_name', + field=models.TextField(blank=True, verbose_name='Name of the e-invoice provider'), + ), + migrations.AlterField( + model_name='applicationalteration', + name='end_date', + field=models.DateField(verbose_name='New benefit end date'), + ), + migrations.AlterField( + model_name='applicationalteration', + name='handled_at', + field=models.DateField(blank=True, null=True, verbose_name='Date when alteration notice was handled'), + ), + migrations.AlterField( + model_name='applicationalteration', + name='handled_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Handled by'), + ), + migrations.AlterField( + model_name='applicationalteration', + name='is_recoverable', + field=models.BooleanField(default=False, verbose_name='Whether the alteration should be recovered'), + ), + migrations.AlterField( + model_name='applicationalteration', + name='reason', + field=models.TextField(blank=True, verbose_name='Reason for alteration'), + ), + migrations.AlterField( + model_name='applicationalteration', + name='recovery_amount', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True, verbose_name='Amount of unwarranted benefit to be recovered'), + ), + migrations.AlterField( + model_name='applicationalteration', + name='recovery_end_date', + field=models.DateField(blank=True, null=True, verbose_name='The last day the unwarranted benefit will be recovered from'), + ), + migrations.AlterField( + model_name='applicationalteration', + name='recovery_justification', + field=models.TextField(blank=True, verbose_name='The justification provided in the recovering bill, if eligible'), + ), + migrations.AlterField( + model_name='applicationalteration', + name='recovery_start_date', + field=models.DateField(blank=True, null=True, verbose_name='The first day the unwarranted benefit will be collected from'), + ), + migrations.AlterField( + model_name='applicationalteration', + name='resume_date', + field=models.DateField(blank=True, null=True, verbose_name='Date when employment resumes after suspended'), + ), + migrations.AlterField( + model_name='applicationalteration', + name='state', + field=models.TextField(choices=[('received', 'Received'), ('opened', 'Opened'), ('handled', 'Handled'), ('cancelled', 'Cancelled')], default='received', verbose_name='State of alteration'), + ), + migrations.AlterField( + model_name='applicationalteration', + name='use_einvoice', + field=models.BooleanField(default=False, verbose_name='Whether to use handle billing with an e-invoice instead of a bill sent to a physical address'), + ), + ] diff --git a/backend/benefit/applications/models.py b/backend/benefit/applications/models.py index 664ef76898..98aa1e6d4c 100755 --- a/backend/benefit/applications/models.py +++ b/backend/benefit/applications/models.py @@ -1188,48 +1188,48 @@ class ApplicationAlteration(TimeStampedModel): application = models.ForeignKey( Application, - verbose_name=_("alteration of application"), + verbose_name=_("Alteration of application"), related_name="alteration_set", on_delete=models.CASCADE, ) alteration_type = models.TextField( - verbose_name=_("type of alteration"), choices=ApplicationAlterationType.choices + verbose_name=_("Type of alteration"), choices=ApplicationAlterationType.choices ) state = models.TextField( - verbose_name=_("state of alteration"), + verbose_name=_("State of alteration"), choices=ApplicationAlterationState.choices, default=ApplicationAlterationState.RECEIVED, ) - end_date = models.DateField(verbose_name=_("new benefit end date")) + end_date = models.DateField(verbose_name=_("New benefit end date")) resume_date = models.DateField( - verbose_name=_("date when employment resumes after suspended"), + verbose_name=_("Date when employment resumes after suspended"), null=True, blank=True, ) reason = models.TextField( - verbose_name=_("reason for alteration"), + verbose_name=_("Reason for alteration"), blank=True, ) handled_at = models.DateField( - verbose_name=_("date when alteration notice was handled"), + verbose_name=_("Date when alteration notice was handled"), null=True, blank=True, ) recovery_start_date = models.DateField( - verbose_name=_("the first day the unwarranted benefit will be collected from"), + verbose_name=_("The first day the unwarranted benefit will be collected from"), null=True, blank=True, ) recovery_end_date = models.DateField( - verbose_name=_("the last day the unwarranted benefit will be collected from"), + verbose_name=_("The last day the unwarranted benefit will be recovered from"), null=True, blank=True, ) @@ -1237,45 +1237,45 @@ class ApplicationAlteration(TimeStampedModel): recovery_amount = models.DecimalField( max_digits=8, decimal_places=2, - verbose_name=_("amount of unwarranted benefit to be collected"), + verbose_name=_("Amount of unwarranted benefit to be recovered"), null=True, blank=True, ) use_einvoice = models.BooleanField( verbose_name=_( - "whether to use handle billing with an e-invoice instead of a bill sent to a physical address" + "Whether to use handle billing with an e-invoice instead of a bill sent to a physical address" ), default=False, ) einvoice_provider_name = models.TextField( - verbose_name=_("name of the e-invoice provider"), + verbose_name=_("Name of the e-invoice provider"), blank=True, ) einvoice_provider_identifier = models.TextField( - verbose_name=_("identifier of the e-invoice provider"), + verbose_name=_("Identifier of the e-invoice provider"), blank=True, ) einvoice_address = models.TextField( - verbose_name=_("e-invoice address"), + verbose_name=_("E-invoice address"), blank=True, ) contact_person_name = models.TextField( - verbose_name=_("contact person"), + verbose_name=_("Contact person"), ) is_recoverable = models.BooleanField( - verbose_name=_("whether the alteration should be recovered"), + verbose_name=_("Whether the alteration should be recovered"), default=False, ) recovery_justification = models.TextField( verbose_name=_( - "the justification provided in the recovering bill, if eligible" + "The justification provided in the recovering bill, if eligible" ), blank=True, ) @@ -1285,6 +1285,25 @@ class ApplicationAlteration(TimeStampedModel): on_delete=models.SET_NULL, null=True, blank=True, + related_name="+", + verbose_name=_("Handled by"), + ) + + cancelled_at = models.DateField( + verbose_name=_( + "The date the alteration was cancelled after it had been handled" + ), + null=True, + blank=True, + ) + + cancelled_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + verbose_name=_("Cancelled by"), ) diff --git a/backend/benefit/applications/tests/test_alteration_api.py b/backend/benefit/applications/tests/test_alteration_api.py index 68cd22c772..e7583b6066 100644 --- a/backend/benefit/applications/tests/test_alteration_api.py +++ b/backend/benefit/applications/tests/test_alteration_api.py @@ -1,3 +1,5 @@ +from datetime import date + import faker import pytest from dateutil.relativedelta import relativedelta @@ -390,7 +392,6 @@ def test_application_alteration_forbidden_handler_in_applicant_api( def test_application_alteration_create_ignored_fields_applicant( api_client, application, - user, ): pk = application.id response = api_client.post( @@ -407,7 +408,6 @@ def test_application_alteration_create_ignored_fields_applicant( "recovery_end_date": application.end_date, "recovery_amount": 4000, "is_recoverable": True, - "recovery_justification": "sas", "handled_by": 1, "contact_person_name": "Ella Esimerkki", }, @@ -418,7 +418,6 @@ def test_application_alteration_create_ignored_fields_applicant( assert response.data["recovery_start_date"] is None assert response.data["recovery_end_date"] is None assert response.data["recovery_amount"] is None - assert response.data["recovery_justification"] == "" assert response.data["is_recoverable"] is False assert response.data["handled_by"] is None assert response.data["handled_at"] is None @@ -438,7 +437,6 @@ def test_application_alteration_create_ignored_fields_handler( "reason": "Päättynyt", "end_date": application.start_date + relativedelta(days=7), "use_invoice": False, - "handled_at": application.start_date + relativedelta(days=10), "recovery_start_date": application.start_date + relativedelta(days=7), "recovery_end_date": application.end_date, "recovery_amount": 4000, @@ -454,10 +452,6 @@ def test_application_alteration_create_ignored_fields_handler( ) assert response.data["recovery_end_date"] == application.end_date.isoformat() assert response.data["recovery_amount"] == "4000.00" - assert ( - response.data["handled_at"] - == (application.start_date + relativedelta(days=10)).isoformat() - ) def test_application_alteration_patch_applicant(api_client, application): @@ -521,6 +515,49 @@ def test_application_alteration_patch_handler(handler_api_client, application): assert response.data["recovery_amount"] == "4000.00" +def test_application_alteration_handler_automatic_state_change_fields( + handler_api_client, application, admin_user +): + alteration = _create_application_alteration(application) + today = date.today() + + assert alteration.handled_at is None + assert alteration.handled_by is None + assert alteration.cancelled_at is None + assert alteration.cancelled_by is None + + response = handler_api_client.patch( + reverse( + "v1:handler-application-alteration-detail", + kwargs={"pk": alteration.id}, + ), + { + "state": "handled", + }, + ) + assert response.status_code == 200 + assert response.data["state"] == "handled" + assert date.fromisoformat(response.data["handled_at"]) == today + assert response.data["handled_by"]["id"] == str(admin_user.id) + assert response.data["cancelled_at"] is None + assert response.data["cancelled_by"] is None + + response = handler_api_client.patch( + reverse( + "v1:handler-application-alteration-detail", + kwargs={"pk": alteration.id}, + ), + { + "state": "cancelled", + }, + ) + + assert response.status_code == 200 + assert response.data["state"] == "cancelled" + assert date.fromisoformat(response.data["handled_at"]) == today + assert response.data["handled_by"]["id"] == str(admin_user.id) + + @pytest.mark.parametrize( "initial_state,result", [ diff --git a/backend/benefit/locale/en/LC_MESSAGES/django.po b/backend/benefit/locale/en/LC_MESSAGES/django.po index 83ff7d7225..22b33084a7 100644 --- a/backend/benefit/locale/en/LC_MESSAGES/django.po +++ b/backend/benefit/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-04-22 19:07+0300\n" +"POT-Creation-Date: 2024-05-08 09:42+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -770,6 +770,9 @@ msgstr "" msgid "Expert inspector's title" msgstr "" +msgid "Batch created through Ahjo integration" +msgstr "" + msgid "application batch" msgstr "" @@ -911,61 +914,63 @@ msgstr "" msgid "ahjo decision texts" msgstr "" -#, fuzzy -#| msgid "helsinki_benefit_voucher" -msgid "alteration of application" -msgstr "The Helsinki Benefit card" +msgid "Alteration of application" +msgstr "" -msgid "type of alteration" +msgid "Type of alteration" msgstr "" -msgid "state of alteration" +msgid "State of alteration" msgstr "" -#, fuzzy -#| msgid "not_granted" -msgid "new benefit end date" -msgstr "None" +msgid "New benefit end date" +msgstr "" -msgid "date when employment resumes after suspended" +msgid "Date when employment resumes after suspended" msgstr "" -msgid "reason for alteration" +msgid "Reason for alteration" msgstr "" -msgid "date when alteration notice was handled" +msgid "Date when alteration notice was handled" msgstr "" -msgid "the first day the unwarranted benefit will be collected from" +msgid "The first day the unwarranted benefit will be collected from" msgstr "" -msgid "the last day the unwarranted benefit will be collected from" +msgid "The last day the unwarranted benefit will be recovered from" msgstr "" -msgid "amount of unwarranted benefit to be collected" +msgid "Amount of unwarranted benefit to be recovered" msgstr "" msgid "" -"whether to use handle billing with an e-invoice instead of a bill sent to a " +"Whether to use handle billing with an e-invoice instead of a bill sent to a " "physical address" msgstr "" -msgid "name of the e-invoice provider" +msgid "Name of the e-invoice provider" +msgstr "" + +msgid "Identifier of the e-invoice provider" +msgstr "" + +msgid "Contact person" msgstr "" -msgid "identifier of the e-invoice provider" +msgid "Whether the alteration should be recovered" msgstr "" -msgid "e-invoice address" +msgid "The justification provided in the recovering bill, if eligible" msgstr "" -msgid "contact person" +msgid "Handled by" msgstr "" -msgid "whether the alteration should be recovered" +msgid "The date the alteration was cancelled after it had been handled" msgstr "" -msgid "the justification provided in the recovering bill, if eligible" +msgid "Cancelled by" msgstr "" msgid "decision_proposal_draft" diff --git a/backend/benefit/locale/fi/LC_MESSAGES/django.po b/backend/benefit/locale/fi/LC_MESSAGES/django.po index 2fc4c9cdb6..3fad5730d1 100644 --- a/backend/benefit/locale/fi/LC_MESSAGES/django.po +++ b/backend/benefit/locale/fi/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-04-22 19:07+0300\n" +"POT-Creation-Date: 2024-05-08 09:42+0300\n" "PO-Revision-Date: 2022-11-01 10:45+0200\n" "Last-Translator: Kari Salminen \n" "Language-Team: \n" @@ -806,6 +806,9 @@ msgstr "Asiantuntijatarkastajan sähköpostiosoite" msgid "Expert inspector's title" msgstr "Asiantuntijatarkastajan nimi" +msgid "Batch created through Ahjo integration" +msgstr "" + msgid "application batch" msgstr "hakemuserä" @@ -957,59 +960,65 @@ msgstr "tila" msgid "ahjo decision texts" msgstr "tila" -msgid "alteration of application" +msgid "Alteration of application" msgstr "Muutos hakemukseen" -msgid "type of alteration" +msgid "Type of alteration" msgstr "Muutoksen tyyppi" -msgid "state of alteration" +msgid "State of alteration" msgstr "Muutoksen tila" -msgid "new benefit end date" +msgid "New benefit end date" msgstr "Uusi avustuksen päättymispäivä" -msgid "date when employment resumes after suspended" +msgid "Date when employment resumes after suspended" msgstr "Avustuksen jatkumispäivä keskeytymisen jälkeen" -msgid "reason for alteration" +msgid "Reason for alteration" msgstr "Muutoksen syy" -msgid "date when alteration notice was handled" +msgid "Date when alteration notice was handled" msgstr "Muutosilmoituksen käsittelypäivä" -msgid "the first day the unwarranted benefit will be collected from" +msgid "The first day the unwarranted benefit will be collected from" msgstr "Takaisinperinnän alkamispäivä" -msgid "the last day the unwarranted benefit will be collected from" +msgid "The last day the unwarranted benefit will be recovered from" msgstr "Takaisinperinnän päättymispäivä" -msgid "amount of unwarranted benefit to be collected" +msgid "Amount of unwarranted benefit to be recovered" msgstr "Takaisinperittävä summa" msgid "" -"whether to use handle billing with an e-invoice instead of a bill sent to a " +"Whether to use handle billing with an e-invoice instead of a bill sent to a " "physical address" msgstr "Lähetetäänkö lasku verkkolaskuna kirjelaskun sijaan?" -msgid "name of the e-invoice provider" +msgid "Name of the e-invoice provider" msgstr "Verkkolaskuoperaattorin nimi" -msgid "identifier of the e-invoice provider" +msgid "Identifier of the e-invoice provider" msgstr "Välittäjän tunnus" -msgid "e-invoice address" -msgstr "Verkkolaskuosoite" - -msgid "contact person" +msgid "Contact person" msgstr "Yhteyshenkilö" -msgid "whether the alteration should be recovered" +msgid "Whether the alteration should be recovered" msgstr "Peritäänkö tukea takaisin?" -msgid "the justification provided in the recovering bill, if eligible" +msgid "The justification provided in the recovering bill, if eligible" msgstr "Tuen takaisinperinnän perustelu, mikäli tukea peritään takaisin" +msgid "Handled by" +msgstr "Käsittelijä" + +msgid "The date the alteration was cancelled after it had been handled" +msgstr "Muutosilmoituksen peruutuspäivä" + +msgid "Cancelled by" +msgstr "Muutosilmoituksen peruuttanut käsittelijä" + #, fuzzy #| msgid "Decision date" msgid "decision_proposal_draft" @@ -1601,6 +1610,9 @@ msgstr "palveluehtojen hyväksyntä" msgid "terms of service approvals" msgstr "palveluehtojen hyväksynnät" +#~ msgid "e-invoice address" +#~ msgstr "Verkkolaskuosoite" + #, fuzzy #~| msgid "Decision date" #~ msgid "type of the decision proposal template section" diff --git a/backend/benefit/locale/sv/LC_MESSAGES/django.po b/backend/benefit/locale/sv/LC_MESSAGES/django.po index f366a2ce79..1e7ac37372 100644 --- a/backend/benefit/locale/sv/LC_MESSAGES/django.po +++ b/backend/benefit/locale/sv/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-04-22 19:07+0300\n" +"POT-Creation-Date: 2024-05-08 09:42+0300\n" "PO-Revision-Date: 2022-11-01 10:47+0200\n" "Last-Translator: Kari Salminen \n" "Language-Team: \n" @@ -810,6 +810,9 @@ msgstr "E-postadress till sakkunnig inspektör" msgid "Expert inspector's title" msgstr "Namn på sakkunnig inspektör" +msgid "Batch created through Ahjo integration" +msgstr "" + msgid "application batch" msgstr "ansökningssats" @@ -963,81 +966,89 @@ msgstr "status" #, fuzzy #| msgid "application batches" -msgid "alteration of application" +msgid "Alteration of application" msgstr "ansökningssatser" #, fuzzy #| msgid "type of terms" -msgid "type of alteration" +msgid "Type of alteration" msgstr "typ av villkor" #, fuzzy #| msgid "type of terms" -msgid "state of alteration" +msgid "State of alteration" msgstr "typ av villkor" #, fuzzy #| msgid "benefit end date" -msgid "new benefit end date" +msgid "New benefit end date" msgstr "slutdatum för understöd" -msgid "date when employment resumes after suspended" +msgid "Date when employment resumes after suspended" msgstr "" #, fuzzy #| msgid "type of terms" -msgid "reason for alteration" +msgid "Reason for alteration" msgstr "typ av villkor" -msgid "date when alteration notice was handled" +msgid "Date when alteration notice was handled" msgstr "" #, fuzzy #| msgid "amount of the benefit granted, calculated by the system" -msgid "the first day the unwarranted benefit will be collected from" +msgid "The first day the unwarranted benefit will be collected from" msgstr "understödsbelopp som beviljats, beräknat av systemet" #, fuzzy #| msgid "amount of the benefit granted, calculated by the system" -msgid "the last day the unwarranted benefit will be collected from" +msgid "The last day the unwarranted benefit will be recovered from" msgstr "understödsbelopp som beviljats, beräknat av systemet" #, fuzzy #| msgid "amount of the benefit granted, calculated by the system" -msgid "amount of unwarranted benefit to be collected" +msgid "Amount of unwarranted benefit to be recovered" msgstr "understödsbelopp som beviljats, beräknat av systemet" msgid "" -"whether to use handle billing with an e-invoice instead of a bill sent to a " +"Whether to use handle billing with an e-invoice instead of a bill sent to a " "physical address" msgstr "" #, fuzzy #| msgid "Name of the employer" -msgid "name of the e-invoice provider" +msgid "Name of the e-invoice provider" msgstr "Arbetsgivarens namn" #, fuzzy #| msgid "Name of the employer" -msgid "identifier of the e-invoice provider" +msgid "Identifier of the e-invoice provider" msgstr "Arbetsgivarens namn" -#, fuzzy -#| msgid "Delivery address" -msgid "e-invoice address" -msgstr "Leveransadress" - #, fuzzy #| msgid "company contact person's email" -msgid "contact person" +msgid "Contact person" msgstr "kontaktpersonens e-postadress" -msgid "whether the alteration should be recovered" +msgid "Whether the alteration should be recovered" +msgstr "" + +msgid "The justification provided in the recovering bill, if eligible" msgstr "" -msgid "the justification provided in the recovering bill, if eligible" +#, fuzzy +#| msgid "Handler" +msgid "Handled by" +msgstr "Handläggare" + +msgid "The date the alteration was cancelled after it had been handled" msgstr "" +#, fuzzy +#| msgid "Cancelled" +msgid "Cancelled by" +msgstr "Återkallad" + #, fuzzy #| msgid "Decision date" msgid "decision_proposal_draft" @@ -1624,6 +1635,11 @@ msgstr "godkännande av villkoren för tjänsten" msgid "terms of service approvals" msgstr "godkännanden av villkoren för tjänsten" +#, fuzzy +#~| msgid "Delivery address" +#~ msgid "e-invoice address" +#~ msgstr "Leveransadress" + #, fuzzy #~| msgid "Decision date" #~ msgid "type of the decision proposal template section" diff --git a/frontend/benefit/applicant/src/components/applications/Applications.sc.ts b/frontend/benefit/applicant/src/components/applications/Applications.sc.ts index 3651f2936c..17b402c3c6 100644 --- a/frontend/benefit/applicant/src/components/applications/Applications.sc.ts +++ b/frontend/benefit/applicant/src/components/applications/Applications.sc.ts @@ -1,4 +1,3 @@ -import { APPLICATION_STATUSES } from 'benefit-shared/constants'; import { respondAbove } from 'shared/styles/mediaQueries'; import styled from 'styled-components'; @@ -102,34 +101,3 @@ export const $NoApplicationsContainer = styled.div` gap: ${(props) => props.theme.spacing.xs}; margin: ${(props) => props.theme.spacingLayout.xl} 0; `; - -export const $StatusIcon = styled.span` - display: inline-block; - - svg { - vertical-align: middle; - } - - &.status-icon--${APPLICATION_STATUSES.HANDLING} { - svg { - color: ${(props) => props.theme.colors.info}; - } - } - - &.status-icon--${APPLICATION_STATUSES.ACCEPTED} { - svg { - color: ${(props) => props.theme.colors.success}; - } - } - - &.status-icon--${APPLICATION_STATUSES.REJECTED}, - &.status-icon--${APPLICATION_STATUSES.CANCELLED} { - svg { - color: ${(props) => props.theme.colors.error}; - } - } - - &.status-icon--${APPLICATION_STATUSES.INFO_REQUIRED} { - color: ${(props) => props.theme.colors.alertDark}; - } -`; diff --git a/frontend/benefit/applicant/src/components/applications/alteration/AlterationAccordionItem.tsx b/frontend/benefit/applicant/src/components/applications/alteration/AlterationAccordionItem.tsx index ab8ab9f8c1..f610d2f8e2 100644 --- a/frontend/benefit/applicant/src/components/applications/alteration/AlterationAccordionItem.tsx +++ b/frontend/benefit/applicant/src/components/applications/alteration/AlterationAccordionItem.tsx @@ -5,8 +5,7 @@ import useLocale from 'benefit/applicant/hooks/useLocale'; import { useTranslation } from 'benefit/applicant/i18n'; import { ALTERATION_STATE, ALTERATION_TYPE } from 'benefit-shared/constants'; import { - Application, - ApplicationAlteration, + AlterationAccordionItemProps, } from 'benefit-shared/types/application'; import { prettyPrintObject } from 'benefit-shared/utils/errors'; import camelcaseKeys from 'camelcase-keys'; @@ -20,15 +19,10 @@ import hdsToast from 'shared/components/toast/Toast'; import { convertToUIDateFormat } from 'shared/utils/date.utils'; import { formatFloatToCurrency } from 'shared/utils/string.utils'; -type Props = { - alteration: ApplicationAlteration; - application: Application; -}; - const AlterationAccordionItem = ({ alteration, application, -}: Props): JSX.Element => { +}: AlterationAccordionItemProps): JSX.Element => { const locale = useLocale(); const { t } = useTranslation(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); diff --git a/frontend/benefit/applicant/src/components/applications/applicationList/listItem/ListItem.tsx b/frontend/benefit/applicant/src/components/applications/applicationList/listItem/ListItem.tsx index 7a822acc9b..520055b693 100644 --- a/frontend/benefit/applicant/src/components/applications/applicationList/listItem/ListItem.tsx +++ b/frontend/benefit/applicant/src/components/applications/applicationList/listItem/ListItem.tsx @@ -1,6 +1,6 @@ -import StatusIcon from 'benefit/applicant/components/applications/StatusIcon'; import { useTranslation } from 'benefit/applicant/i18n'; import { Loading } from 'benefit/applicant/types/common'; +import StatusIcon from 'benefit-shared/components/statusIcon/StatusIcon'; import { APPLICATION_STATUSES } from 'benefit-shared/constants'; import { ApplicationListItemData } from 'benefit-shared/types/application'; import { Button, IconSpeechbubbleText } from 'hds-react'; diff --git a/frontend/benefit/applicant/src/components/applications/forms/application/alteration/useAlterationForm.tsx b/frontend/benefit/applicant/src/components/applications/forms/application/alteration/useAlterationForm.tsx index b41232eec4..89736d830f 100644 --- a/frontend/benefit/applicant/src/components/applications/forms/application/alteration/useAlterationForm.tsx +++ b/frontend/benefit/applicant/src/components/applications/forms/application/alteration/useAlterationForm.tsx @@ -55,7 +55,7 @@ const useAlterationForm = ({ ...data, endDate: convertDateFormat(data.endDate), resumeDate: convertDateFormat(data.resumeDate) || undefined, - }) as ApplicationAlterationData; + }, { deep: true }) as ApplicationAlterationData; createQuery(payload); }; diff --git a/frontend/benefit/applicant/src/components/applications/pageContent/PageContent.tsx b/frontend/benefit/applicant/src/components/applications/pageContent/PageContent.tsx index 071cdced11..b253202da9 100644 --- a/frontend/benefit/applicant/src/components/applications/pageContent/PageContent.tsx +++ b/frontend/benefit/applicant/src/components/applications/pageContent/PageContent.tsx @@ -1,3 +1,4 @@ +import AlterationAccordionItem from 'benefit/applicant/components/applications/alteration/AlterationAccordionItem'; import { $HeaderItem, $HeaderRightColumnItem, @@ -14,18 +15,21 @@ import ApplicationFormStep3 from 'benefit/applicant/components/applications/form import ApplicationFormStep4 from 'benefit/applicant/components/applications/forms/application/step4/ApplicationFormStep4'; import ApplicationFormStep5 from 'benefit/applicant/components/applications/forms/application/step5/ApplicationFormStep5'; import ApplicationFormStep6 from 'benefit/applicant/components/applications/forms/application/step6/ApplicationFormStep6'; -import DecisionSummary from 'benefit/applicant/components/applications/pageContent/DecisionSummary'; -import StatusIcon from 'benefit/applicant/components/applications/StatusIcon'; import NoCookieConsentsNotification from 'benefit/applicant/components/cookieConsent/NoCookieConsentsNotification'; import { $Hr } from 'benefit/applicant/components/pages/Pages.sc'; -import { SUBMITTED_STATUSES } from 'benefit/applicant/constants'; +import { ROUTES, SUBMITTED_STATUSES } from 'benefit/applicant/constants'; import { useAskem } from 'benefit/applicant/hooks/useAnalytics'; -import { IconInfoCircleFill, LoadingSpinner, Stepper } from 'hds-react'; +import DecisionSummary from 'benefit-shared/components/decisionSummary/DecisionSummary'; +import StatusIcon from 'benefit-shared/components/statusIcon/StatusIcon'; +import { ALTERATION_STATE, ALTERATION_TYPE, APPLICATION_STATUSES } from 'benefit-shared/constants'; +import { DecisionDetailList } from 'benefit-shared/types/application'; +import { Button, IconInfoCircleFill, LoadingSpinner, Stepper } from 'hds-react'; import { useRouter } from 'next/router'; -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import Container from 'shared/components/container/Container'; import { getFullName } from 'shared/utils/application.utils'; -import { convertToUIDateAndTimeFormat } from 'shared/utils/date.utils'; +import { convertToUIDateAndTimeFormat, convertToUIDateFormat } from 'shared/utils/date.utils'; +import { formatFloatToCurrency } from 'shared/utils/string.utils'; import { useTheme } from 'styled-components'; import ErrorPage from '../../errorPage/ErrorPage'; @@ -71,6 +75,28 @@ const PageContent: React.FC = () => { window.scrollTo(0, 0); }, [currentStep]); + const decisionDetailList = useMemo(() => [ + { + accessor: (app) => <> + + {t(`common:applications.statuses.${app.status}`)} + , + key: 'status', + }, + { + accessor: (app) => formatFloatToCurrency(app.calculatedBenefitAmount), + key: 'benefitAmount', + }, + { + accessor: (app) => `${convertToUIDateFormat(app.startDate)} – ${convertToUIDateFormat(app.endDate)}`, + key: 'benefitPeriod', + }, + { + accessor: (app) => convertToUIDateFormat(app.ahjoDecisionDate), + key: 'decisionDate', + } + ], [t]); + if (isLoading) { return ( <$SpinnerContainer> @@ -125,6 +151,12 @@ const PageContent: React.FC = () => { ); } + const hasHandledTermination = application.alterations?.some( + (alteration) => + alteration.state === ALTERATION_STATE.HANDLED && + alteration.alterationType === ALTERATION_TYPE.TERMINATION + ); + // if view mode, show customized summary if ( application.status && @@ -164,7 +196,24 @@ const PageContent: React.FC = () => { )} - + + router.push( + `${ROUTES.APPLICATION_ALTERATION}?id=${application.id}` + ) + } + disabled={hasHandledTermination} + > + {t('common:applications.decision.actions.reportAlteration')} + + ) : null} + itemComponent={AlterationAccordionItem} + detailList={decisionDetailList} + /> ); diff --git a/frontend/benefit/handler/public/locales/en/common.json b/frontend/benefit/handler/public/locales/en/common.json index d9e16b20fa..ba8dd5c325 100644 --- a/frontend/benefit/handler/public/locales/en/common.json +++ b/frontend/benefit/handler/public/locales/en/common.json @@ -89,6 +89,14 @@ "label": "Aloita hakemuksen vieminen ahjoon", "linkLabel": "Siirry Ahjoon", "message": "" + }, + "alterationDeleted": { + "label": "Saapunut työsuhteen muutosilmoitus on poistettu", + "message": "Poistit hakemukselle {{applicationNumber}} tehdyn ilmoituksen työsuhteen muutoksesta." + }, + "alterationCancelled": { + "label": "Käsitelty takaisinlasku on poistettu", + "message": "Poistit hakemuksen {{applicationNumber}} muutosilmoituksen." } }, "mainIngress": { @@ -238,7 +246,7 @@ "draft": "Luonnos", "additionalInformationNeeded": "Lisätietoja tarvitaan", "received": "Vastaanotettu", - "approved": "Hyväksytty", + "accepted": "Hyväksytty", "rejected": "Hylätty", "handling": "Käsittelyssä", "cancelled": "Peruttu" @@ -690,6 +698,148 @@ "helperText": "Kirjaa milloin ja mitä kautta hakija on ollut yhteydessä. Tiedot tulevat näkyviin hakemuksen muutoshistoriassa." } }, + "decision": { + "description": { + "accepted": "Hakemus on hyväksytty ja Helsinki-lisää on myönnetty aikavälille {{dateRangeStart}} – {{dateRangeEnd}}", + "rejected": "Hakemus on hylätty. Helsinki-lisää ei myönnetä." + }, + "headings": { + "mainHeading": "Päätöksen tiedot", + "caseId": "Diaarinumero", + "status": "Päätös", + "decisionDate": "Päätös tehty", + "benefitPeriod": "Tukiaika", + "benefitAmount": "Myönnetty tuki yhteensä", + "existingAlterations": "Muutokset työsuhteessa", + "grantedAsDeMinimis": "Myönnetty de minimis -tukena", + "sectionOfLaw": "Pykälä", + "handler": "Käsittelijä", + "decisionMaker": "Päätöksentekijä" + }, + "alterationList": { + "count_one": "{{count}} ilmoitettu muutos työsuhteessa", + "count_other": "{{count}} ilmoitettua muutosta työsuhteessa", + "empty": "Ei ilmoitettuja keskeytyksiä työsuhteessa. Työsuhde on toteutunut suunnitellusti.", + "item": { + "heading": { + "termination": "Työsuhde on päättynyt {{endDate}}", + "suspension": "Työsuhde on keskeytynyt {{endDate}}–{{resumeDate}}" + }, + "actions": { + "delete": "Poista", + "cancel": "Peruuta", + "beginHandling": "Käsittele muutosilmoitus" + }, + "state": { + "label": "Tila", + "received": "Odottaa käsittelyä", + "opened": "Käsittelyssä", + "handled": "Käsitelty", + "cancelled": "Peruutettu" + }, + "recoveryAmount": "Takaisin peritty tuki", + "recoveryPeriod": "Aika, jolta tukea perittiin takaisin", + "contactPersonName": "Yhteyshenkilö laskutuksessa", + "einvoiceProviderName": "Verkkolaskuoperaattori", + "einvoiceProviderIdentifier": "Välittäjätunnus", + "einvoiceAddress": "Verkkolaskuosoite", + "deliveryAddress": "Laskutusosoite", + "reasonTermination": "Syy työsuhteen päättymiseen", + "reasonSuspension": "Syy työsuhteen keskeytymiseen", + "recoveryJustification": "Laskun selite", + "noRecoveryJustification": "Perustelu", + "decisionResult": "Käsittelyn tulos", + "notRecoverable": "Tukea ei laskuteta", + "handledBy": "Käsittelijä", + "receivedAt": "Saapunut", + "handledAt": "Käsitelty", + "cancelledAt": "Peruutettu", + "cancelledBy": "Käsittelijä" + }, + "deleteModal": { + "title": "Haluatko varmasti poistaa saapuneen ilmoituksen muutoksesta työsuhteessa?", + "body": "Jos jatkat, tiedot työsuhteen muutoksesta poistuvat lopullisesti hakijan ja käsittelijän järjestelmistä.", + "delete": "Poista", + "cancel": "Takaisin" + }, + "cancelModal": { + "titleTermination": "Haluatko varmasti peruuttaa käsitellyn ilmoituksen työsuhteen päättymisestä {{endDate}}?", + "titleSuspension": "Haluatko varmasti peruuttaa käsitellyn ilmoituksen työsuhteen keskeytymisestä {{endDate}}–{{resumeDate}}?", + "body": "Toiminto ei poista Talpaan lähetettyä takaisinlaskutuspyyntöä. Ota yhteyttä Talpaan ennen kuin peruutat käsitellyn muutosilmoituksen, mikäli olet lähettänyt takaisinlaskupyynnön. Jos jatkat, muutosilmoitus näkyy käsittelijän järjestelmässä peruttuna ja poistuu hakijan näkymästä.", + "setCancelled": "Peruuta", + "cancel": "Takaisin, älä peruuta" + } + }, + "actions": { + "showDecision": "Tarkastele päätöstä", + "reportAlteration": "Ilmoita työsuhteen muutoksesta" + }, + "calculation": "Laskelma" + }, + "alteration": { + "title": "Ilmoita työsuhteen muutoksesta", + "explanation": "Ilmoita, jos työllistetyn henkilön työsuhde päättyy tai keskeytyy väliaikaisesti tukijakson aikana. Saatamme periä Helsinki-lisää takaisin, jos tukijakson aikana työsuhteeseen tulee muutoksia.", + "actions": { + "submit": "Lähetä", + "cancel": "Peruuta" + }, + "fields": { + "alterationType": { + "label": "Millaisesta muutoksesta työsuhteessa haluat ilmoittaa?", + "termination": "Työsuhteen päättymisestä", + "suspension": "Työsuhteen keskeytymisestä väliaikaisesti" + }, + "endDate": { + "label": "Viimeinen työpäivä" + }, + "resumeDate": { + "label": "Töihinpaluupäivä" + }, + "date": { + "helpText": "Kirjoita päivämäärä muodossa P.K.VVVV" + }, + "terminationReason": { + "label": "Syy työsuhteen päättymiseen", + "helpText": "Kirjoita halutessasi syy työsuhteen päättymiseen" + }, + "suspensionReason": { + "label": "Syy työsuhteen keskeytymiseen", + "helpText": "Kirjoita halutessasi syy työsuhteen keskeytymiseen" + }, + "useEinvoice": { + "label": "Laskun toimitustapa", + "yes": "Verkkolaskulla", + "no": "Postiosoitteeseen: {{streetAddress}}, {{postCode}} {{city}}" + }, + "einvoiceProviderName": { + "label": "Verkkolaskuoperaattorin nimi", + "placeholder": "Esim. Basware Oyj" + }, + "einvoiceProviderIdentifier": { + "label": "Verkkolaskuoperaattorin tunnus", + "placeholder": "Esim. BAWCFI22" + }, + "einvoiceAddress": { + "label": "Verkkolaskuosoite", + "placeholder": "Esim. 001100223300", + "tooltip": "Voit tarkistaa organisaatiosi verkkolaskuosoitteen verkkosivulta verkkolaskuosoite.fi" + }, + "contactPersonName": { + "label": "Yhteyshenkilö laskutusasioissa", + "helpText": "Kirjoita yhteyshenkilön nimi, joka lisätään laskulle" + } + }, + "validation": { + "resumeDateBeforeEndDate": "Töihinpaluupäivän on oltava keskeytymisen alkupäivämäärän jälkeen", + "einvoiceRequiredTogether": "Verkkolaskuosoitetta käytettäessä kaikki osoitekentät on täytettävä" + }, + "error": { + "notYetAccepted": "Et voi ilmoittaa muutosta työsuhteessa tälle hakemukselle, sillä tukea ei ole vielä myönnetty.", + "alreadyTerminated": "Tämä työsuhde on aiemmin jo merkitty päättyneeksi, joten et voi enää ilmoittaa uudesta muutoksesta." + }, + "details": "Muutokset työsuhteessa", + "billing": "Laskutustiedot" + }, "alterations": { "list": { "heading": "Muutosilmoitukset", @@ -1119,7 +1269,8 @@ "empty": "Tyhjä", "next": "Seuraava", "previous": "Edellinen", - "send": "Lähetä" + "send": "Lähetä", + "deleting": "Poistetaan..." }, "status": { "draft": "Luonnos", diff --git a/frontend/benefit/handler/public/locales/fi/common.json b/frontend/benefit/handler/public/locales/fi/common.json index 5b7691d296..c057966e04 100644 --- a/frontend/benefit/handler/public/locales/fi/common.json +++ b/frontend/benefit/handler/public/locales/fi/common.json @@ -89,6 +89,14 @@ "label": "Aloita hakemuksen vieminen ahjoon", "linkLabel": "Siirry Ahjoon", "message": "" + }, + "alterationDeleted": { + "label": "Saapunut työsuhteen muutosilmoitus on poistettu", + "message": "Poistit hakemukselle {{applicationNumber}} tehdyn ilmoituksen työsuhteen muutoksesta." + }, + "alterationCancelled": { + "label": "Käsitelty takaisinlasku on poistettu", + "message": "Poistit hakemuksen {{applicationNumber}} muutosilmoituksen." } }, "mainIngress": { @@ -238,7 +246,7 @@ "draft": "Luonnos", "additionalInformationNeeded": "Lisätietoja tarvitaan", "received": "Vastaanotettu", - "approved": "Hyväksytty", + "accepted": "Hyväksytty", "rejected": "Hylätty", "handling": "Käsittelyssä", "cancelled": "Peruttu" @@ -690,6 +698,148 @@ "helperText": "Kirjaa milloin ja mitä kautta hakija on ollut yhteydessä. Tiedot tulevat näkyviin hakemuksen muutoshistoriassa." } }, + "decision": { + "description": { + "accepted": "Hakemus on hyväksytty ja Helsinki-lisää on myönnetty aikavälille {{dateRangeStart}} – {{dateRangeEnd}}", + "rejected": "Hakemus on hylätty. Helsinki-lisää ei myönnetä." + }, + "headings": { + "mainHeading": "Päätöksen tiedot", + "caseId": "Diaarinumero", + "status": "Päätös", + "decisionDate": "Päätös tehty", + "benefitPeriod": "Tukiaika", + "benefitAmount": "Myönnetty tuki yhteensä", + "existingAlterations": "Muutokset työsuhteessa", + "grantedAsDeMinimis": "Myönnetty de minimis -tukena", + "sectionOfLaw": "Pykälä", + "handler": "Käsittelijä", + "decisionMaker": "Päätöksentekijä" + }, + "alterationList": { + "count_one": "{{count}} ilmoitettu muutos työsuhteessa", + "count_other": "{{count}} ilmoitettua muutosta työsuhteessa", + "empty": "Ei ilmoitettuja keskeytyksiä työsuhteessa. Työsuhde on toteutunut suunnitellusti.", + "item": { + "heading": { + "termination": "Työsuhde on päättynyt {{endDate}}", + "suspension": "Työsuhde on keskeytynyt {{endDate}}–{{resumeDate}}" + }, + "actions": { + "delete": "Poista", + "cancel": "Peruuta", + "beginHandling": "Käsittele muutosilmoitus" + }, + "state": { + "label": "Tila", + "received": "Odottaa käsittelyä", + "opened": "Käsittelyssä", + "handled": "Käsitelty", + "cancelled": "Peruutettu" + }, + "recoveryAmount": "Takaisin peritty tuki", + "recoveryPeriod": "Aika, jolta tukea perittiin takaisin", + "contactPersonName": "Yhteyshenkilö laskutuksessa", + "einvoiceProviderName": "Verkkolaskuoperaattori", + "einvoiceProviderIdentifier": "Välittäjätunnus", + "einvoiceAddress": "Verkkolaskuosoite", + "deliveryAddress": "Laskutusosoite", + "reasonTermination": "Syy työsuhteen päättymiseen", + "reasonSuspension": "Syy työsuhteen keskeytymiseen", + "recoveryJustification": "Laskun selite", + "noRecoveryJustification": "Perustelu", + "decisionResult": "Käsittelyn tulos", + "notRecoverable": "Tukea ei laskuteta", + "handledBy": "Käsittelijä", + "receivedAt": "Saapunut", + "handledAt": "Käsitelty", + "cancelledAt": "Peruutettu", + "cancelledBy": "Käsittelijä" + }, + "deleteModal": { + "title": "Haluatko varmasti poistaa saapuneen ilmoituksen muutoksesta työsuhteessa?", + "body": "Jos jatkat, tiedot työsuhteen muutoksesta poistuvat lopullisesti hakijan ja käsittelijän järjestelmistä.", + "delete": "Poista", + "cancel": "Takaisin" + }, + "cancelModal": { + "titleTermination": "Haluatko varmasti peruuttaa käsitellyn ilmoituksen työsuhteen päättymisestä {{endDate}}?", + "titleSuspension": "Haluatko varmasti peruuttaa käsitellyn ilmoituksen työsuhteen keskeytymisestä {{endDate}}–{{resumeDate}}?", + "body": "Toiminto ei poista Talpaan lähetettyä takaisinlaskutuspyyntöä. Ota yhteyttä Talpaan ennen kuin peruutat käsitellyn muutosilmoituksen, mikäli olet lähettänyt takaisinlaskupyynnön. Jos jatkat, muutosilmoitus näkyy käsittelijän järjestelmässä peruttuna ja poistuu hakijan näkymästä.", + "setCancelled": "Peruuta", + "cancel": "Takaisin, älä peruuta" + } + }, + "actions": { + "showDecision": "Tarkastele päätöstä", + "reportAlteration": "Ilmoita työsuhteen muutoksesta" + }, + "calculation": "Laskelma" + }, + "alteration": { + "title": "Ilmoita työsuhteen muutoksesta", + "explanation": "Ilmoita, jos työllistetyn henkilön työsuhde päättyy tai keskeytyy väliaikaisesti tukijakson aikana. Saatamme periä Helsinki-lisää takaisin, jos tukijakson aikana työsuhteeseen tulee muutoksia.", + "actions": { + "submit": "Lähetä", + "cancel": "Peruuta" + }, + "fields": { + "alterationType": { + "label": "Millaisesta muutoksesta työsuhteessa haluat ilmoittaa?", + "termination": "Työsuhteen päättymisestä", + "suspension": "Työsuhteen keskeytymisestä väliaikaisesti" + }, + "endDate": { + "label": "Viimeinen työpäivä" + }, + "resumeDate": { + "label": "Töihinpaluupäivä" + }, + "date": { + "helpText": "Kirjoita päivämäärä muodossa P.K.VVVV" + }, + "terminationReason": { + "label": "Syy työsuhteen päättymiseen", + "helpText": "Kirjoita halutessasi syy työsuhteen päättymiseen" + }, + "suspensionReason": { + "label": "Syy työsuhteen keskeytymiseen", + "helpText": "Kirjoita halutessasi syy työsuhteen keskeytymiseen" + }, + "useEinvoice": { + "label": "Laskun toimitustapa", + "yes": "Verkkolaskulla", + "no": "Postiosoitteeseen: {{streetAddress}}, {{postCode}} {{city}}" + }, + "einvoiceProviderName": { + "label": "Verkkolaskuoperaattorin nimi", + "placeholder": "Esim. Basware Oyj" + }, + "einvoiceProviderIdentifier": { + "label": "Verkkolaskuoperaattorin tunnus", + "placeholder": "Esim. BAWCFI22" + }, + "einvoiceAddress": { + "label": "Verkkolaskuosoite", + "placeholder": "Esim. 001100223300", + "tooltip": "Voit tarkistaa organisaatiosi verkkolaskuosoitteen verkkosivulta verkkolaskuosoite.fi" + }, + "contactPersonName": { + "label": "Yhteyshenkilö laskutusasioissa", + "helpText": "Kirjoita yhteyshenkilön nimi, joka lisätään laskulle" + } + }, + "validation": { + "resumeDateBeforeEndDate": "Töihinpaluupäivän on oltava keskeytymisen alkupäivämäärän jälkeen", + "einvoiceRequiredTogether": "Verkkolaskuosoitetta käytettäessä kaikki osoitekentät on täytettävä" + }, + "error": { + "notYetAccepted": "Et voi ilmoittaa muutosta työsuhteessa tälle hakemukselle, sillä tukea ei ole vielä myönnetty.", + "alreadyTerminated": "Tämä työsuhde on aiemmin jo merkitty päättyneeksi, joten et voi enää ilmoittaa uudesta muutoksesta." + }, + "details": "Muutokset työsuhteessa", + "billing": "Laskutustiedot" + }, "alterations": { "list": { "heading": "Muutosilmoitukset", @@ -1119,7 +1269,8 @@ "empty": "Tyhjä", "next": "Seuraava", "previous": "Edellinen", - "send": "Lähetä" + "send": "Lähetä", + "deleting": "Poistetaan..." }, "status": { "draft": "Luonnos", diff --git a/frontend/benefit/handler/public/locales/sv/common.json b/frontend/benefit/handler/public/locales/sv/common.json index d9e16b20fa..ba8dd5c325 100644 --- a/frontend/benefit/handler/public/locales/sv/common.json +++ b/frontend/benefit/handler/public/locales/sv/common.json @@ -89,6 +89,14 @@ "label": "Aloita hakemuksen vieminen ahjoon", "linkLabel": "Siirry Ahjoon", "message": "" + }, + "alterationDeleted": { + "label": "Saapunut työsuhteen muutosilmoitus on poistettu", + "message": "Poistit hakemukselle {{applicationNumber}} tehdyn ilmoituksen työsuhteen muutoksesta." + }, + "alterationCancelled": { + "label": "Käsitelty takaisinlasku on poistettu", + "message": "Poistit hakemuksen {{applicationNumber}} muutosilmoituksen." } }, "mainIngress": { @@ -238,7 +246,7 @@ "draft": "Luonnos", "additionalInformationNeeded": "Lisätietoja tarvitaan", "received": "Vastaanotettu", - "approved": "Hyväksytty", + "accepted": "Hyväksytty", "rejected": "Hylätty", "handling": "Käsittelyssä", "cancelled": "Peruttu" @@ -690,6 +698,148 @@ "helperText": "Kirjaa milloin ja mitä kautta hakija on ollut yhteydessä. Tiedot tulevat näkyviin hakemuksen muutoshistoriassa." } }, + "decision": { + "description": { + "accepted": "Hakemus on hyväksytty ja Helsinki-lisää on myönnetty aikavälille {{dateRangeStart}} – {{dateRangeEnd}}", + "rejected": "Hakemus on hylätty. Helsinki-lisää ei myönnetä." + }, + "headings": { + "mainHeading": "Päätöksen tiedot", + "caseId": "Diaarinumero", + "status": "Päätös", + "decisionDate": "Päätös tehty", + "benefitPeriod": "Tukiaika", + "benefitAmount": "Myönnetty tuki yhteensä", + "existingAlterations": "Muutokset työsuhteessa", + "grantedAsDeMinimis": "Myönnetty de minimis -tukena", + "sectionOfLaw": "Pykälä", + "handler": "Käsittelijä", + "decisionMaker": "Päätöksentekijä" + }, + "alterationList": { + "count_one": "{{count}} ilmoitettu muutos työsuhteessa", + "count_other": "{{count}} ilmoitettua muutosta työsuhteessa", + "empty": "Ei ilmoitettuja keskeytyksiä työsuhteessa. Työsuhde on toteutunut suunnitellusti.", + "item": { + "heading": { + "termination": "Työsuhde on päättynyt {{endDate}}", + "suspension": "Työsuhde on keskeytynyt {{endDate}}–{{resumeDate}}" + }, + "actions": { + "delete": "Poista", + "cancel": "Peruuta", + "beginHandling": "Käsittele muutosilmoitus" + }, + "state": { + "label": "Tila", + "received": "Odottaa käsittelyä", + "opened": "Käsittelyssä", + "handled": "Käsitelty", + "cancelled": "Peruutettu" + }, + "recoveryAmount": "Takaisin peritty tuki", + "recoveryPeriod": "Aika, jolta tukea perittiin takaisin", + "contactPersonName": "Yhteyshenkilö laskutuksessa", + "einvoiceProviderName": "Verkkolaskuoperaattori", + "einvoiceProviderIdentifier": "Välittäjätunnus", + "einvoiceAddress": "Verkkolaskuosoite", + "deliveryAddress": "Laskutusosoite", + "reasonTermination": "Syy työsuhteen päättymiseen", + "reasonSuspension": "Syy työsuhteen keskeytymiseen", + "recoveryJustification": "Laskun selite", + "noRecoveryJustification": "Perustelu", + "decisionResult": "Käsittelyn tulos", + "notRecoverable": "Tukea ei laskuteta", + "handledBy": "Käsittelijä", + "receivedAt": "Saapunut", + "handledAt": "Käsitelty", + "cancelledAt": "Peruutettu", + "cancelledBy": "Käsittelijä" + }, + "deleteModal": { + "title": "Haluatko varmasti poistaa saapuneen ilmoituksen muutoksesta työsuhteessa?", + "body": "Jos jatkat, tiedot työsuhteen muutoksesta poistuvat lopullisesti hakijan ja käsittelijän järjestelmistä.", + "delete": "Poista", + "cancel": "Takaisin" + }, + "cancelModal": { + "titleTermination": "Haluatko varmasti peruuttaa käsitellyn ilmoituksen työsuhteen päättymisestä {{endDate}}?", + "titleSuspension": "Haluatko varmasti peruuttaa käsitellyn ilmoituksen työsuhteen keskeytymisestä {{endDate}}–{{resumeDate}}?", + "body": "Toiminto ei poista Talpaan lähetettyä takaisinlaskutuspyyntöä. Ota yhteyttä Talpaan ennen kuin peruutat käsitellyn muutosilmoituksen, mikäli olet lähettänyt takaisinlaskupyynnön. Jos jatkat, muutosilmoitus näkyy käsittelijän järjestelmässä peruttuna ja poistuu hakijan näkymästä.", + "setCancelled": "Peruuta", + "cancel": "Takaisin, älä peruuta" + } + }, + "actions": { + "showDecision": "Tarkastele päätöstä", + "reportAlteration": "Ilmoita työsuhteen muutoksesta" + }, + "calculation": "Laskelma" + }, + "alteration": { + "title": "Ilmoita työsuhteen muutoksesta", + "explanation": "Ilmoita, jos työllistetyn henkilön työsuhde päättyy tai keskeytyy väliaikaisesti tukijakson aikana. Saatamme periä Helsinki-lisää takaisin, jos tukijakson aikana työsuhteeseen tulee muutoksia.", + "actions": { + "submit": "Lähetä", + "cancel": "Peruuta" + }, + "fields": { + "alterationType": { + "label": "Millaisesta muutoksesta työsuhteessa haluat ilmoittaa?", + "termination": "Työsuhteen päättymisestä", + "suspension": "Työsuhteen keskeytymisestä väliaikaisesti" + }, + "endDate": { + "label": "Viimeinen työpäivä" + }, + "resumeDate": { + "label": "Töihinpaluupäivä" + }, + "date": { + "helpText": "Kirjoita päivämäärä muodossa P.K.VVVV" + }, + "terminationReason": { + "label": "Syy työsuhteen päättymiseen", + "helpText": "Kirjoita halutessasi syy työsuhteen päättymiseen" + }, + "suspensionReason": { + "label": "Syy työsuhteen keskeytymiseen", + "helpText": "Kirjoita halutessasi syy työsuhteen keskeytymiseen" + }, + "useEinvoice": { + "label": "Laskun toimitustapa", + "yes": "Verkkolaskulla", + "no": "Postiosoitteeseen: {{streetAddress}}, {{postCode}} {{city}}" + }, + "einvoiceProviderName": { + "label": "Verkkolaskuoperaattorin nimi", + "placeholder": "Esim. Basware Oyj" + }, + "einvoiceProviderIdentifier": { + "label": "Verkkolaskuoperaattorin tunnus", + "placeholder": "Esim. BAWCFI22" + }, + "einvoiceAddress": { + "label": "Verkkolaskuosoite", + "placeholder": "Esim. 001100223300", + "tooltip": "Voit tarkistaa organisaatiosi verkkolaskuosoitteen verkkosivulta verkkolaskuosoite.fi" + }, + "contactPersonName": { + "label": "Yhteyshenkilö laskutusasioissa", + "helpText": "Kirjoita yhteyshenkilön nimi, joka lisätään laskulle" + } + }, + "validation": { + "resumeDateBeforeEndDate": "Töihinpaluupäivän on oltava keskeytymisen alkupäivämäärän jälkeen", + "einvoiceRequiredTogether": "Verkkolaskuosoitetta käytettäessä kaikki osoitekentät on täytettävä" + }, + "error": { + "notYetAccepted": "Et voi ilmoittaa muutosta työsuhteessa tälle hakemukselle, sillä tukea ei ole vielä myönnetty.", + "alreadyTerminated": "Tämä työsuhde on aiemmin jo merkitty päättyneeksi, joten et voi enää ilmoittaa uudesta muutoksesta." + }, + "details": "Muutokset työsuhteessa", + "billing": "Laskutustiedot" + }, "alterations": { "list": { "heading": "Muutosilmoitukset", @@ -1119,7 +1269,8 @@ "empty": "Tyhjä", "next": "Seuraava", "previous": "Edellinen", - "send": "Lähetä" + "send": "Lähetä", + "deleting": "Poistetaan..." }, "status": { "draft": "Luonnos", diff --git a/frontend/benefit/handler/src/components/alterationList/AlterationList.sc.ts b/frontend/benefit/handler/src/components/alterationList/AlterationList.sc.ts index 3d48cfae53..bb08d7b36d 100644 --- a/frontend/benefit/handler/src/components/alterationList/AlterationList.sc.ts +++ b/frontend/benefit/handler/src/components/alterationList/AlterationList.sc.ts @@ -22,3 +22,9 @@ export const $EmptyListText = styled.p` font-weight: normal; margin: 0; `; + +export const $Link = styled.a` + color: ${(props) => props.theme.colors.coatOfArms}; + text-decoration: none; + font-weight: 500; +`; diff --git a/frontend/benefit/handler/src/components/alterationList/AlterationList.tsx b/frontend/benefit/handler/src/components/alterationList/AlterationList.tsx index 7a20142498..17dc2e5381 100644 --- a/frontend/benefit/handler/src/components/alterationList/AlterationList.tsx +++ b/frontend/benefit/handler/src/components/alterationList/AlterationList.tsx @@ -1,8 +1,9 @@ import { $EmptyListText, - $Heading, + $Heading, $Link, $Subheading, } from 'benefit/handler/components/alterationList/AlterationList.sc'; +import { ROUTES } from 'benefit/handler/constants'; import { ApplicationAlterationData } from 'benefit-shared/types/application'; import { IconArrowRight, Table } from 'hds-react'; import { Trans, useTranslation } from 'next-i18next'; @@ -27,13 +28,17 @@ const AlterationList: React.FC = ({ isLoading, list, heading }) => { headerName: t('common:applications.alterations.list.columns.applicant'), key: 'application_company_name', isSortable: true, + transform: ({ application_company_name, application }: ApplicationAlterationData) => + <$Link href={`${ROUTES.APPLICATION}/?id=${application}`} target="_blank"> + {application_company_name} + }, { headerName: t( 'common:applications.alterations.list.columns.applicationNumber' ), key: 'application_number', - isSortable: false, + isSortable: true, }, { headerName: t( diff --git a/frontend/benefit/handler/src/components/applicationReview/handlingView/AlterationAccordionItem.sc.ts b/frontend/benefit/handler/src/components/applicationReview/handlingView/AlterationAccordionItem.sc.ts new file mode 100644 index 0000000000..2e195f43ff --- /dev/null +++ b/frontend/benefit/handler/src/components/applicationReview/handlingView/AlterationAccordionItem.sc.ts @@ -0,0 +1,94 @@ +import { Accordion, Button, Tag } from 'hds-react'; +import styled from 'styled-components'; + +export const $AlterationAccordionItemContainer = styled.div` + position: relative; + + div[role="heading"] > div[role="button"] > span.label { + display: inline-block; + max-width: 66.6%; + padding-left: ${(props) => props.theme.spacing.xl}; + } +`; + +export const $AlterationAccordionItem = styled(Accordion)` + margin-top: ${(props) => props.theme.spacingLayout.xs2}; + + dl { + row-gap: ${(props) => props.theme.spacingLayout.xs}; + } + + dl dt { + font-weight: 500; + padding-bottom: ${(props) => props.theme.spacing.s}; + } + dl dd { + margin: 0; + } +`; + +export const $TextAreaValue = styled.dd` + white-space: pre-line; +`; + +export const $AlterationAccordionItemIconContainer = styled.div` + position: absolute; + left: ${(props) => props.theme.spacing.xs}; + top: ${(props) => props.theme.spacing.s}; + pointer-events: none; + z-index: 1; + padding: 2px 0; +`; + +export const $TagContainer = styled.div` + position: absolute; + left: 66.7%; + top: ${(props) => props.theme.spacing.xs}; + width: 33%; + pointer-events: none; + z-index: 1; + padding: 2px 0; +`; + +export const $Tag = styled(Tag)` + font-weight: normal; + + &.state-received { + --tag-background: ${(props) => props.theme.colors.alert}; + } + &.state-opened { + --tag-background: ${(props) => props.theme.colors.alert}; + } + &.state-handled { + --tag-background: ${(props) => props.theme.colors.success}; + --tag-color: white; + } + &.state-cancelled { + --tag-background: ${(props) => props.theme.colors.silverDark}; + } +`; + +export const $ActionContainer = styled.div` + display: flex; + align-items: center; + box-sizing: border-box; + gap: ${(props) => props.theme.spacing.m}; +`; + + +export const $SecondaryDangerButton = styled(Button)` + --border-color: ${(props) => props.theme.colors.error}; + --background-color: ${(props) => props.theme.colors.white}; + --background-color-hover: ${(props) => props.theme.colors.errorLight}; + --background-color-focus: ${(props) => props.theme.colors.white}; + --background-color-hover-focus: ${(props) => props.theme.colors.errorLight}; + --border-color: ${(props) => props.theme.colors.error}; + --border-color-hover: ${(props) => props.theme.colors.errorDark}; + --border-color-focus: ${(props) => props.theme.colors.error}; + --border-color-hover-focus: ${(props) => props.theme.colors.errorDark}; + --color: ${(props) => props.theme.colors.error}; + --color-hover: ${(props) => props.theme.colors.error}; + --color-focus: ${(props) => props.theme.colors.error}; + --color-hover-focus: ${(props) => props.theme.colors.error}; + --focus-outline-color: ${(props) => props.theme.colors.error}; +`; diff --git a/frontend/benefit/handler/src/components/applicationReview/handlingView/AlterationAccordionItem.tsx b/frontend/benefit/handler/src/components/applicationReview/handlingView/AlterationAccordionItem.tsx new file mode 100644 index 0000000000..4d841a42d6 --- /dev/null +++ b/frontend/benefit/handler/src/components/applicationReview/handlingView/AlterationAccordionItem.tsx @@ -0,0 +1,365 @@ +import { AxiosError } from 'axios'; +import { + $ActionContainer, + $AlterationAccordionItem, $AlterationAccordionItemContainer, + $Tag, $TagContainer, + $TextAreaValue +} from 'benefit/handler/components/applicationReview/handlingView/AlterationAccordionItem.sc'; +import AlterationCancelModal from 'benefit/handler/components/applicationReview/handlingView/AlterationCancelModal'; +import AlterationDeleteModal from 'benefit/handler/components/applicationReview/handlingView/AlterationDeleteModal'; +import { + $DecisionCalculatorAccordionIconContainer +} from 'benefit/handler/components/applicationReview/handlingView/DecisionCalculationAccordion.sc'; +import useDeleteApplicationAlterationQuery from 'benefit/handler/hooks/useDeleteApplicationAlterationQuery'; +import useUpdateApplicationAlterationQuery from 'benefit/handler/hooks/useUpdateApplicationAlterationQuery'; +import { ErrorData } from 'benefit/handler/types/common'; +import { ALTERATION_STATE, ALTERATION_TYPE } from 'benefit-shared/constants'; +import { AlterationAccordionItemProps, } from 'benefit-shared/types/application'; +import { prettyPrintObject } from 'benefit-shared/utils/errors'; +import camelcaseKeys from 'camelcase-keys'; +import { Button, IconCross, IconInfoCircle, IconTrash } from 'hds-react'; +import { useTranslation } from 'next-i18next'; +import React, { useState } from 'react'; +import { $Grid, $GridCell, $Hr, } from 'shared/components/forms/section/FormSection.sc'; +import hdsToast from 'shared/components/toast/Toast'; +import useLocale from 'shared/hooks/useLocale'; +import { convertToUIDateFormat, formatDate } from 'shared/utils/date.utils'; +import { formatFloatToCurrency } from 'shared/utils/string.utils'; + +const AlterationAccordionItem = ({ + alteration, + application, +// eslint-disable-next-line sonarjs/cognitive-complexity +}: AlterationAccordionItemProps): JSX.Element => { + const locale = useLocale(); + const { t } = useTranslation(); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const { mutate: deleteAlteration, status: deleteStatus } = + useDeleteApplicationAlterationQuery(); + const { mutate: updateAlteration, status: cancelStatus } = + useUpdateApplicationAlterationQuery(); + + const deletable = [ALTERATION_STATE.RECEIVED, ALTERATION_STATE.OPENED].includes(alteration.state); + const cancellable = [ALTERATION_STATE.HANDLED].includes(alteration.state); + const isHandled = [ALTERATION_STATE.HANDLED, ALTERATION_STATE.CANCELLED].includes(alteration.state); + + const handlerName = alteration.handledBy + ? `${alteration.handledBy.firstName} ${alteration.handledBy.lastName[0]}.` + : '-'; + const cancellerName = alteration.cancelledBy + ? `${alteration.cancelledBy.firstName} ${alteration.cancelledBy.lastName[0]}.` + : '-'; + + const onActionError = (error: AxiosError): void => { + const errorData = camelcaseKeys(error.response?.data ?? {}); + const isContentTypeHTML = typeof errorData === 'string'; + + void hdsToast({ + autoDismissTime: 0, + type: 'error', + labelText: t('common:notifications.applicationDeleteError.label'), + text: + isContentTypeHTML || Object.keys(errorData).length === 0 + ? t('common:error.generic.text') + : Object.entries(errorData).map(([key, value]) => + typeof value === 'string' ? ( + {value} + ) : ( + prettyPrintObject({ data: value }) + ) + ), + }); + } + + const deleteItem = (): void => { + deleteAlteration( + { id: String(alteration.id), applicationId: application.id }, + { + onSuccess: () => { + setIsDeleteModalOpen(false); + return void hdsToast({ + autoDismissTime: 0, + type: 'success', + labelText: t( + 'common:notifications.alterationDeleted.label' + ), + text: t('common:notifications.alterationDeleted.message', { + applicationNumber: application.applicationNumber + }), + }); + }, + onError: onActionError, + } + ); + }; + + const setItemCancelled = (): void => { + updateAlteration( + { id: alteration.id, applicationId: application.id, data: { state: ALTERATION_STATE.CANCELLED } }, + { + onSuccess: () => { + setIsDeleteModalOpen(false); + return void hdsToast({ + autoDismissTime: 0, + type: 'success', + labelText: t( + 'common:notifications.alterationCancelled.label' + ), + text: t('common:notifications.alterationCancelled.message', + { + applicationNumber: application.applicationNumber + }), + }); + }, + onError: onActionError, + } + ); + }; + + return ( + <$AlterationAccordionItemContainer> + <$DecisionCalculatorAccordionIconContainer aria-hidden="true"> + + + <$TagContainer> + <$Tag className={`state-${alteration.state}`} aria-hidden="true"> + {t(`applications.decision.alterationList.item.state.${alteration.state}`)} + + + <$AlterationAccordionItem + card + size="s" + language={locale} + headingLevel={3} + heading={t( + `common:applications.decision.alterationList.item.heading.${alteration.alterationType}`, + { + endDate: convertToUIDateFormat(alteration.endDate), + resumeDate: alteration.resumeDate + ? convertToUIDateFormat(alteration.resumeDate) + : '', + } + )} + > + <$Grid as="dl"> + <$GridCell className="sr-only"> +
+ {t('common:applications.decision.alterationList.item.state.label')} +
+
+ {t(`applications.decision.alterationList.item.state.${alteration.state}`)} +
+ + {alteration.state === ALTERATION_STATE.CANCELLED && <> + <$GridCell $colSpan={3}> +
+ {t('common:applications.decision.alterationList.item.cancelledAt')} +
+
+ {formatDate(new Date(alteration.cancelledAt))} +
+ + <$GridCell $colSpan={3}> +
+ {t('common:applications.decision.alterationList.item.cancelledBy')} +
+
+ {cancellerName} +
+ + <$GridCell $colSpan={6} /> + <$GridCell $colSpan={12}> + <$Hr css="margin-top: 0;" /> + + } + {isHandled ? <$GridCell $colSpan={3}> +
+ {t('common:applications.decision.alterationList.item.handledAt')} +
+
+ {formatDate(new Date(alteration.handledAt))} +
+ : <$GridCell $colSpan={3}> +
+ {t('common:applications.decision.alterationList.item.receivedAt')} +
+
+ {formatDate(new Date(alteration.createdAt))} +
+ } + {(isHandled && alteration.isRecoverable) && ( + <> + <$GridCell $colSpan={3}> +
+ {t( + 'common:applications.decision.alterationList.item.handledBy' + )} +
+
+ {handlerName} +
+ + <$GridCell $colSpan={3}> +
+ {t( + 'common:applications.decision.alterationList.item.recoveryAmount' + )} +
+
{formatFloatToCurrency(alteration.recoveryAmount)}
+ + <$GridCell $colSpan={3}> +
+ {t( + 'common:applications.decision.alterationList.item.recoveryPeriod' + )} +
+
+ {convertToUIDateFormat(alteration.recoveryStartDate)}– + {convertToUIDateFormat(alteration.recoveryEndDate)} +
+ + + )} + {(isHandled && !alteration.isRecoverable) && ( + <> + <$GridCell $colSpan={3}> +
+ {t( + 'common:applications.decision.alterationList.item.decisionResult' + )} +
+
+ {t( + 'common:applications.decision.alterationList.item.notRecoverable' + )}
+ + <$GridCell $colSpan={3} /> + <$GridCell $colSpan={3}> +
+ {t( + 'common:applications.decision.alterationList.item.handledBy' + )} +
+
+ {handlerName}
+ <$GridCell $colSpan={12}> +
+ {t( + 'common:applications.decision.alterationList.item.noRecoveryJustification' + )} +
+ <$TextAreaValue>{alteration.recoveryJustification || '-'} + + + )} + {!isHandled && <$GridCell $colSpan={9} />} + <$GridCell $colSpan={3}> +
+ {t( + 'common:applications.decision.alterationList.item.contactPersonName' + )} +
+
{alteration.contactPersonName}
+ + {alteration.useEinvoice ? ( + <> + <$GridCell $colSpan={3}> +
+ {t( + 'common:applications.decision.alterationList.item.einvoiceProviderName' + )} +
+
{alteration.einvoiceProviderName}
+ + <$GridCell $colSpan={3}> +
+ {t( + 'common:applications.decision.alterationList.item.einvoiceProviderIdentifier' + )} +
+
{alteration.einvoiceProviderIdentifier}
+ + <$GridCell $colSpan={3}> +
+ {t( + 'common:applications.decision.alterationList.item.einvoiceAddress' + )} +
+
{alteration.einvoiceAddress}
+ + + ) : ( + <$GridCell $colSpan={9}> +
+ {t( + 'common:applications.decision.alterationList.item.deliveryAddress' + )} +
+
+ {application.company.streetAddress},{' '} + {application.company.postcode} {application.company.city} +
+ + )} + <$GridCell $colSpan={12}> +
+ {t( + `common:applications.decision.alterationList.item.reason${alteration.alterationType === ALTERATION_TYPE.TERMINATION ? 'Termination' : 'Suspension'}` + )} +
+ <$TextAreaValue>{alteration.reason || '-'} + + {(isHandled && alteration.isRecoverable) && <$GridCell $colSpan={12}> +
+ {t( + 'common:applications.decision.alterationList.item.recoveryJustification' + )} +
+ <$TextAreaValue>{alteration.recoveryJustification || '-'} + } + + <$ActionContainer> + {alteration.state === ALTERATION_STATE.RECEIVED && ( + )} + {(deletable || cancellable) && } + + {isDeleteModalOpen && deletable && ( + setIsDeleteModalOpen(false)} + onDelete={deleteItem} + isDeleting={deleteStatus === 'loading'} + /> + )} + {isDeleteModalOpen && cancellable && ( + setIsDeleteModalOpen(false)} + onSetCancelled={setItemCancelled} + isDeleting={cancelStatus === 'loading'} + alteration={alteration} + /> + )} + + + ); +}; + +export default AlterationAccordionItem; diff --git a/frontend/benefit/handler/src/components/applicationReview/handlingView/AlterationCancelModal.tsx b/frontend/benefit/handler/src/components/applicationReview/handlingView/AlterationCancelModal.tsx new file mode 100644 index 0000000000..9f004c5ffe --- /dev/null +++ b/frontend/benefit/handler/src/components/applicationReview/handlingView/AlterationCancelModal.tsx @@ -0,0 +1,85 @@ +import { + $SecondaryDangerButton +} from 'benefit/handler/components/applicationReview/handlingView/AlterationAccordionItem.sc'; +import { ALTERATION_TYPE } from 'benefit-shared/constants'; +import { ApplicationAlteration } from 'benefit-shared/types/application'; +import { Button, Dialog, IconInfoCircle, IconTrash } from 'hds-react'; +import noop from 'lodash/noop'; +import { useTranslation } from 'next-i18next'; +import React from 'react'; +import Modal from 'shared/components/modal/Modal'; +import theme from 'shared/styles/theme'; +import { formatDate } from 'shared/utils/date.utils'; + +type Props = { + onClose: () => void; + onSetCancelled: () => void; + isOpen: boolean; + isDeleting: boolean; + alteration: ApplicationAlteration; +}; + +const AlterationCancelModal = ({ + onClose, + onSetCancelled, + isOpen, + isDeleting, + alteration + }: Props): JSX.Element => { + const { t } = useTranslation(); + + return ( + } + customContent={ + <> + +

+ {t( + 'common:applications.decision.alterationList.cancelModal.body' + )} +

+
+ + <$SecondaryDangerButton + disabled={isDeleting} + onClick={onClose} + > + {t( + 'common:applications.decision.alterationList.cancelModal.cancel' + )} + + + + + } + /> + ); +}; + +export default AlterationCancelModal; diff --git a/frontend/benefit/handler/src/components/applicationReview/handlingView/AlterationDeleteModal.tsx b/frontend/benefit/handler/src/components/applicationReview/handlingView/AlterationDeleteModal.tsx new file mode 100644 index 0000000000..71ba487b18 --- /dev/null +++ b/frontend/benefit/handler/src/components/applicationReview/handlingView/AlterationDeleteModal.tsx @@ -0,0 +1,77 @@ +import { + $SecondaryDangerButton +} from 'benefit/handler/components/applicationReview/handlingView/AlterationAccordionItem.sc'; +import { Button, Dialog, IconInfoCircle, IconTrash } from 'hds-react'; +import noop from 'lodash/noop'; +import { useTranslation } from 'next-i18next'; +import React from 'react'; +import Modal from 'shared/components/modal/Modal'; +import theme from 'shared/styles/theme'; + +type Props = { + onClose: () => void; + onDelete: () => void; + isOpen: boolean; + isDeleting: boolean; +}; + +const AlterationDeleteModal = ({ + onClose, + onDelete, + isOpen, + isDeleting, +}: Props): JSX.Element => { + const { t } = useTranslation(); + + return ( + } + customContent={ + <> + +

+ {t( + 'common:applications.decision.alterationList.deleteModal.body' + )} +

+
+ + <$SecondaryDangerButton + disabled={isDeleting} + onClick={onClose} + > + {t( + 'common:applications.decision.alterationList.deleteModal.cancel' + )} + + + + + } + /> + ); +}; + +export default AlterationDeleteModal; diff --git a/frontend/benefit/handler/src/components/applicationReview/handlingView/DecisionCalculationAccordion.sc.ts b/frontend/benefit/handler/src/components/applicationReview/handlingView/DecisionCalculationAccordion.sc.ts new file mode 100644 index 0000000000..8134ad94eb --- /dev/null +++ b/frontend/benefit/handler/src/components/applicationReview/handlingView/DecisionCalculationAccordion.sc.ts @@ -0,0 +1,31 @@ +import { respondAbove } from 'shared/styles/mediaQueries'; +import styled from 'styled-components'; + +export const $DecisionCalculatorAccordion = styled.div` + position: relative; + + div[role="heading"] > div[role="button"] > span.label { + padding-left: ${(props) => props.theme.spacing.xl}; + } +`; + +export const $DecisionCalculatorAccordionIconContainer = styled.div` + position: absolute; + left: ${(props) => props.theme.spacing.xs}; + top: ${(props) => props.theme.spacing.s}; + pointer-events: none; + z-index: 1; + padding: 2px 0; +`; + +export const $CalculatorContainer = styled.div` + ${respondAbove('md')` + width: 75%; + `}; +`; + +export const $Section = styled.div` + &.subtotal { + background-color: ${(props) => props.theme.colors.coatOfArmsLight}; + } +`; diff --git a/frontend/benefit/handler/src/components/applicationReview/handlingView/DecisionCalculationAccordion.tsx b/frontend/benefit/handler/src/components/applicationReview/handlingView/DecisionCalculationAccordion.tsx new file mode 100644 index 0000000000..9993446486 --- /dev/null +++ b/frontend/benefit/handler/src/components/applicationReview/handlingView/DecisionCalculationAccordion.tsx @@ -0,0 +1,133 @@ +import { + $CalculatorContainer, + $DecisionCalculatorAccordion, $DecisionCalculatorAccordionIconContainer, + $Section +} from 'benefit/handler/components/applicationReview/handlingView/DecisionCalculationAccordion.sc'; +import { CALCULATION_PER_MONTH_ROW_TYPES } from 'benefit/handler/constants'; +import { extractCalculatorRows, groupCalculatorRows } from 'benefit/handler/utils/calculator'; +import { CALCULATION_ROW_DESCRIPTION_TYPES, CALCULATION_ROW_TYPES, } from 'benefit-shared/constants'; +import { Application } from 'benefit-shared/types/application'; +import { Accordion, IconGlyphEuro } from 'hds-react'; +import { useTranslation } from 'next-i18next'; +import * as React from 'react'; +import { $ViewField } from 'shared/components/benefit/summaryView/SummaryView.sc'; +import { $GridCell } from 'shared/components/forms/section/FormSection.sc'; +import { formatFloatToCurrency } from 'shared/utils/string.utils'; +import { useTheme } from 'styled-components'; + +import { $CalculatorTableHeader, $CalculatorTableRow, $Highlight, } from '../ApplicationReview.sc'; + +type Props = { + data: Application; +} + +const DecisionCalculationAccordion: React.FC = ({ + data, + }) => { + const theme = useTheme(); + const translationsBase = 'common:calculators.result'; + const { t } = useTranslation(); + const { rowsWithoutTotal, totalRow, totalRowDescription } = + extractCalculatorRows(data?.calculation?.rows); + + // Group rows into sections to give monthly subtotals a separate background color + const sections = groupCalculatorRows(rowsWithoutTotal); + + const headingSize = { fontSize: theme.fontSize.heading.l }; + + return (<$DecisionCalculatorAccordion> + <$DecisionCalculatorAccordionIconContainer aria-hidden="true"> + + + + <$GridCell + $colSpan={11} + style={{ + padding: theme.spacing.m + }} + > + <$CalculatorContainer> + {totalRow && ( + <> + <$CalculatorTableHeader css={headingSize}> + {t(`${translationsBase}.header`)} + + <$Highlight data-testid="calculation-results-total"> +
+ {totalRowDescription + ? totalRowDescription.descriptionFi + : totalRow?.descriptionFi} +
+
+ {formatFloatToCurrency(totalRow.amount, 'EUR', 'fi-FI', 0)} +
+ +
+ + )} + <$CalculatorTableHeader style={{ paddingBottom: theme.spacing.m }} css={headingSize}> + {t(`${translationsBase}.header2`)} + + {sections.map((section) => { + const firstRowIsMonthSubtotal = [ + CALCULATION_ROW_TYPES.HELSINKI_BENEFIT_MONTHLY_EUR, + CALCULATION_ROW_TYPES.HELSINKI_BENEFIT_SUB_TOTAL_EUR + ].includes(section[0]?.rowType); + + return <$Section key={section[0].id || 'filler'} className={firstRowIsMonthSubtotal ? 'subtotal' : ''}>{section.map((row) => { + const isDateRange = + CALCULATION_ROW_DESCRIPTION_TYPES.DATE === row.descriptionType; + const isDescriptionRowType = + CALCULATION_ROW_TYPES.DESCRIPTION === row.rowType; + + const isPerMonth = CALCULATION_PER_MONTH_ROW_TYPES.includes( + row.rowType + ); + return ( +
+ {CALCULATION_ROW_TYPES.HELSINKI_BENEFIT_MONTHLY_EUR === + row.rowType && ( + <$CalculatorTableRow> + <$ViewField isBold> + {t(`${translationsBase}.acceptedBenefit`)} + + + )} + <$CalculatorTableRow + isNewSection={isDateRange} + style={{ + marginBottom: '7px', + }} + > + <$ViewField + isBold={isDateRange || isDescriptionRowType} + isBig={isDateRange} + > + {row.descriptionFi} + + {!isDescriptionRowType && ( + <$ViewField + isBold + style={{ marginRight: theme.spacing.xl4 }} + > + {formatFloatToCurrency(row.amount)} + {isPerMonth && t('common:utility.perMonth')} + + )} + +
+ ); + })}; + })} + + +
+ ); +}; + +export default DecisionCalculationAccordion; diff --git a/frontend/benefit/handler/src/components/applicationReview/handlingView/HandlingStep1.tsx b/frontend/benefit/handler/src/components/applicationReview/handlingView/HandlingStep1.tsx index 8b266215df..b294a912e8 100644 --- a/frontend/benefit/handler/src/components/applicationReview/handlingView/HandlingStep1.tsx +++ b/frontend/benefit/handler/src/components/applicationReview/handlingView/HandlingStep1.tsx @@ -1,13 +1,19 @@ +import AlterationAccordionItem from 'benefit/handler/components/applicationReview/handlingView/AlterationAccordionItem'; +import DecisionCalculationAccordion + from 'benefit/handler/components/applicationReview/handlingView/DecisionCalculationAccordion'; import { HANDLED_STATUSES } from 'benefit/handler/constants'; import ReviewStateContext from 'benefit/handler/context/ReviewStateContext'; -import { - APPLICATION_ORIGINS, - APPLICATION_STATUSES, -} from 'benefit-shared/constants'; -import { Application } from 'benefit-shared/types/application'; +import DecisionSummary from 'benefit-shared/components/decisionSummary/DecisionSummary'; +import StatusIcon from 'benefit-shared/components/statusIcon/StatusIcon'; +import { APPLICATION_ORIGINS, APPLICATION_STATUSES, } from 'benefit-shared/constants'; +import { Application, DecisionDetailList } from 'benefit-shared/types/application'; import { ErrorData } from 'benefit-shared/types/common'; import * as React from 'react'; +import { useMemo } from 'react'; import Container from 'shared/components/container/Container'; +import { getFullName } from 'shared/utils/application.utils'; +import { convertToUIDateFormat } from 'shared/utils/date.utils'; +import { formatFloatToCurrency } from 'shared/utils/string.utils'; import ApplicationProcessingView from '../applicationProcessingView/ApplicationProcessingView'; import BenefitView from '../benefitView/BenefitView'; @@ -39,9 +45,48 @@ const HandlingStep1: React.FC = ({ calculationsErrors, setCalculationErrors, }) => { - const { isUploading, handleUpload, reviewState, handleUpdateReviewState } = + const { isUploading, handleUpload, reviewState, handleUpdateReviewState, t } = useApplicationReview(); + const decisionDetailList = useMemo(() => [ + { + accessor: (app) => <> + + {t(`common:applications.statuses.${app.status}`)} + , + key: 'status', + }, + { + accessor: (app) => formatFloatToCurrency(app.calculatedBenefitAmount), + key: 'benefitAmount', + }, + { + accessor: (app) => `${convertToUIDateFormat(app.startDate)} – ${convertToUIDateFormat(app.endDate)}`, + key: 'benefitPeriod', + }, + { + accessor: (app) => app.batch?.sectionOfTheLaw, + key: 'sectionOfLaw', + }, + { + accessor: (app) => app.calculation.grantedAsDeMinimisAid ? t('utility.yes') : t('utility.no'), + key: 'grantedAsDeMinimis', + }, + { + accessor: (app) => convertToUIDateFormat(app.ahjoDecisionDate), + key: 'decisionDate', + }, + { + accessor: (app) => app.batch?.handler && getFullName(app.batch.handler.firstName, app.batch.handler.lastName), + key: 'handler', + }, + { + accessor: (app) => app.batch?.decisionMakerName, + key: 'decisionMaker', + }, + ], [t]); + + return ( = ({ }} > + } + /> {application.applicationOrigin === APPLICATION_ORIGINS.HANDLER && ( )} diff --git a/frontend/benefit/handler/src/components/applicationsArchive/useApplicationsArchive.ts b/frontend/benefit/handler/src/components/applicationsArchive/useApplicationsArchive.ts index e629c4262c..1f8676d723 100644 --- a/frontend/benefit/handler/src/components/applicationsArchive/useApplicationsArchive.ts +++ b/frontend/benefit/handler/src/components/applicationsArchive/useApplicationsArchive.ts @@ -1,5 +1,6 @@ import useApplicationsQuery from 'benefit/handler/hooks/useApplicationsQuery'; import { getBatchDataReceived } from 'benefit/handler/utils/common'; +import { APPLICATION_STATUSES } from 'benefit-shared/constants'; import { ApplicationData, ApplicationListItemData, @@ -22,7 +23,11 @@ const translationsBase = 'common:applications.list'; const useApplicationsArchive = (): ApplicationListProps => { const { t } = useTranslation(); const query = useApplicationsQuery( - ['accepted', 'rejected', 'cancelled'], + [ + APPLICATION_STATUSES.ACCEPTED, + APPLICATION_STATUSES.REJECTED, + APPLICATION_STATUSES.CANCELLED + ], '-handled_at', false, true diff --git a/frontend/benefit/handler/src/components/header/useHeader.tsx b/frontend/benefit/handler/src/components/header/useHeader.tsx index 7748942149..3a10e2a306 100644 --- a/frontend/benefit/handler/src/components/header/useHeader.tsx +++ b/frontend/benefit/handler/src/components/header/useHeader.tsx @@ -43,7 +43,7 @@ const useHeader = (): ExtendedComponentProps => { label: ( <> {t('common:header.navigation.alterations')} - {!isAlterationListLoading && ( + {!isAlterationListLoading && alterationData.length > 0 && ( )} diff --git a/frontend/benefit/handler/src/hooks/useApplicationAlterationsQuery.ts b/frontend/benefit/handler/src/hooks/useApplicationAlterationsQuery.ts index e05fff502d..e1bdbcb06a 100644 --- a/frontend/benefit/handler/src/hooks/useApplicationAlterationsQuery.ts +++ b/frontend/benefit/handler/src/hooks/useApplicationAlterationsQuery.ts @@ -11,27 +11,27 @@ const useApplicationAlterationsQuery = ( const { axios, handleResponse } = useBackendAPI(); const { t } = useTranslation(); - const status = ['received']; + const state = ['received']; const handleError = (): void => { showErrorToast( t('common:applications.list.errors.fetch.label'), t('common:applications.list.errors.fetch.text', { - status, + state, }) ); }; const params: { order_by: string; - status: Array; + state: string; } = { order_by: orderBy, - status, + state: state.join(','), }; return useQuery( - ['applicationAlterationList', ...status], + ['applicationAlterationList', ...state], async () => { const res = axios.get( `${BackendEndpoint.HANDLER_APPLICATION_ALTERATION}`, diff --git a/frontend/benefit/handler/src/hooks/useDeleteApplicationAlterationQuery.ts b/frontend/benefit/handler/src/hooks/useDeleteApplicationAlterationQuery.ts new file mode 100644 index 0000000000..7c3d339162 --- /dev/null +++ b/frontend/benefit/handler/src/hooks/useDeleteApplicationAlterationQuery.ts @@ -0,0 +1,29 @@ +import { AxiosError } from 'axios'; +import { BackendEndpoint } from 'benefit-shared/backend-api/backend-api'; +import { useMutation, UseMutationResult, useQueryClient } from 'react-query'; +import useBackendAPI from 'shared/hooks/useBackendAPI'; + +import { ErrorData } from '../types/common'; + +const useDeleteApplicationQuery = (): UseMutationResult< + null, + AxiosError, + { id: string; applicationId: string } +> => { + const { axios, handleResponse } = useBackendAPI(); + const queryClient = useQueryClient(); + return useMutation( + 'deleteApplicationAlteration', + ({ id }) => + handleResponse( + axios.delete(`${BackendEndpoint.HANDLER_APPLICATION_ALTERATION}${id}/`) + ), + { + onSuccess: (data, { applicationId }) => { + void queryClient.resetQueries(['applications', applicationId]); + }, + } + ); +}; + +export default useDeleteApplicationQuery; diff --git a/frontend/benefit/handler/src/hooks/useUpdateApplicationAlterationQuery.ts b/frontend/benefit/handler/src/hooks/useUpdateApplicationAlterationQuery.ts new file mode 100644 index 0000000000..83bfc4a1f2 --- /dev/null +++ b/frontend/benefit/handler/src/hooks/useUpdateApplicationAlterationQuery.ts @@ -0,0 +1,34 @@ +import { AxiosError } from 'axios'; +import { BackendEndpoint } from 'benefit-shared/backend-api/backend-api'; +import { ApplicationAlterationData } from 'benefit-shared/types/application'; +import { useMutation, UseMutationResult, useQueryClient } from 'react-query'; +import useBackendAPI from 'shared/hooks/useBackendAPI'; + +import { ErrorData } from '../types/common'; + +const useDeleteApplicationQuery = (): UseMutationResult< + ApplicationAlterationData, + AxiosError, + { + id: number, + applicationId: string, + data: Partial + } +> => { + const { axios, handleResponse } = useBackendAPI(); + const queryClient = useQueryClient(); + return useMutation( + 'updateApplicationAlteration', + ({ id, data }) => + handleResponse( + axios.patch(`${BackendEndpoint.HANDLER_APPLICATION_ALTERATION}${id}/`, { ...data }), + ), + { + onSuccess: (data, { applicationId }) => { + void queryClient.resetQueries(['applications', applicationId]); + }, + } + ); +}; + +export default useDeleteApplicationQuery; diff --git a/frontend/benefit/handler/src/utils/calculator.ts b/frontend/benefit/handler/src/utils/calculator.ts index bb0cbb7a31..0f3df7288c 100644 --- a/frontend/benefit/handler/src/utils/calculator.ts +++ b/frontend/benefit/handler/src/utils/calculator.ts @@ -32,3 +32,44 @@ export const extractCalculatorRows = ( helsinkiBenefitMonthlyRows, }; }; + +// eslint-disable-next-line unicorn/prefer-set-has +const SUBTOTAL_CALCULATION_ROW_TYPES = [ + CALCULATION_ROW_TYPES.HELSINKI_BENEFIT_MONTHLY_EUR, + CALCULATION_ROW_TYPES.HELSINKI_BENEFIT_SUB_TOTAL_EUR +]; + +export const groupCalculatorRows = (rows: Row[]): Row[][] => { + const sections: Array> = []; + for (let start = 0, end = 0; start < rows.length; end = start) { + const firstRow = rows[start]; + if (SUBTOTAL_CALCULATION_ROW_TYPES.includes(firstRow.rowType)) { + // Select all subtotal lines into combined groups + while (SUBTOTAL_CALCULATION_ROW_TYPES.includes(rows[end]?.rowType)) { + end += 1; + } + sections.push(rows.slice(start, end)); + start = end; + } else if ( + firstRow.rowType === CALCULATION_ROW_TYPES.DESCRIPTION && + firstRow.descriptionType === CALCULATION_ROW_DESCRIPTION_TYPES.DATE + ) { + // Select all date description rows into their own groups + start += 1; + sections.push([firstRow]); + } else { + // Select all other rows that don't fall into the above groups into combined groups + end += 1; + while ( + rows[end] + && rows[end]?.rowType !== CALCULATION_ROW_TYPES.DESCRIPTION + && !SUBTOTAL_CALCULATION_ROW_TYPES.includes(rows[end]?.rowType)) { + end += 1; + } + sections.push(rows.slice(start, end)); + start = end; + } + } + + return sections; +} diff --git a/frontend/benefit/shared/.eslintrc.js b/frontend/benefit/shared/.eslintrc.js index 69b840982a..06f1d3eb65 100644 --- a/frontend/benefit/shared/.eslintrc.js +++ b/frontend/benefit/shared/.eslintrc.js @@ -1,3 +1,6 @@ module.exports = { - extends: ['../../.eslintrc.base.js'], + extends: ['../../.eslintrc.nextjs.js'], + rules: { + '@next/next/no-document-import-in-page': 'off' + } }; diff --git a/frontend/benefit/shared/next-env.d.ts b/frontend/benefit/shared/next-env.d.ts new file mode 100644 index 0000000000..4f11a03dc6 --- /dev/null +++ b/frontend/benefit/shared/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/frontend/benefit/shared/package.json b/frontend/benefit/shared/package.json index c77ebcf99a..09404c7dab 100644 --- a/frontend/benefit/shared/package.json +++ b/frontend/benefit/shared/package.json @@ -19,6 +19,7 @@ "hds-react": "^2.17.1", "ibantools": "^4.1.5", "next": "14.1.1", + "next-i18next": "^13.0.3", "next-router-mock": "0.9.12", "react": "^18.2.0", "styled-components": "^5.3.11", diff --git a/frontend/benefit/applicant/src/components/applications/pageContent/PageContent.sc.ts b/frontend/benefit/shared/src/components/decisionSummary/DecisionSummary.sc.ts similarity index 90% rename from frontend/benefit/applicant/src/components/applications/pageContent/PageContent.sc.ts rename to frontend/benefit/shared/src/components/decisionSummary/DecisionSummary.sc.ts index 8749246b79..60900702bc 100644 --- a/frontend/benefit/applicant/src/components/applications/pageContent/PageContent.sc.ts +++ b/frontend/benefit/shared/src/components/decisionSummary/DecisionSummary.sc.ts @@ -31,7 +31,9 @@ export const $Subheading = styled.h2` export const $DecisionDetails = styled.dl` display: flex; flex-direction: column; - gap: ${(props) => props.theme.spacing.s}; + flex-wrap: wrap; + box-sizing: border-box; + margin: 0 calc(${(props) => props.theme.spacing.s} * -1); ${respondAbove('md')` flex-direction: row; @@ -42,6 +44,11 @@ export const $DecisionDetails = styled.dl` } `}; + div { + box-sizing: border-box; + padding: ${(props) => props.theme.spacing.s}; + } + dt { font-weight: 500; margin-bottom: ${(props) => props.theme.spacing.s}; diff --git a/frontend/benefit/applicant/src/components/applications/pageContent/DecisionSummary.tsx b/frontend/benefit/shared/src/components/decisionSummary/DecisionSummary.tsx similarity index 53% rename from frontend/benefit/applicant/src/components/applications/pageContent/DecisionSummary.tsx rename to frontend/benefit/shared/src/components/decisionSummary/DecisionSummary.tsx index 6e583cf5b3..89fbf41394 100644 --- a/frontend/benefit/applicant/src/components/applications/pageContent/DecisionSummary.tsx +++ b/frontend/benefit/shared/src/components/decisionSummary/DecisionSummary.tsx @@ -1,4 +1,3 @@ -import AlterationAccordionItem from 'benefit/applicant/components/applications/alteration/AlterationAccordionItem'; import { $AlterationActionContainer, $AlterationListCount, @@ -8,30 +7,24 @@ import { $DecisionDetails, $DecisionNumber, $Subheading, -} from 'benefit/applicant/components/applications/pageContent/PageContent.sc'; -import StatusIcon from 'benefit/applicant/components/applications/StatusIcon'; -import { ROUTES } from 'benefit/applicant/constants'; -import { useTranslation } from 'benefit/applicant/i18n'; -import { - ALTERATION_STATE, - ALTERATION_TYPE, - APPLICATION_STATUSES, -} from 'benefit-shared/constants'; -import { Application } from 'benefit-shared/types/application'; +} from 'benefit-shared/components/decisionSummary/DecisionSummary.sc'; +import { AlterationAccordionItemProps, Application, DecisionDetailList } from 'benefit-shared/types/application'; import { isTruthy } from 'benefit-shared/utils/common'; import { Button, IconLinkExternal } from 'hds-react'; -import { useRouter } from 'next/router'; -import React from 'react'; +import { useTranslation } from 'next-i18next'; +import React, { ReactNode } from 'react'; import { convertToUIDateFormat } from 'shared/utils/date.utils'; -import { formatFloatToCurrency } from 'shared/utils/string.utils'; type Props = { application: Application; + actions: ReactNode; + itemComponent: React.ComponentType; + detailList: DecisionDetailList; + extraInformation?: ReactNode; }; -const DecisionSummary = ({ application }: Props): JSX.Element => { +const DecisionSummary = ({ application, actions, itemComponent: ItemComponent, detailList, extraInformation }: Props): JSX.Element => { const { t } = useTranslation(); - const router = useRouter(); if (!application.ahjoCaseId) { return null; @@ -44,12 +37,6 @@ const DecisionSummary = ({ application }: Props): JSX.Element => { window.open(`https://paatokset.hel.fi/fi/asia/${id}`, '_blank'); }; - const hasHandledTermination = application.alterations.some( - (alteration) => - alteration.state === ALTERATION_STATE.HANDLED && - alteration.alterationType === ALTERATION_TYPE.TERMINATION - ); - const sortedAlterations = application.alterations?.sort( (a, b) => Date.parse(a.endDate) - Date.parse(b.endDate) ); @@ -71,30 +58,14 @@ const DecisionSummary = ({ application }: Props): JSX.Element => { })} <$DecisionDetails> -
-
{t('common:applications.decision.headings.status')}
-
- - {t(`common:applications.statuses.${application.status}`)} -
-
-
-
{t('common:applications.decision.headings.benefitAmount')}
-
{formatFloatToCurrency(application.calculatedBenefitAmount)}
-
-
-
{t('common:applications.decision.headings.benefitPeriod')}
+ {detailList.map((detail) =>
+
{t(`common:applications.decision.headings.${detail.key}`)}
- {`${convertToUIDateFormat( - application.startDate - )} – ${convertToUIDateFormat(application.endDate)}`} + {detail.accessor(application) || '-'}
-
-
-
{t('common:applications.decision.headings.decisionDate')}
-
{convertToUIDateFormat(application.ahjoDecisionDate)}
-
+
)} + {extraInformation} <$DecisionActionContainer> - )} + {actions} )} diff --git a/frontend/benefit/shared/src/components/statusIcon/StatusIcon.sc.ts b/frontend/benefit/shared/src/components/statusIcon/StatusIcon.sc.ts new file mode 100644 index 0000000000..5c6f8fb81b --- /dev/null +++ b/frontend/benefit/shared/src/components/statusIcon/StatusIcon.sc.ts @@ -0,0 +1,33 @@ +import { APPLICATION_STATUSES } from 'benefit-shared/constants'; +import styled from 'styled-components'; + +export const $StatusIcon = styled.span` + display: inline-block; + + svg { + vertical-align: middle; + } + + &.status-icon--${APPLICATION_STATUSES.HANDLING} { + svg { + color: ${(props) => props.theme.colors.info}; + } + } + + &.status-icon--${APPLICATION_STATUSES.ACCEPTED} { + svg { + color: ${(props) => props.theme.colors.success}; + } + } + + &.status-icon--${APPLICATION_STATUSES.REJECTED}, + &.status-icon--${APPLICATION_STATUSES.CANCELLED} { + svg { + color: ${(props) => props.theme.colors.error}; + } + } + + &.status-icon--${APPLICATION_STATUSES.INFO_REQUIRED} { + color: ${(props) => props.theme.colors.alertDark}; + } +`; diff --git a/frontend/benefit/applicant/src/components/applications/StatusIcon.tsx b/frontend/benefit/shared/src/components/statusIcon/StatusIcon.tsx similarity index 92% rename from frontend/benefit/applicant/src/components/applications/StatusIcon.tsx rename to frontend/benefit/shared/src/components/statusIcon/StatusIcon.tsx index 6f85b0f706..ba9084ab56 100644 --- a/frontend/benefit/applicant/src/components/applications/StatusIcon.tsx +++ b/frontend/benefit/shared/src/components/statusIcon/StatusIcon.tsx @@ -1,4 +1,4 @@ -import { $StatusIcon } from 'benefit/applicant/components/applications/Applications.sc'; +import { $StatusIcon } from 'benefit-shared/components/statusIcon/StatusIcon.sc'; import { APPLICATION_STATUSES } from 'benefit-shared/constants'; import { IconAlertCircleFill, IconCheckCircle, IconCheckCircleFill, IconCrossCircleFill } from 'hds-react'; import React from 'react'; diff --git a/frontend/benefit/shared/src/constants.ts b/frontend/benefit/shared/src/constants.ts index 2bccd0f66b..ff58f75568 100644 --- a/frontend/benefit/shared/src/constants.ts +++ b/frontend/benefit/shared/src/constants.ts @@ -219,6 +219,7 @@ export enum ALTERATION_STATE { RECEIVED = 'received', OPENED = 'opened', HANDLED = 'handled', + CANCELLED = 'cancelled', } export enum DECISION_TYPES { diff --git a/frontend/benefit/shared/src/types/application.d.ts b/frontend/benefit/shared/src/types/application.d.ts index 3deddd6c6f..165b9a1e0d 100644 --- a/frontend/benefit/shared/src/types/application.d.ts +++ b/frontend/benefit/shared/src/types/application.d.ts @@ -8,7 +8,7 @@ import { PROPOSALS_FOR_DECISION, TALPA_STATUSES, } from 'benefit-shared/constants'; -import React from 'react'; +import React, { ReactNode } from 'react'; import { Language } from 'shared/i18n/i18n'; import { BenefitAttachment } from 'shared/types/attachment'; import { DefaultTheme } from 'styled-components'; @@ -627,6 +627,12 @@ export type ApplicationAlteration = { applicationNumber?: number; applicationEmployeeFirstName?: string; applicationEmployeeLastName?: string; + isRecoverable?: boolean; + recoveryJustification?: string; + handledBy?: User; + handledAt?: string; + cancelledBy?: User; + cancelledAt?: string; }; export type ApplicationAlterationData = { @@ -650,4 +656,22 @@ export type ApplicationAlterationData = { application_number?: number; application_employee_first_name?: string; application_employee_last_name?: string; + is_recoverable?: boolean; + recovery_justification?: string; + handled_by?: UserData; + handled_at?: string; + cancelled_by?: UserData; + cancelled_at?: string; +}; + +export type AlterationAccordionItemProps = { + alteration: ApplicationAlteration; + application: Application; }; + +export type DecisionDetailAccessorFunction = (app: Application) => ReactNode; + +export type DecisionDetailList = Array<{ + accessor: DecisionDetailAccessorFunction, + key: string +}>; diff --git a/frontend/benefit/shared/tsconfig.json b/frontend/benefit/shared/tsconfig.json index 6b2d59d61a..7472a29647 100644 --- a/frontend/benefit/shared/tsconfig.json +++ b/frontend/benefit/shared/tsconfig.json @@ -7,5 +7,11 @@ "benefit-shared/*": ["benefit/shared/src/*"], "shared/*": ["shared/src/*","shared/browser-tests/*"] } - } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + "../../shared/src/types/*.d.ts" + ] } diff --git a/frontend/shared/src/styles/globalStyling.ts b/frontend/shared/src/styles/globalStyling.ts index 272710f46e..d1e2702f68 100644 --- a/frontend/shared/src/styles/globalStyling.ts +++ b/frontend/shared/src/styles/globalStyling.ts @@ -26,5 +26,13 @@ const GlobalStyle = createGlobalStyle` div#hds-tag { border-radius: 15px; } + + .sr-only { + position: absolute !important; + height: 1px; width: 1px; + overflow: hidden; + clip: rect(1px 1px 1px 1px); + clip: rect(1px, 1px, 1px, 1px); + } `; export default GlobalStyle; diff --git a/frontend/shared/src/styles/theme.ts b/frontend/shared/src/styles/theme.ts index da43e90110..ff0dc41253 100644 --- a/frontend/shared/src/styles/theme.ts +++ b/frontend/shared/src/styles/theme.ts @@ -3,6 +3,7 @@ import { DefaultTheme } from 'styled-components'; const tokens = { coatOfArms: 'var(--color-coat-of-arms)', fog: 'var(--color-fog)', + danger: 'var(--color-error)' }; const componentColors = { @@ -19,6 +20,7 @@ const componentColors = { }, modal: { base: tokens.coatOfArms, + danger: tokens.danger }, }; @@ -202,6 +204,9 @@ const theme: DefaultTheme = { coat: { '--accent-line-color': componentColors.modal.base, }, + danger: { + '--accent-line-color': componentColors.modal.danger, + }, }, }, }; diff --git a/frontend/shared/src/types/styled-components.d.ts b/frontend/shared/src/types/styled-components.d.ts index 4d4b71dd05..aa356f0aec 100644 --- a/frontend/shared/src/types/styled-components.d.ts +++ b/frontend/shared/src/types/styled-components.d.ts @@ -174,6 +174,9 @@ declare module 'styled-components' { coat: { '--accent-line-color': string; }; + danger: { + '--accent-line-color': string; + }; }; }; }