diff --git a/backend/benefit/README.md b/backend/benefit/README.md index 5168f82fff..cb61d9cf2b 100644 --- a/backend/benefit/README.md +++ b/backend/benefit/README.md @@ -44,6 +44,7 @@ Load test fixtures python manage.py loaddata default_terms.json python manage.py loaddata groups.json + python manage.py loaddata test_applications.json This creates terms of service and applicant terms in the database. The attachment PDF files are not actually created by loading the fixture. In order to actually download the PDF files, log in via the django admin diff --git a/backend/benefit/applications/api/v1/ahjo_decision_views.py b/backend/benefit/applications/api/v1/ahjo_decision_views.py index a1fc8b3cde..af3e16e74f 100644 --- a/backend/benefit/applications/api/v1/ahjo_decision_views.py +++ b/backend/benefit/applications/api/v1/ahjo_decision_views.py @@ -2,17 +2,23 @@ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema, OpenApiParameter from rest_framework import status +from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.views import APIView +from applications.api.v1.serializers.decision_proposal import ( + AhjoDecisionProposalSerializer, +) from applications.api.v1.serializers.decision_proposal_template import ( DecisionProposalTemplateSectionSerializer, ) from applications.api.v1.serializers.decision_text import DecisionTextSerializer -from applications.enums import ApplicationStatus +from applications.enums import ApplicationStatus, DecisionType from applications.models import ( + AhjoDecisionProposalDraft, AhjoDecisionText, Application, + ApplicationLogEntry, DecisionProposalTemplateSection, ) from applications.services.ahjo_decision_service import process_template_sections @@ -42,10 +48,16 @@ class DecisionProposalTemplateSectionList(APIView): ) def get(self, request, format=None) -> Response: application_id = self.request.query_params.get("application_id") + try: application = ( Application.objects.filter( - pk=application_id, status=ApplicationStatus.ACCEPTED + pk=application_id, + status__in=[ + ApplicationStatus.HANDLING, + ApplicationStatus.ACCEPTED, + ApplicationStatus.REJECTED, + ], ) .prefetch_related("calculation", "company") .first() @@ -55,11 +67,15 @@ def get(self, request, format=None) -> Response: {"message": "Application not found"}, status=status.HTTP_404_NOT_FOUND ) + language = ( + application.applicant_language + if application.applicant_language == "sv" + else "fi" + ) decision_types = self.request.query_params.getlist("decision_type") - section_types = self.request.query_params.getlist("section_type") template_sections = DecisionProposalTemplateSection.objects.filter( - section_type__in=section_types, decision_type__in=decision_types + decision_type__in=decision_types, language=language ) replaced_template_sections = process_template_sections( @@ -131,3 +147,76 @@ def put(self, request, application_id, decision_id): serializer.save() return Response(serializer.data) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class DecisionProposalDraftUpdate(APIView): + permission_classes = [BFIsHandler] + + @action(methods=["PATCH"], detail=False) + def patch(self, request): + app_id = request.data["application_id"] + application = get_object_or_404(Application, id=app_id) + + proposal_object = AhjoDecisionProposalDraft.objects.filter( + application=application + ).first() + + proposal = AhjoDecisionProposalSerializer( + proposal_object, data=request.data, partial=True + ) + + if not proposal.is_valid(): + return Response(proposal.errors, status=status.HTTP_400_BAD_REQUEST) + + data = proposal.validated_data + proposal.save() + + if data.get("decision_text") and data.get("justification_text"): + ahjo_text = AhjoDecisionText.objects.filter(application=application) + if ahjo_text: + ahjo_text.update( + language=application.applicant_language, + decision_type=( + DecisionType.ACCEPTED + if data["status"] == ApplicationStatus.ACCEPTED + else DecisionType.DENIED + ), + decision_text=data["decision_text"] + + "\n\n" + + data.get("justification_text"), + ) + else: + AhjoDecisionText.objects.create( + application=application, + language=application.applicant_language, + decision_type=( + DecisionType.ACCEPTED + if data["status"] == ApplicationStatus.ACCEPTED + else DecisionType.DENIED + ), + decision_text=data.get("decision_text") + + "\n\n" + + data.get("justification_text"), + ) + + if data["review_step"] >= 4: + # Ensure frontend has a page that exists + proposal.review_step = 3 + proposal.save() + + ApplicationLogEntry.objects.create( + application=application, + from_status=application.status, + to_status=data["status"], + comment=data.get("log_entry_comment") or "", + ) + application.status = data.get("status") + + application.save() + + application.calculation.granted_as_de_minimis_aid = data[ + "granted_as_de_minimis_aid" + ] + application.calculation.save() + + return Response(proposal.data, status=status.HTTP_200_OK) diff --git a/backend/benefit/applications/api/v1/decision_proposal_draft_views.py b/backend/benefit/applications/api/v1/decision_proposal_draft_views.py new file mode 100755 index 0000000000..1861831c19 --- /dev/null +++ b/backend/benefit/applications/api/v1/decision_proposal_draft_views.py @@ -0,0 +1,125 @@ +from django.db import transaction +from django.shortcuts import get_object_or_404 +from django_filters import rest_framework as filters +from drf_spectacular.utils import extend_schema +from rest_framework import filters as drf_filters, status, viewsets +from rest_framework.decorators import action +from rest_framework.response import Response + +from applications.api.v1.serializers.decision_proposal import ( + AhjoDecisionProposalReadOnlySerializer, + AhjoDecisionProposalSerializer, +) +from applications.enums import ApplicationStatus, DecisionType +from applications.models import AhjoDecisionProposalDraft, AhjoDecisionText, Application +from common.permissions import BFIsHandler + + +class AhjoDecisionProposalDraftFilter(filters.FilterSet): + class Meta: + model = AhjoDecisionProposalDraft + fields = { + "status": ["exact"], + } + + +@extend_schema( + description=( + "API for create/read/update/delete/export operations on Helsinki benefit" + " application batches" + ) +) +class AhjoDecisionProposalDraftViewSet(viewsets.ViewSet): + queryset = AhjoDecisionProposalDraft.objects.all() + serializer_class = AhjoDecisionProposalSerializer + permission_classes = [BFIsHandler] + filter_backends = [ + drf_filters.OrderingFilter, + filters.DjangoFilterBackend, + drf_filters.SearchFilter, + ] + filterset_class = AhjoDecisionProposalDraftFilter + + def get_queryset(self): + order_by = self.request.query_params.get("order_by") or None + + if order_by: + self.queryset = self.queryset.order_by(order_by) + + return self.queryset + + def get_serializer_class(self): + """ + ApplicationBatchSerializer for default behaviour on mutation functions, + ApplicationBatchListSerializer for listing applications on a batch + """ + if self.action in ["create", "update", "partial_update", "destroy"]: + return AhjoDecisionProposalReadOnlySerializer + + return AhjoDecisionProposalSerializer + + @action(methods=["PATCH"], detail=False) + @transaction.atomic + def modify(self, request, pk=None): + """ + Override default destroy(), batch can only be deleted if it's status is "draft" + """ + app_id = request.data["application_id"] + application = get_object_or_404(Application, id=app_id) + + proposal_object = AhjoDecisionProposalDraft.objects.filter( + application=application + ).first() + + proposal = AhjoDecisionProposalSerializer( + proposal_object, data=request.data, partial=True + ) + + if not proposal.is_valid(): + return Response(proposal.errors, status=status.HTTP_400_BAD_REQUEST) + + data = proposal.validated_data + proposal.save() + + if data.get("decision_text") and data.get("justification_text"): + ahjo_text = AhjoDecisionText.objects.filter(application=application) + if ahjo_text: + ahjo_text.update( + language=application.applicant_language, + decision_type=( + DecisionType.ACCEPTED + if data["status"] == ApplicationStatus.ACCEPTED + else DecisionType.DENIED + ), + decision_text=data["decision_text"] + + "\n\n" + + data["justification_text"], + ) + else: + AhjoDecisionText.objects.create( + application=application, + language=application.applicant_language, + decision_type=( + DecisionType.ACCEPTED + if data["status"] == ApplicationStatus.ACCEPTED + else DecisionType.DENIED + ), + decision_text=data["decision_text"] + + "\n\n" + + data["justification_text"], + ) + + if data["review_step"] >= 4: + # Ensure GUI has a page that exists + proposal.review_step = 3 + proposal.save() + + application.status = data["status"] + application.save() + + application.calculation.granted_as_de_minimis_aid = data[ + "granted_as_de_minimis_aid" + ] + application.calculation.save() + + return Response(proposal.data, status=status.HTTP_200_OK) diff --git a/backend/benefit/applications/api/v1/serializers/application.py b/backend/benefit/applications/api/v1/serializers/application.py index c2eed29de2..5d90bcbe0d 100755 --- a/backend/benefit/applications/api/v1/serializers/application.py +++ b/backend/benefit/applications/api/v1/serializers/application.py @@ -18,6 +18,9 @@ from applications.api.v1.serializers.attachment import AttachmentSerializer from applications.api.v1.serializers.batch import ApplicationBatchSerializer from applications.api.v1.serializers.de_minimis import DeMinimisAidSerializer +from applications.api.v1.serializers.decision_proposal import ( + AhjoDecisionProposalReadOnlySerializer, +) from applications.api.v1.serializers.employee import EmployeeSerializer from applications.api.v1.serializers.utils import DynamicFieldsModelSerializer from applications.api.v1.status_transition_validator import ( @@ -37,6 +40,7 @@ PaySubsidyGranted, ) from applications.models import ( + AhjoDecisionProposalDraft, AhjoStatus, Application, ApplicationBasis, @@ -1251,6 +1255,7 @@ def _base_update(self, instance, validated_data): de_minimis_data = validated_data.pop("de_minimis_aid_set", None) employee_data = validated_data.pop("employee", None) approve_terms = validated_data.pop("approve_terms", None) + decision_proposal_data = validated_data.pop("decision_proposal", None) pre_update_status = instance.status application = super().update(instance, validated_data) if de_minimis_data is not None: @@ -1259,6 +1264,11 @@ def _base_update(self, instance, validated_data): if employee_data is not None: self._update_or_create_employee(application, employee_data) + if decision_proposal_data is not None: + AhjoDecisionProposalDraft.objects.update_or_create( + application=application, defaults=decision_proposal_data + ) + if instance.status != pre_update_status: self.handle_status_transition( instance, @@ -1284,6 +1294,9 @@ def create(self, validated_data): self.assign_default_fields_from_company(application, validated_data["company"]) self._update_de_minimis_aid(application, de_minimis_data) self._update_or_create_employee(application, employee_data) + AhjoDecisionProposalDraft.objects.update_or_create( + application=application, defaults={"review_step": 1} + ) return application def _update_or_create_employee(self, application, employee_data): @@ -1519,6 +1532,10 @@ class HandlerApplicationSerializer(BaseApplicationSerializer): read_only=True, help_text="Application batch of this application, if any" ) + decision_proposal_draft = AhjoDecisionProposalReadOnlySerializer( + required=False, allow_null=True, read_only=True + ) + create_application_for_company = serializers.UUIDField( read_only=True, required=False, @@ -1576,6 +1593,7 @@ class Meta(BaseApplicationSerializer.Meta): "handled_at", "application_origin", "paper_application_date", + "decision_proposal_draft", ] read_only_fields = BaseApplicationSerializer.Meta.read_only_fields + [ "latest_decision_comment", diff --git a/backend/benefit/applications/api/v1/serializers/decision_proposal.py b/backend/benefit/applications/api/v1/serializers/decision_proposal.py new file mode 100644 index 0000000000..8e9ff09365 --- /dev/null +++ b/backend/benefit/applications/api/v1/serializers/decision_proposal.py @@ -0,0 +1,85 @@ +from django.core.exceptions import ValidationError +from rest_framework import serializers + +from applications.enums import ApplicationStatus, HandlerRole +from applications.models import AhjoDecisionProposalDraft + + +class AhjoDecisionProposalReadOnlySerializer(serializers.ModelSerializer): + class Meta: + model = AhjoDecisionProposalDraft + fields = [ + "granted_as_de_minimis_aid", + "status", + "decision_text", + "justification_text", + "review_step", + "handler_role", + "log_entry_comment", + ] + + +class AhjoDecisionProposalSerializer(serializers.ModelSerializer): + """ + # TODO: Add description + + """ + + class Meta: + model = AhjoDecisionProposalDraft + fields = [ + "granted_as_de_minimis_aid", + "status", + "decision_text", + "justification_text", + "review_step", + "handler_role", + "log_entry_comment", + ] + + def validate(self, data): + errors = [] + if data["review_step"] >= 2: + if data.get("status", None) not in [ + ApplicationStatus.ACCEPTED, + ApplicationStatus.REJECTED, + ]: + errors.append( + ValidationError("Pending for status must ACCEPTED or REJECTED") + ) + if ( + data.get("status", None) + in [ + ApplicationStatus.ACCEPTED, + ] + and data.get("granted_as_de_minimis_aid", None) is None + ): + errors.append( + ValidationError("Granted as de minimis aid must be true or false") + ) + if ( + data.get("status", None) + in [ + ApplicationStatus.REJECTED, + ] + and len(data.get("log_entry_comment") or "") == 0 + ): + errors.append(ValidationError("Log entry comment cannot be empty")) + + if data["review_step"] >= 3: + if ( + len(data.get("decision_text") or "") < 8 + or len(data.get("justification_text") or "") < 8 + ): + errors.append( + ValidationError("Decision or justification texts cannot be empty") + ) + if (data.get("handler_role", None)) not in [ + HandlerRole.HANDLER, + HandlerRole.MANAGER, + ]: + errors.append(ValidationError("Handler role must be specified")) + if len(errors) > 0: + raise ValidationError(errors) + + return data diff --git a/backend/benefit/applications/api/v1/serializers/decision_proposal_template.py b/backend/benefit/applications/api/v1/serializers/decision_proposal_template.py index 43426a9ad0..b73c32d835 100644 --- a/backend/benefit/applications/api/v1/serializers/decision_proposal_template.py +++ b/backend/benefit/applications/api/v1/serializers/decision_proposal_template.py @@ -6,4 +6,9 @@ class DecisionProposalTemplateSectionSerializer(serializers.ModelSerializer): class Meta: model = DecisionProposalTemplateSection - fields = ["id", "name", "section_type", "template_text"] + fields = [ + "id", + "name", + "template_justification_text", + "template_decision_text", + ] diff --git a/backend/benefit/applications/enums.py b/backend/benefit/applications/enums.py index 0f4ab868b3..1ebd052a0e 100644 --- a/backend/benefit/applications/enums.py +++ b/backend/benefit/applications/enums.py @@ -195,15 +195,6 @@ class AhjoRequestType(models.TextChoices): SEND_DECISION_PROPOSAL = "send_decision", _("Send decision to Ahjo") -class DecisionProposalTemplateSectionType(models.TextChoices): - DECISION_SECTION = "decision_section", _( - "Template part for the decision section of a application decision proposal" - ) - JUSTIFICATION_SECTION = "justification_section", _( - "Template part for the decision justification section of a decision proposal" - ) - - class DecisionType(models.TextChoices): ACCEPTED = "accepted_decision", _("An accepted decision") DENIED = "denied_decision", _("A denied decision") @@ -220,6 +211,17 @@ class ApplicationAlterationState(models.TextChoices): HANDLED = "handled", _("Handled") +class HandlerRole(models.TextChoices): + HANDLER = "handler", _("Helsinki-benefit handler") + MANAGER = "manager", _("Team manager") + + +class ApplicationReviewStep(models.TextChoices): + STEP_1 = "step_1", _("Step 1 - overview and accept/reject application") + STEP_2 = "step_2", _("Step 2 - preparing the decision") + STEP_3 = "step_3", _("Step 3 - review the decision") + + # Call gettext on some of the enums so that "makemessages" command can find them when used dynamically in templates _("granted") _("granted_aged") diff --git a/backend/benefit/applications/fixtures/test_applications.json b/backend/benefit/applications/fixtures/test_applications.json new file mode 100644 index 0000000000..e4a397b31d --- /dev/null +++ b/backend/benefit/applications/fixtures/test_applications.json @@ -0,0 +1,314 @@ +[ + { + "model": "companies.company", + "pk": "746afc66-6f5a-4cb4-805f-4b58380b4745", + "fields": { + "name": "Automaattinen Testaus Keskus Oy", + "business_id": "5177536-7", + "company_form": "OY", + "street_address": "Reikäkorttitehtaankatu 3", + "postcode": "00120", + "city": "Helsinki", + "bank_account_number": "FI2112345600000785", + "company_form_code": 16 + } + }, + { + "model": "users.user", + "pk": "47ecedfa-351b-4815-bfac-96bdbc640178", + "fields": { + "password": "", + "last_login": "2024-03-26T13:46:05.974Z", + "is_superuser": false, + "username": "handler_examplesson", + "first_name": "Handy", + "last_name": "Examplesson", + "email": "handler-test@example.com", + "is_staff": true, + "is_active": true, + "date_joined": "2024-03-26T13:46:05.966Z", + "ad_username": null, + "groups": [], + "user_permissions": [] + } + }, + { + "model": "applications.application", + "pk": "fd89abe9-54e6-4112-a7ac-50147d95e530", + "fields": { + "created_at": "2024-03-26T13:45:24.607Z", + "modified_at": "2024-03-26T13:46:26.545Z", + "company": "746afc66-6f5a-4cb4-805f-4b58380b4745", + "status": "handling", + "talpa_status": "not_sent_to_talpa", + "application_origin": "applicant", + "application_number": 999999, + "company_name": "Automaattinen Testaus Keskus Oy", + "company_form": "OY", + "company_form_code": 16, + "company_department": "ATK Oy Tampereen keskusyksikkö", + "official_company_street_address": "Reikäkorttitehtaankatu 3", + "official_company_city": "Helsinki", + "official_company_postcode": "00120", + "use_alternative_address": true, + "alternative_company_street_address": "Toinentie 2", + "alternative_company_city": "Tampere", + "alternative_company_postcode": "33101", + "company_bank_account_number": "FI6033556370003404", + "company_contact_person_first_name": "Lumo", + "company_contact_person_last_name": "Tuomenkäpy", + "company_contact_person_phone_number": "+358501234567", + "company_contact_person_email": "lumo.tuanoinnii@example.com", + "association_has_business_activities": null, + "applicant_language": "fi", + "association_immediate_manager_check": null, + "co_operation_negotiations": false, + "co_operation_negotiations_description": "", + "pay_subsidy_granted": "not_granted", + "pay_subsidy_percent": null, + "additional_pay_subsidy_percent": null, + "apprenticeship_program": null, + "archived": false, + "application_step": "step_6", + "benefit_type": "salary_benefit", + "start_date": "2024-01-01", + "end_date": "2024-02-01", + "paper_application_date": null, + "de_minimis_aid": false, + "batch": null, + "ahjo_case_id": null, + "ahjo_case_guid": null, + "bases": [] + } + }, + { + "model": "applications.attachment", + "pk": "70a660b2-f674-4238-8693-421b4af668e2", + "fields": { + "created_at": "2024-03-26T13:45:42.731Z", + "modified_at": "2024-03-26T13:45:42.731Z", + "application": "fd89abe9-54e6-4112-a7ac-50147d95e530", + "attachment_type": "employment_contract", + "content_type": "image/png", + "attachment_file": "1_BqII2rT.png", + "ahjo_version_series_id": null, + "ahjo_hash_value": null + } + }, + { + "model": "applications.attachment", + "pk": "8744af7d-9624-4fc4-bbb0-31faaab6060d", + "fields": { + "created_at": "2024-03-26T13:45:46.601Z", + "modified_at": "2024-03-26T13:45:46.601Z", + "application": "fd89abe9-54e6-4112-a7ac-50147d95e530", + "attachment_type": "employee_consent", + "content_type": "image/png", + "attachment_file": "1_xELpIpP.png", + "ahjo_version_series_id": null, + "ahjo_hash_value": null + } + }, + { + "model": "applications.employee", + "pk": "eff3b55d-b671-4ca7-be78-70cc0ee30279", + "fields": { + "created_at": "2024-03-26T13:45:24.616Z", + "modified_at": "2024-03-26T13:46:26.552Z", + "application": "fd89abe9-54e6-4112-a7ac-50147d95e530", + "encrypted_first_name": "Aari", + "encrypted_last_name": "Hömpömpö", + "first_name": "Aari", + "last_name": "Hömpömpö", + "encrypted_social_security_number": "051201U7113", + "social_security_number": "051201U7113", + "phone_number": "", + "email": "", + "employee_language": "fi", + "job_title": "Et atque enim possim", + "monthly_pay": "1595.00", + "vacation_money": "47.00", + "other_expenses": "18.00", + "working_hours": "168.00", + "collective_bargaining_agreement": "Dolor minus laboris", + "is_living_in_helsinki": true, + "commission_amount": null, + "commission_description": "" + } + }, + { + "model": "applications.ahjodecisionproposaldraft", + "pk": 1, + "fields": { + "created_at": "2024-03-26T13:45:24.620Z", + "modified_at": "2024-03-26T13:45:24.620Z", + "application": "fd89abe9-54e6-4112-a7ac-50147d95e530", + "review_step": "1", + "status": null, + "log_entry_comment": null, + "granted_as_de_minimis_aid": false, + "handler_role": null, + "decision_text": null, + "justification_text": null + } + }, + { + "model": "applications.reviewstate", + "pk": "fd89abe9-54e6-4112-a7ac-50147d95e530", + "fields": { + "paper": false, + "company": false, + "company_contact_person": false, + "de_minimis_aids": false, + "co_operation_negotiations": false, + "employee": false, + "pay_subsidy": false, + "benefit": false, + "employment": false, + "approval": false + } + }, + { + "model": "applications.applicationlogentry", + "pk": "b0c8ee99-298e-4964-ab3f-8331462ba876", + "fields": { + "created_at": "2024-03-26T13:45:54.570Z", + "modified_at": "2024-03-26T13:45:54.570Z", + "application": "fd89abe9-54e6-4112-a7ac-50147d95e530", + "from_status": "draft", + "to_status": "received", + "comment": "" + } + }, + { + "model": "applications.applicationlogentry", + "pk": "f776e9a1-bc93-46c9-871d-d484a5293247", + "fields": { + "created_at": "2024-03-26T13:46:09.717Z", + "modified_at": "2024-03-26T13:46:09.717Z", + "application": "fd89abe9-54e6-4112-a7ac-50147d95e530", + "from_status": "received", + "to_status": "handling", + "comment": "" + } + }, + { + "model": "calculator.calculation", + "pk": "84e253ab-ebb2-434c-ab8a-48e52b0f1717", + "fields": { + "created_at": "2024-03-26T13:45:54.568Z", + "modified_at": "2024-03-26T13:46:26.569Z", + "handler": "47ecedfa-351b-4815-bfac-96bdbc640178", + "application": "fd89abe9-54e6-4112-a7ac-50147d95e530", + "monthly_pay": "1595.00", + "vacation_money": "47.00", + "other_expenses": "18.00", + "start_date": "2024-01-01", + "end_date": "2024-02-01", + "state_aid_max_percentage": 100, + "calculated_benefit_amount": "824.00", + "override_monthly_benefit_amount": null, + "granted_as_de_minimis_aid": false, + "target_group_check": false, + "override_monthly_benefit_amount_comment": "" + } + }, + { + "model": "calculator.calculationrow", + "pk": "02893d7b-4a09-49bb-b1cf-530a96e6a343", + "fields": { + "created_at": "2024-03-26T13:46:26.568Z", + "modified_at": "2024-03-26T13:46:26.568Z", + "calculation": "84e253ab-ebb2-434c-ab8a-48e52b0f1717", + "row_type": "helsinki_benefit_total_eur", + "ordering": 4, + "description_fi": "Helsinki-lisä yhteensä", + "amount": "824.00", + "start_date": "2024-01-01", + "end_date": "2024-02-01", + "description_type": "deduction" + } + }, + { + "model": "calculator.calculationrow", + "pk": "2612daa1-5772-4ee8-9081-72c592b1286c", + "fields": { + "created_at": "2024-03-26T13:46:26.565Z", + "modified_at": "2024-03-26T13:46:26.565Z", + "calculation": "84e253ab-ebb2-434c-ab8a-48e52b0f1717", + "row_type": "state_aid_max_monthly_eur", + "ordering": 1, + "description_fi": "Valtiotukimaksimi", + "amount": "1660.00", + "start_date": null, + "end_date": null, + "description_type": null + } + }, + { + "model": "calculator.calculationrow", + "pk": "716ce541-1e0e-480d-b072-2b5359ef930d", + "fields": { + "created_at": "2024-03-26T13:46:26.566Z", + "modified_at": "2024-03-26T13:46:26.566Z", + "calculation": "84e253ab-ebb2-434c-ab8a-48e52b0f1717", + "row_type": "helsinki_benefit_monthly_eur", + "ordering": 2, + "description_fi": "Helsinki-lisä", + "amount": "800.00", + "start_date": null, + "end_date": null, + "description_type": null + } + }, + { + "model": "calculator.calculationrow", + "pk": "9702a546-ba01-45b7-8f65-241c692512b8", + "fields": { + "created_at": "2024-03-26T13:46:26.564Z", + "modified_at": "2024-03-26T13:46:26.564Z", + "calculation": "84e253ab-ebb2-434c-ab8a-48e52b0f1717", + "row_type": "salary_costs", + "ordering": 0, + "description_fi": "Palkkauskustannukset", + "amount": "1660.00", + "start_date": null, + "end_date": null, + "description_type": null + } + }, + { + "model": "calculator.calculationrow", + "pk": "f9301e6f-e50a-468e-afc7-67a5d960fcc8", + "fields": { + "created_at": "2024-03-26T13:46:26.567Z", + "modified_at": "2024-03-26T13:46:26.567Z", + "calculation": "84e253ab-ebb2-434c-ab8a-48e52b0f1717", + "row_type": "helsinki_benefit_sub_total_eur", + "ordering": 3, + "description_fi": "Yhteensä ajanjaksolta", + "amount": "824.00", + "start_date": "2024-01-01", + "end_date": "2024-02-01", + "description_type": null + } + }, + { + "model": "terms.applicanttermsapproval", + "pk": "06ebf56e-f9ac-4a84-9cc6-f5f55571e0c1", + "fields": { + "created_at": "2024-03-26T13:45:54.565Z", + "modified_at": "2024-03-26T13:45:54.565Z", + "approved_at": "2024-03-26T13:45:54.564Z", + "approved_by": "47ecedfa-351b-4815-bfac-96bdbc640178", + "terms": "1282f1dc-9019-4311-83d0-8f5956634392", + "application": "fd89abe9-54e6-4112-a7ac-50147d95e530", + "selected_applicant_consents": [ + "1282f1dc-9019-4311-83d0-8f5956634392", + "061ed4a3-2195-46e0-a7d6-0859671bb277", + "d9a849c1-3700-4b04-9224-37d29c7abddf", + "f0478de2-bc68-469d-89ba-543ea040a0f6" + ] + } + } +] diff --git a/backend/benefit/applications/fixtures/test_decision_templates.json b/backend/benefit/applications/fixtures/test_decision_templates.json new file mode 100644 index 0000000000..44a0c43ec2 --- /dev/null +++ b/backend/benefit/applications/fixtures/test_decision_templates.json @@ -0,0 +1,54 @@ +[ + { + "model": "applications.decisionproposaltemplatesection", + "pk": "1b3b6c56-9195-42db-9a97-28f320b72937", + "fields": { + "created_at": "2024-03-27T08:00:24.562Z", + "modified_at": "2024-03-27T08:00:24.562Z", + "decision_type": "accepted_decision", + "language": "fi", + "template_decision_text": "

@role päätti myöntää $company:lle Työnantajan Helsinki-lisää käytettäväksi työllistetyn helsinkiläisen työllistämiseksi $total_amount euroa ajalle $benefit_date_range.

\n

Helsinki-lisään on varattu talousarviossa Helsingin kaupungin Työllisyyspalveluille vuosittain budjetoitu määräraha. Avustuksen kustannukset maksetaan kaupungin Työllisyyspalveluille osoitetusta määrärahasta talousarvion erikseen määritellyltä kohdalta. Työnantajan Helsinki-lisä on aina harkinnanvarainen.

\n", + "template_justification_text": "

Helsingin kaupunginhallituksen elinkeinojaosto on 11.9.2023 § 30 päättänyt tukea rahallisesti yksityisen ja kolmannen sektorin työnantajia, jotka tarjoavat työtä kaupungin työllisyydenhoidon kohderyhmiin kuuluville helsinkiläisille.

Avustus

\n

Kaupunginhallituksen elinkeinojaosto on päätöksellään 11.9.2023 § 30 hyväksynyt työnantajan Helsinki-lisän myöntämistä koskevat ehdot. Helsinki-lisän myöntämisessä noudatetaan lisäksi kaupunginhallituksen 28.10.2019 § 723 hyväksymiä Helsingin kaupungin avustusten yleisohjeita.

Helsinki-lisässä on kyse työllistettävän henkilön palkkauskustannuksiin kohdistuvasta avustuksesta. Helsinki-lisä kohdistuu palkan sivukuluihin ja lomarahaan sekä bruttopalkan siihen osaan, jota palkkatuki ei kata. Tuen määrään vaikuttavat samoihin kustannuksiin myönnetyt muut tuet (esim. palkkatuki ja oppisopimuksen koulutuskorvaus). Yritykselle ja taloudellista toimintaa harjoittavalle yhteisölle avustus myönnetään vähämerkityksisenä tukena eli ns. de minimis -tukena, ei koskaan RPA-tukena. Yleishyödyllisille yhteisöille avustus myönnetään valtiontukisääntelyn ulkopuolisena tukena, jos yhdistys ei harjoita taloudellista toimintaa

\n

Avustusta myönnetään valtiontukisäännöissä määrätyn kasautumissäännö sekä tuen enimmäisintensiteetin mukaisesti (tuen määrä suhteessa tukikelpoisiin kustannuksiin). Helsinki-lisä voi olla enintään 800 euroa kuukaudessa. Avustusta ei saa siirtää toisen tahon tai henkilön käytettäväksi. Avustus maksetaan hakemuksessa ilmoitetulle pankkitilille.

Helsinki-lisää saa käyttää ainoastaan kaupunginhallituksen elinkeinojaosto päätöksen 11.9.2023 §30 mukaisiin tarkoituksiin. Avustuksen saajan tulee viipymättä palauttaa virheellisesti, liikaa tai ilmeisen perusteettomasti saamansa avustus. Väärinkäyttötapauksessa kaupunki voi periä maksetun avustuksen takaisin.

\n

Avustuksen saaja sitoutuu antamaan tarvittaessa kaupungille tarvittavat tiedot sen varmistamiseksi, että avustusta ei ole käytetty ehtojen vastaisesti. Mikäli avustus maksetaan ennen päätöksen lainvoimaisuutta, avustuksen saaja sitoutuu palauttamaan jo maksetut avustukset, jos päätös muutoksenhaun johdosta muuttuu.

Valtiontukiarviointi

Yritykselle ja taloudellista toimintaa harjoittavalle yhteisölle avustus myönnetään vähämerkityksisenä tukena eli ns. de minimis -tukena. Tuen myöntämisessä noudatetaan komission asetusta (EU) 2023/2831, annettu 13.12.2023, Euroopan unionista tehdyn sopimuksen 107 ja 108 artiklan soveltamisesta vähämerkityksiseen tukeen (EUVL L2023/281, 15.12.2023).

Kullekin hakijalle myönnettävän de minimis -tuen määrä ilmenee hakijakohtaisesta liitteestä. Avustuksen saajalle voidaan myöntää de minimis -tukena enintään 300 000 euroa kuluvan vuoden ja kahden sitä edeltäneen kahden vuoden muodostaman jakson aikana. Avustuksen saaja vastaa siitä, että eri tahojen (mm. ministeriöt, ministeriöiden alaiset viranomaiset, Business Finland, Finnvera Oyj, kunnat, maakuntien liitot) myöntämien de minimis -tukien yhteismäärä ei ylitä tätä määrää. Avustuksen saaja on avustushakemuksessa ilmoittanut kaupungille kaikkien saamiensa de minimis -tukien määrät ja myöntöajankohdat.

\n", + "name": "FI: Myönteisen päätöksen Päätös-osion teksti" + } + }, + { + "model": "applications.decisionproposaltemplatesection", + "pk": "24b4cbbc-04d9-4e61-aaab-6dd0d16d7654", + "fields": { + "created_at": "2024-03-27T07:58:20.550Z", + "modified_at": "2024-03-27T07:58:20.550Z", + "decision_type": "denied_decision", + "language": "fi", + "template_decision_text": "

@role päätti hylätä $company:n hakemuksen koskien Työnantajan Helsinki-lisää, koska myöntämisen ehdot eivät täyty.

Helsinki-lisään on varattu talousarviossa Helsingin kaupungin Työllisyyspalveluille vuosittain budjetoitu määräraha. Avustuksen kustannukset maksetaan kaupungin Työllisyyspalveluille osoitetusta määrärahasta. Työnantajan Helsinki-lisä on aina harkinnanvarainen.

\n", + "template_justification_text": "

Helsingin kaupunginhallituksen elinkeinojaosto on 11.9.2023 § 30 päättänyt tukea rahallisesti yksityisen ja kolmannen sektorin työnantajia, jotka tarjoavat työtä kaupungin työllisyydenhoidon kohderyhmiin kuuluville helsinkiläisille.

Lisätään/kirjoitetaan jokaisen päätöksen yksittäinen perustelu tähän.

Työnantajan hakemus ei täytä Helsinki-lisän ehtoja, joten avustusta ei voida myöntää.

Avustus

\n

Kaupunginhallituksen elinkeinojaosto on päätöksellään 11.9.2023 § 30 hyväksynyt työnantajan Helsinki-lisän myöntämistä koskevat ehdot. Helsinki-lisän myöntämisessä noudatetaan lisäksi kaupunginhallituksen 28.10.2019 § 723 hyväksymiä Helsingin kaupungin avustusten yleisohjeita.

Helsinki-lisässä on kyse työllistettävän henkilön palkkauskustannuksiin kohdistuvasta avustuksesta. Helsinki-lisä kohdistuu palkan sivukuluihin ja lomarahaan sekä bruttopalkan siihen osaan, jota palkkatuki ei kata. Tuen määrään vaikuttavat samoihin kustannuksiin myönnetyt muut tuet (esim. palkkatuki ja oppisopimuksen koulutuskorvaus). Yritykselle ja taloudellista toimintaa harjoittavalle yhteisölle avustus myönnetään vähämerkityksisenä tukena eli ns. de minimis -tukena, ei koskaan RPA-tukena. Yleishyödyllisille yhteisöille avustus myönnetään valtiontukisääntelyn ulkopuolisena tukena, jos yhdistys ei harjoita taloudellista toimintaa

\n

Avustusta myönnetään valtiontukisäännöissä määrätyn kasautumissäännö sekä tuen enimmäisintensiteetin mukaisesti (tuen määrä suhteessa tukikelpoisiin kustannuksiin). Helsinki-lisä voi olla enintään 800 euroa kuukaudessa. Avustusta ei saa siirtää toisen tahon tai henkilön käytettäväksi. Avustus maksetaan hakemuksessa ilmoitetulle pankkitilille.

Helsinki-lisää saa käyttää ainoastaan kaupunginhallituksen elinkeinojaosto päätöksen 11.9.2023 §30 mukaisiin tarkoituksiin. Avustuksen saajan tulee viipymättä palauttaa virheellisesti, liikaa tai ilmeisen perusteettomasti saamansa avustus. Väärinkäyttötapauksessa kaupunki voi periä maksetun avustuksen takaisin.

\n

Avustuksen saaja sitoutuu antamaan tarvittaessa kaupungille tarvittavat tiedot sen varmistamiseksi, että avustusta ei ole käytetty ehtojen vastaisesti. Mikäli avustus maksetaan ennen päätöksen lainvoimaisuutta, avustuksen saaja sitoutuu palauttamaan jo maksetut avustukset, jos päätös muutoksenhaun johdosta muuttuu.

\n", + "name": "FI: Kielteisen päätöksen Päätöksen päätös-osion teksti" + } + }, + { + "model": "applications.decisionproposaltemplatesection", + "pk": "2b60a08a-44a7-446a-8ed3-b842c0cb3e95", + "fields": { + "created_at": "2024-03-27T08:00:25.265Z", + "modified_at": "2024-03-27T08:00:25.265Z", + "decision_type": "accepted_decision", + "language": "sv", + "template_decision_text": "

@role päätti myöntää $company:lle Työnantajan Helsinki-lisää käytettäväksi työllistetyn helsinkiläisen työllistämiseksi $total_amount euroa ajalle $benefit_date_range.

\n

Helsinki-lisään on varattu talousarviossa Helsingin kaupungin Työllisyyspalveluille vuosittain budjetoitu määräraha. Avustuksen kustannukset maksetaan kaupungin Työllisyyspalveluille osoitetusta määrärahasta talousarvion erikseen määritellyltä kohdalta. Työnantajan Helsinki-lisä on aina harkinnanvarainen.

\n", + "template_justification_text": "

Helsingin kaupunginhallituksen elinkeinojaosto on 11.9.2023 § 30 päättänyt tukea rahallisesti yksityisen ja kolmannen sektorin työnantajia, jotka tarjoavat työtä kaupungin työllisyydenhoidon kohderyhmiin kuuluville helsinkiläisille.

Avustus

\n

Kaupunginhallituksen elinkeinojaosto on päätöksellään 11.9.2023 § 30 hyväksynyt työnantajan Helsinki-lisän myöntämistä koskevat ehdot. Helsinki-lisän myöntämisessä noudatetaan lisäksi kaupunginhallituksen 28.10.2019 § 723 hyväksymiä Helsingin kaupungin avustusten yleisohjeita.

Helsinki-lisässä on kyse työllistettävän henkilön palkkauskustannuksiin kohdistuvasta avustuksesta. Helsinki-lisä kohdistuu palkan sivukuluihin ja lomarahaan sekä bruttopalkan siihen osaan, jota palkkatuki ei kata. Tuen määrään vaikuttavat samoihin kustannuksiin myönnetyt muut tuet (esim. palkkatuki ja oppisopimuksen koulutuskorvaus). Yritykselle ja taloudellista toimintaa harjoittavalle yhteisölle avustus myönnetään vähämerkityksisenä tukena eli ns. de minimis -tukena, ei koskaan RPA-tukena. Yleishyödyllisille yhteisöille avustus myönnetään valtiontukisääntelyn ulkopuolisena tukena, jos yhdistys ei harjoita taloudellista toimintaa

\n

Avustusta myönnetään valtiontukisäännöissä määrätyn kasautumissäännö sekä tuen enimmäisintensiteetin mukaisesti (tuen määrä suhteessa tukikelpoisiin kustannuksiin). Helsinki-lisä voi olla enintään 800 euroa kuukaudessa. Avustusta ei saa siirtää toisen tahon tai henkilön käytettäväksi. Avustus maksetaan hakemuksessa ilmoitetulle pankkitilille.

Helsinki-lisää saa käyttää ainoastaan kaupunginhallituksen elinkeinojaosto päätöksen 11.9.2023 §30 mukaisiin tarkoituksiin. Avustuksen saajan tulee viipymättä palauttaa virheellisesti, liikaa tai ilmeisen perusteettomasti saamansa avustus. Väärinkäyttötapauksessa kaupunki voi periä maksetun avustuksen takaisin.

\n

Avustuksen saaja sitoutuu antamaan tarvittaessa kaupungille tarvittavat tiedot sen varmistamiseksi, että avustusta ei ole käytetty ehtojen vastaisesti. Mikäli avustus maksetaan ennen päätöksen lainvoimaisuutta, avustuksen saaja sitoutuu palauttamaan jo maksetut avustukset, jos päätös muutoksenhaun johdosta muuttuu.

Valtiontukiarviointi

Yritykselle ja taloudellista toimintaa harjoittavalle yhteisölle avustus myönnetään vähämerkityksisenä tukena eli ns. de minimis -tukena. Tuen myöntämisessä noudatetaan komission asetusta (EU) 2023/2831, annettu 13.12.2023, Euroopan unionista tehdyn sopimuksen 107 ja 108 artiklan soveltamisesta vähämerkityksiseen tukeen (EUVL L2023/281, 15.12.2023).

Kullekin hakijalle myönnettävän de minimis -tuen määrä ilmenee hakijakohtaisesta liitteestä. Avustuksen saajalle voidaan myöntää de minimis -tukena enintään 300 000 euroa kuluvan vuoden ja kahden sitä edeltäneen kahden vuoden muodostaman jakson aikana. Avustuksen saaja vastaa siitä, että eri tahojen (mm. ministeriöt, ministeriöiden alaiset viranomaiset, Business Finland, Finnvera Oyj, kunnat, maakuntien liitot) myöntämien de minimis -tukien yhteismäärä ei ylitä tätä määrää. Avustuksen saaja on avustushakemuksessa ilmoittanut kaupungille kaikkien saamiensa de minimis -tukien määrät ja myöntöajankohdat.

\n", + "name": "SV: Myönteisen päätöksen Päätös-osion teksti" + } + }, + { + "model": "applications.decisionproposaltemplatesection", + "pk": "35bebb28-3a13-4b1e-8013-ba547ac5cada", + "fields": { + "created_at": "2024-03-27T08:00:25.432Z", + "modified_at": "2024-03-27T08:00:25.432Z", + "decision_type": "denied_decision", + "language": "sv", + "template_decision_text": "

@role päätti hylätä $company:n hakemuksen koskien Työnantajan Helsinki-lisää, koska myöntämisen ehdot eivät täyty.

Helsinki-lisään on varattu talousarviossa Helsingin kaupungin Työllisyyspalveluille vuosittain budjetoitu määräraha. Avustuksen kustannukset maksetaan kaupungin Työllisyyspalveluille osoitetusta määrärahasta. Työnantajan Helsinki-lisä on aina harkinnanvarainen.

\n", + "template_justification_text": "

Helsingin kaupunginhallituksen elinkeinojaosto on 11.9.2023 § 30 päättänyt tukea rahallisesti yksityisen ja kolmannen sektorin työnantajia, jotka tarjoavat työtä kaupungin työllisyydenhoidon kohderyhmiin kuuluville helsinkiläisille.

Lisätään/kirjoitetaan jokaisen päätöksen yksittäinen perustelu tähän.

Työnantajan hakemus ei täytä Helsinki-lisän ehtoja, joten avustusta ei voida myöntää.

Avustus

\n

Kaupunginhallituksen elinkeinojaosto on päätöksellään 11.9.2023 § 30 hyväksynyt työnantajan Helsinki-lisän myöntämistä koskevat ehdot. Helsinki-lisän myöntämisessä noudatetaan lisäksi kaupunginhallituksen 28.10.2019 § 723 hyväksymiä Helsingin kaupungin avustusten yleisohjeita.

Helsinki-lisässä on kyse työllistettävän henkilön palkkauskustannuksiin kohdistuvasta avustuksesta. Helsinki-lisä kohdistuu palkan sivukuluihin ja lomarahaan sekä bruttopalkan siihen osaan, jota palkkatuki ei kata. Tuen määrään vaikuttavat samoihin kustannuksiin myönnetyt muut tuet (esim. palkkatuki ja oppisopimuksen koulutuskorvaus). Yritykselle ja taloudellista toimintaa harjoittavalle yhteisölle avustus myönnetään vähämerkityksisenä tukena eli ns. de minimis -tukena, ei koskaan RPA-tukena. Yleishyödyllisille yhteisöille avustus myönnetään valtiontukisääntelyn ulkopuolisena tukena, jos yhdistys ei harjoita taloudellista toimintaa

\n

Avustusta myönnetään valtiontukisäännöissä määrätyn kasautumissäännö sekä tuen enimmäisintensiteetin mukaisesti (tuen määrä suhteessa tukikelpoisiin kustannuksiin). Helsinki-lisä voi olla enintään 800 euroa kuukaudessa. Avustusta ei saa siirtää toisen tahon tai henkilön käytettäväksi. Avustus maksetaan hakemuksessa ilmoitetulle pankkitilille.

Helsinki-lisää saa käyttää ainoastaan kaupunginhallituksen elinkeinojaosto päätöksen 11.9.2023 §30 mukaisiin tarkoituksiin. Avustuksen saajan tulee viipymättä palauttaa virheellisesti, liikaa tai ilmeisen perusteettomasti saamansa avustus. Väärinkäyttötapauksessa kaupunki voi periä maksetun avustuksen takaisin.

\n

Avustuksen saaja sitoutuu antamaan tarvittaessa kaupungille tarvittavat tiedot sen varmistamiseksi, että avustusta ei ole käytetty ehtojen vastaisesti. Mikäli avustus maksetaan ennen päätöksen lainvoimaisuutta, avustuksen saaja sitoutuu palauttamaan jo maksetut avustukset, jos päätös muutoksenhaun johdosta muuttuu.

\n", + "name": "SV: Kielteisen päätöksen Päätöksen päätös-osion teksti" + } + } +] diff --git a/backend/benefit/applications/management/commands/seed.py b/backend/benefit/applications/management/commands/seed.py index 4a308470ca..dd6d8eaa6a 100755 --- a/backend/benefit/applications/management/commands/seed.py +++ b/backend/benefit/applications/management/commands/seed.py @@ -25,14 +25,12 @@ ) from applications.tests.factories import ( AcceptedDecisionProposalFactory, - AcceptedDecisionProposalJustificationFactory, AdditionalInformationNeededApplicationFactory, ApplicationBatchFactory, ApplicationWithAttachmentFactory, CancelledApplicationFactory, DecidedApplicationFactory, DeniedDecisionProposalFactory, - DeniedDecisionProposalJustificationFactory, HandlingApplicationFactory, ReceivedApplicationFactory, RejectedApplicationFactory, @@ -190,6 +188,4 @@ def _past_datetime(days: int) -> datetime: def _create_templates(): AcceptedDecisionProposalFactory(), - AcceptedDecisionProposalJustificationFactory(), DeniedDecisionProposalFactory(), - DeniedDecisionProposalJustificationFactory(), diff --git a/backend/benefit/applications/migrations/0063_decision_proposal_template_changes.py b/backend/benefit/applications/migrations/0063_decision_proposal_template_changes.py new file mode 100644 index 0000000000..281aaa885d --- /dev/null +++ b/backend/benefit/applications/migrations/0063_decision_proposal_template_changes.py @@ -0,0 +1,48 @@ +# Generated by Django 3.2.23 on 2024-04-09 12:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("applications", "0062_alter_applicationalteration_recovery_amount"), + ] + + operations = [ + migrations.AlterModelOptions( + name="decisionproposaltemplatesection", + options={ + "verbose_name": "Ahjo decision text template", + "verbose_name_plural": "Ahjo decision text templates", + }, + ), + migrations.RemoveField( + model_name="decisionproposaltemplatesection", + name="section_type", + ), + migrations.RemoveField( + model_name="decisionproposaltemplatesection", + name="template_text", + ), + migrations.AddField( + model_name="decisionproposaltemplatesection", + name="template_decision_text", + field=models.TextField(null=True, verbose_name="Proposal text, decision"), + ), + migrations.AddField( + model_name="decisionproposaltemplatesection", + name="template_justification_text", + field=models.TextField( + null=True, verbose_name="Proposal text, justification" + ), + ), + migrations.AlterField( + model_name="decisionproposaltemplatesection", + name="name", + field=models.CharField( + max_length=256, verbose_name="name of the decision text template" + ), + ), + ] diff --git a/backend/benefit/applications/migrations/0064_decision_proposal_drafts.py b/backend/benefit/applications/migrations/0064_decision_proposal_drafts.py new file mode 100644 index 0000000000..68462de546 --- /dev/null +++ b/backend/benefit/applications/migrations/0064_decision_proposal_drafts.py @@ -0,0 +1,35 @@ +# Generated by Django 3.2.23 on 2024-04-09 12:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("applications", "0063_decision_proposal_template_changes"), + ] + + operations = [ + migrations.CreateModel( + name='AhjoDecisionProposalDraft', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='time created')), + ('modified_at', models.DateTimeField(auto_now=True, verbose_name='time modified')), + ('review_step', models.CharField(blank=True, choices=[(1, 'step 1'), (2, 'step 2'), (3, 'step 3'), (4, 'submitted')], default=1, max_length=64, null=True, verbose_name='step of the draft proposal')), + ('status', models.CharField(blank=True, choices=[('accepted', 'accepted'), ('rejected', 'rejected')], max_length=64, null=True, verbose_name='Proposal status')), + ('log_entry_comment', models.TextField(blank=True, null=True, verbose_name='Log entry comment')), + ('granted_as_de_minimis_aid', models.BooleanField(default=False, null=True)), + ('handler_role', models.CharField(blank=True, choices=[('handler', 'Helsinki-benefit handler'), ('manager', 'Team manager')], max_length=64, null=True, verbose_name='Handler role')), + ('decision_text', models.TextField(blank=True, null=True, verbose_name='Decision text content')), + ('justification_text', models.TextField(blank=True, null=True, verbose_name='Justification text content')), + ('application', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='decision_proposal_draft', to='applications.application', verbose_name='application')), + ], + options={ + 'verbose_name': 'decision_proposal_draft', + 'verbose_name_plural': 'decision_proposal_drafts', + 'db_table': 'bf_applications_decision_proposal_draft', + }, + ), + ] diff --git a/backend/benefit/applications/models.py b/backend/benefit/applications/models.py index 50343ef59c..6f7e19eead 100755 --- a/backend/benefit/applications/models.py +++ b/backend/benefit/applications/models.py @@ -22,8 +22,8 @@ ApplicationTalpaStatus, AttachmentType, BenefitType, - DecisionProposalTemplateSectionType, DecisionType, + HandlerRole, PaySubsidyGranted, ) from applications.exceptions import ( @@ -980,13 +980,6 @@ class DecisionProposalTemplateSection(UUIDModel, TimeStampedModel): text or the following justification text. """ - section_type = models.CharField( - max_length=64, - verbose_name=_("type of the decision proposal template section"), - choices=DecisionProposalTemplateSectionType.choices, - default=DecisionProposalTemplateSectionType.DECISION_SECTION, - ) - decision_type = models.CharField( max_length=64, verbose_name=_("type of the decision"), @@ -1000,12 +993,16 @@ class DecisionProposalTemplateSection(UUIDModel, TimeStampedModel): max_length=2, ) - template_text = models.TextField( - verbose_name=_("decision proposal section text content") + template_decision_text = models.TextField( + verbose_name=_("Proposal text, decision"), null=True + ) + + template_justification_text = models.TextField( + verbose_name=_("Proposal text, justification"), null=True ) name = models.CharField( - max_length=256, verbose_name=_("name of the decision proposal template section") + max_length=256, verbose_name=_("name of the decision text template") ) def __str__(self): @@ -1013,8 +1010,8 @@ def __str__(self): class Meta: db_table = "bf_applications_decision_proposal_template_section" - verbose_name = _("decision proposal template section") - verbose_name_plural = _("decision proposal template sections") + verbose_name = _("Ahjo decision text template") + verbose_name_plural = _("Ahjo decision text templates") class AhjoDecisionText(UUIDModel, TimeStampedModel): @@ -1141,3 +1138,72 @@ class ApplicationAlteration(TimeStampedModel): contact_person_name = models.TextField( verbose_name=_("contact person"), ) + + +class AhjoDecisionProposalDraft(TimeStampedModel): + """ + Draft of decision proposal. Supports application handling + when drafting a decision that ultimately leads to Ahjo handling and decision. + """ + + class Meta: + db_table = "bf_applications_decision_proposal_draft" + verbose_name = _("decision_proposal_draft") + verbose_name_plural = _("decision_proposal_drafts") + + application = models.OneToOneField( + Application, + verbose_name=_("application"), + related_name="decision_proposal_draft", + on_delete=models.CASCADE, + ) + + review_step = models.CharField( + max_length=64, + verbose_name=_("step of the draft proposal"), + blank=True, + null=True, + default=1, + choices=[(1, "step 1"), (2, "step 2"), (3, "step 3"), (4, "submitted")], + ) + + status = models.CharField( + max_length=64, + verbose_name=_("Proposal status"), + choices=[ + (ApplicationStatus.ACCEPTED, "accepted"), + (ApplicationStatus.REJECTED, "rejected"), + ], + null=True, + blank=True, + ) + + log_entry_comment = models.TextField( + verbose_name=_("Log entry comment"), + blank=True, + null=True, + ) + + granted_as_de_minimis_aid = models.BooleanField( + default=False, null=True, blank=False + ) + + handler_role = models.CharField( + max_length=64, + verbose_name=_("Handler role"), + blank=True, + null=True, + choices=HandlerRole.choices, + ) + + decision_text = models.TextField( + verbose_name=_("Decision text content"), + blank=True, + null=True, + ) + + justification_text = models.TextField( + verbose_name=_("Justification text content"), + blank=True, + null=True, + ) diff --git a/backend/benefit/applications/services/ahjo_decision_service.py b/backend/benefit/applications/services/ahjo_decision_service.py index 5f55856f34..cc99ce1145 100644 --- a/backend/benefit/applications/services/ahjo_decision_service.py +++ b/backend/benefit/applications/services/ahjo_decision_service.py @@ -3,7 +3,7 @@ from django.conf import settings -from applications.enums import DecisionProposalTemplateSectionType, DecisionType +from applications.enums import DecisionType, HandlerRole from applications.models import ( AhjoDecisionText, Application, @@ -11,9 +11,7 @@ ) from applications.tests.factories import ( AcceptedDecisionProposalFactory, - AcceptedDecisionProposalJustificationFactory, DeniedDecisionProposalFactory, - DeniedDecisionProposalJustificationFactory, ) @@ -23,19 +21,32 @@ class AhjoDecisionError(Exception): def replace_decision_template_placeholders( text_to_replace: str, + decision_type: DecisionType, application: Application, - decision_maker: str = "Päättäjä x (Helsinki-lisä-suunnittelija/Tiimipäällikkö)", + decision_maker: HandlerRole = HandlerRole.HANDLER, ) -> str: """Replace the placeholders starting with $ in the decision template with real data""" text_to_replace = Template(text_to_replace) - start_date = application.calculation.start_date.strftime("%d.%m.%Y") - end_date = application.calculation.end_date.strftime("%d.%m.%Y") + start_date = ( + application.calculation.start_date.strftime("%d.%m.%Y") + if decision_type == DecisionType.ACCEPTED + else application.start_date + ) + end_date = ( + application.calculation.end_date.strftime("%d.%m.%Y") + if decision_type == DecisionType.ACCEPTED + else application.end_date + ) try: return text_to_replace.substitute( - decision_maker=decision_maker, company=application.company.name, - total_amount=application.calculation.calculated_benefit_amount, - benefit_date_range=f"{start_date} - {end_date}", + decision_maker=decision_maker, + total_amount=( + application.calculation.calculated_benefit_amount + if decision_type == DecisionType.ACCEPTED + else "" + ), + benefit_date_range=(f"{start_date} - {end_date}"), ) except AhjoDecisionError as e: raise ValueError(f"Error in preparing the decision proposal template: {e}") @@ -48,10 +59,9 @@ def process_template_sections( """Loop through the template sections and conditionally replace placeholders if section is a decision section""" for section in template_sections: - if section.section_type == DecisionProposalTemplateSectionType.DECISION_SECTION: - section.template_text = replace_decision_template_placeholders( - section.template_text, application - ) + section.template_decision_text = replace_decision_template_placeholders( + section.template_decision_text, section.decision_type, application + ) return template_sections @@ -84,11 +94,11 @@ def _generate_decision_text_string( ) -> str: if decision_type == DecisionType.ACCEPTED: decision_section = AcceptedDecisionProposalFactory() - justification_section = AcceptedDecisionProposalJustificationFactory() else: decision_section = DeniedDecisionProposalFactory() - justification_section = DeniedDecisionProposalJustificationFactory() - decision_string = f"""

Päätös

{decision_section.template_text}
\ -

Päätösteksti

{justification_section.template_text}
""" - - return replace_decision_template_placeholders(decision_string, application) + decision_string = f"""

Päätös

{decision_section.template_decision_text}
\ +

Päätösteksti

{decision_section.template_justification_text}
""" # noqa + decision_type = decision_section.decision_type + return replace_decision_template_placeholders( + decision_string, decision_type, application + ) diff --git a/backend/benefit/applications/services/clamav.py b/backend/benefit/applications/services/clamav.py index 754f9895ec..428ba900c7 100644 --- a/backend/benefit/applications/services/clamav.py +++ b/backend/benefit/applications/services/clamav.py @@ -11,7 +11,7 @@ class ClamavClient: BASE_URL = None def __init__(self): - if not settings.CLAMAV_URL: + if not settings.CLAMAV_URL and not settings.NEXT_PUBLIC_MOCK_FLAG: log.warning( "CLAMAV_URL not defined in settings -- Clamav Client not enabled" ) diff --git a/backend/benefit/applications/tests/conftest.py b/backend/benefit/applications/tests/conftest.py index 7c019ea0e5..7062ff3567 100755 --- a/backend/benefit/applications/tests/conftest.py +++ b/backend/benefit/applications/tests/conftest.py @@ -418,7 +418,9 @@ def denied_ahjo_decision_section(): def accepted_ahjo_decision_text(decided_application): template = AcceptedDecisionProposalFactory() replaced_decision_text = replace_decision_template_placeholders( - template.template_text, decided_application + template.template_decision_text + template.template_justification_text, + DecisionType.ACCEPTED, + decided_application, ) return AhjoDecisionTextFactory( decision_type=DecisionType.ACCEPTED, diff --git a/backend/benefit/applications/tests/factories.py b/backend/benefit/applications/tests/factories.py index 0d539bb0c1..541ff31d5d 100755 --- a/backend/benefit/applications/tests/factories.py +++ b/backend/benefit/applications/tests/factories.py @@ -9,12 +9,12 @@ ApplicationStatus, ApplicationStep, BenefitType, - DecisionProposalTemplateSectionType, DecisionType, PaySubsidyGranted, ) from applications.models import ( AhjoDecision, + AhjoDecisionProposalDraft, AhjoDecisionText, Application, APPLICATION_LANGUAGE_CHOICES, @@ -70,6 +70,11 @@ class Meta: model = DeMinimisAid +class AhjoDecisionProposalDraftFactory(factory.django.DjangoModelFactory): + class Meta: + model = AhjoDecisionProposalDraft + + class ApplicationBasisFactory(factory.django.DjangoModelFactory): identifier = factory.Sequence( lambda id: f"basis_identifier_{id}" @@ -146,6 +151,11 @@ def bases(self, created, extracted, **kwargs): factory_related_name="application", ) + decision_proposal_draft = factory.RelatedFactory( + AhjoDecisionProposalDraftFactory, + factory_related_name="application", + ) + class Meta: model = Application @@ -370,10 +380,9 @@ class Meta: class AcceptedDecisionProposalFactory(factory.django.DjangoModelFactory): - section_type = DecisionProposalTemplateSectionType.DECISION_SECTION decision_type = DecisionType.ACCEPTED name = "Myönteisen päätöksen Päätös-osion teksti" - template_text = """

$decision_maker päätti myöntää $company:lle Työnantajan Helsinki-lisää \ + template_decision_text = """

$decision_maker päätti myöntää $company:lle Työnantajan Helsinki-lisää \ käytettäväksi työllistetyn helsinkiläisen työllistämiseksi $total_amount euroa ajalle $benefit_date_range.

Helsinki-lisään on varattu talousarviossa Helsingin kaupungin Työllisyyspalveluille \ vuosittain budjetoitu määräraha. Avustuksen kustannukset maksetaan \ @@ -381,15 +390,7 @@ class AcceptedDecisionProposalFactory(factory.django.DjangoModelFactory): erikseen määritellyltä kohdalta. Työnantajan Helsinki-lisä on aina harkinnanvarainen.

""" - class Meta: - model = DecisionProposalTemplateSection - - -class AcceptedDecisionProposalJustificationFactory(factory.django.DjangoModelFactory): - section_type = DecisionProposalTemplateSectionType.JUSTIFICATION_SECTION - decision_type = DecisionType.ACCEPTED - name = "Myönteisen Päätöksen perustelut-osion teksti" - template_text = """

Helsingin kaupunginhallituksen elinkeinojaosto on 11.9.2023 § 30 päättänyt \ + template_justification_text = """

Helsingin kaupunginhallituksen elinkeinojaosto on 11.9.2023 § 30 päättänyt \ tukea rahallisesti yksityisen ja kolmannen sektorin työnantajia, \ jotka tarjoavat työtä kaupungin työllisyydenhoidon \ kohderyhmiin kuuluville helsinkiläisille.

\ @@ -442,25 +443,15 @@ class Meta: class DeniedDecisionProposalFactory(factory.django.DjangoModelFactory): - section_type = DecisionProposalTemplateSectionType.DECISION_SECTION decision_type = DecisionType.DENIED name = "Kielteisen päätöksen Päätöksen päätös-osion teksti" - template_text = """

$decision_maker päätti hylätä $company:n hakemuksen koskien Työnantajan \ + template_decision_text = """

$decision_maker päätti hylätä $company:n hakemuksen koskien Työnantajan \ Helsinki-lisää, koska myöntämisen ehdot eivät täyty.

\

Helsinki-lisään on varattu talousarviossa Helsingin kaupungin Työllisyyspalveluille \ vuosittain budjetoitu määräraha. Avustuksen kustannukset maksetaan kaupungin \ Työllisyyspalveluille osoitetusta määrärahasta. Työnantajan Helsinki-lisä on aina harkinnanvarainen.

""" - - class Meta: - model = DecisionProposalTemplateSection - - -class DeniedDecisionProposalJustificationFactory(factory.django.DjangoModelFactory): - section_type = DecisionProposalTemplateSectionType.JUSTIFICATION_SECTION - decision_type = DecisionType.DENIED - name = "Kielteisen päätöksen Päätöksen perustelut-osion teksti" - template_text = """

Helsingin kaupunginhallituksen elinkeinojaosto on 11.9.2023 § 30 päättänyt \ + template_justification_text = """

Helsingin kaupunginhallituksen elinkeinojaosto on 11.9.2023 § 30 päättänyt \ tukea rahallisesti yksityisen ja kolmannen sektorin työnantajia, \ jotka tarjoavat työtä kaupungin työllisyydenhoidon kohderyhmiin \ kuuluville helsinkiläisille.

\ diff --git a/backend/benefit/applications/tests/test_ahjo_decision_proposal_drafts.py b/backend/benefit/applications/tests/test_ahjo_decision_proposal_drafts.py new file mode 100644 index 0000000000..2db5e786fe --- /dev/null +++ b/backend/benefit/applications/tests/test_ahjo_decision_proposal_drafts.py @@ -0,0 +1,143 @@ +from datetime import date + +import pytest +from rest_framework.reverse import reverse + +from applications.enums import ApplicationStatus +from applications.models import AhjoDecisionText +from calculator.models import Calculation +from calculator.tests.factories import PaySubsidyFactory + + +def _prepare_calculation(application): + application.calculation = Calculation( + application=application, + monthly_pay=1234, + vacation_money=123, + other_expenses=321, + start_date=application.start_date, + end_date=application.end_date, + state_aid_max_percentage=50, + calculated_benefit_amount=0, + override_monthly_benefit_amount=None, + ) + pay_subsidy = PaySubsidyFactory( + pay_subsidy_percent=50, start_date=date(2021, 7, 10), end_date=date(2021, 9, 10) + ) + application.pay_subsidies.add(pay_subsidy) + application.calculation.save() + application.save() + + +@pytest.mark.parametrize( + """response_status,review_step,status,granted_as_de_minimis_aid,log_entry_comment, + handler_role,decision_text,justification_text""", + [ + ( + 200, + 2, + ApplicationStatus.ACCEPTED, + False, + None, + None, + None, + None, + ), + ( + 200, + 3, + ApplicationStatus.ACCEPTED, + False, + None, + "manager", + "decision text", + "justification text", + ), + ( + 200, + 4, + ApplicationStatus.ACCEPTED, + False, + None, + "manager", + "decision text", + "justification text", + ), + ( + 400, + 2, + ApplicationStatus.REJECTED, + False, + "", + "", + "", + "", + ), + ( + 200, + 3, + ApplicationStatus.REJECTED, + False, + "some log entry comment", + "handler", + "decision text", + "justification text", + ), + ( + 400, + 3, + ApplicationStatus.REJECTED, + False, + "some log entry comment", + "handler", + "", + "justification text", + ), + ( + 400, + 3, + ApplicationStatus.REJECTED, + False, + "some log entry comment", + "handler", + "decision text", + "", + ), + ], +) +def test_decision_proposal_drafting( + application, + handler_api_client, + response_status, + review_step, + status, + granted_as_de_minimis_aid, + log_entry_comment, + handler_role, + decision_text, + justification_text, +): + if review_step == 4: + _prepare_calculation(application=application) + + url = reverse("decision_proposal_draft_update") + response = handler_api_client.patch( + url, + { + "application_id": application.id, + "review_step": review_step, + "status": status, + "log_entry_comment": log_entry_comment, + "granted_as_de_minimis_aid": granted_as_de_minimis_aid, + "handler_role": handler_role, + "decision_text": decision_text, + "justification_text": justification_text, + }, + ) + assert response.status_code == response_status + + if review_step == 4: + final_ahjo_text = AhjoDecisionText.objects.get(application=application) + assert ( + final_ahjo_text.decision_text == f"{decision_text}\n\n{justification_text}" + ) diff --git a/backend/benefit/applications/tests/test_ahjo_decisions.py b/backend/benefit/applications/tests/test_ahjo_decisions.py index 42d6d8dba7..7e2d6b38e6 100644 --- a/backend/benefit/applications/tests/test_ahjo_decisions.py +++ b/backend/benefit/applications/tests/test_ahjo_decisions.py @@ -3,7 +3,7 @@ import pytest from rest_framework.reverse import reverse -from applications.enums import DecisionProposalTemplateSectionType, DecisionType +from applications.enums import DecisionType from applications.models import AhjoDecisionText from applications.services.ahjo_decision_service import ( replace_decision_template_placeholders, @@ -14,7 +14,9 @@ def test_replace_accepted_decision_template_placeholders( decided_application, accepted_ahjo_decision_section ): replaced_template = replace_decision_template_placeholders( - accepted_ahjo_decision_section.template_text, decided_application + accepted_ahjo_decision_section.template_decision_text, + accepted_ahjo_decision_section.decision_type, + decided_application, ) assert decided_application.company.name in replaced_template @@ -31,7 +33,9 @@ def test_replace_denied_decision_template_placeholders( decided_application, denied_ahjo_decision_section ): replaced_template = replace_decision_template_placeholders( - denied_ahjo_decision_section.template_text, decided_application + denied_ahjo_decision_section.template_decision_text, + denied_ahjo_decision_section.decision_type, + decided_application, ) assert decided_application.company.name in replaced_template @@ -46,42 +50,24 @@ def test_anonymous_client_cannot_access_templates( @pytest.mark.parametrize( - "decision_type,section_type", + "decision_type", [ - ( - DecisionType.ACCEPTED, - DecisionProposalTemplateSectionType.DECISION_SECTION, - ), - ( - DecisionType.ACCEPTED, - DecisionProposalTemplateSectionType.JUSTIFICATION_SECTION, - ), - ( - DecisionType.DENIED, - DecisionProposalTemplateSectionType.DECISION_SECTION, - ), - ( - DecisionType.DENIED, - DecisionProposalTemplateSectionType.JUSTIFICATION_SECTION, - ), + (DecisionType.ACCEPTED,), + (DecisionType.ACCEPTED,), + (DecisionType.DENIED,), + (DecisionType.DENIED,), ], ) def test_handler_gets_the_template_sections( - decided_application, handler_api_client, decision_type, section_type + decided_application, handler_api_client, decision_type ): url = f"""{reverse("template_section_list")}?application_id={decided_application.id}\ -&decision_type={decision_type}\ -§ion_type={section_type}""" +&decision_type={decision_type}""" response = handler_api_client.get(url) assert response.status_code == 200 for template in response.data: assert template.decision_type == decision_type - assert template.section_type == section_type - if ( - template.section_type - == DecisionProposalTemplateSectionType.DECISION_SECTION - ): - assert decided_application.company.name in template.template_text + assert decided_application.company.name in template.template_decision_text def get_decisions_list_url(application_id: uuid) -> str: diff --git a/backend/benefit/calculator/api/v1/serializers.py b/backend/benefit/calculator/api/v1/serializers.py index adab4e8416..d1f0d28058 100644 --- a/backend/benefit/calculator/api/v1/serializers.py +++ b/backend/benefit/calculator/api/v1/serializers.py @@ -28,6 +28,8 @@ class Meta: "description_fi", "amount", "description_type", + "start_date", + "end_date", ] # CalculationRows are generated by the calculator and not directly editable. # Edit the source data instead and recalculate. @@ -38,6 +40,8 @@ class Meta: "description_fi", "amount", "description_type", + "start_date", + "end_date", ] diff --git a/backend/benefit/calculator/rules.py b/backend/benefit/calculator/rules.py index 5446f84036..975f58a22e 100644 --- a/backend/benefit/calculator/rules.py +++ b/backend/benefit/calculator/rules.py @@ -320,6 +320,7 @@ def create_rows(self): SalaryBenefitTotalRow, start_date=self.calculation.start_date, end_date=self.calculation.end_date, + description_type=DescriptionType.DEDUCTION, ) diff --git a/backend/benefit/docker-entrypoint.sh b/backend/benefit/docker-entrypoint.sh index 040c0d3c0b..8382c41c0d 100755 --- a/backend/benefit/docker-entrypoint.sh +++ b/backend/benefit/docker-entrypoint.sh @@ -17,7 +17,9 @@ if [[ "$LOAD_FIXTURES" = "1" ]]; then echo "Loading fixtures..." ./manage.py loaddata groups.json if [[ "$LOAD_DEFAULT_TERMS" = "1" ]]; then - ./manage.py loaddata default_terms.json + ./manage.py loaddata default_terms.json && + ./manage.py loaddata test_applications.json && + ./manage.py loaddata test_decision_templates.json fi ./manage.py set_group_permissions fi diff --git a/backend/benefit/helsinkibenefit/urls.py b/backend/benefit/helsinkibenefit/urls.py index f357c4331e..7baeb14562 100644 --- a/backend/benefit/helsinkibenefit/urls.py +++ b/backend/benefit/helsinkibenefit/urls.py @@ -13,6 +13,7 @@ from applications.api.v1 import application_batch_views, application_views from applications.api.v1.ahjo_decision_views import ( + DecisionProposalDraftUpdate, DecisionProposalTemplateSectionList, DecisionTextDetail, DecisionTextList, @@ -97,6 +98,11 @@ DecisionProposalTemplateSectionList.as_view(), name="template_section_list", ), + path( + "v1/decision-proposal-drafts/", + DecisionProposalDraftUpdate.as_view(), + name="decision_proposal_draft_update", + ), path("gdpr-api/v1/user/", UserUuidGDPRAPIView.as_view(), name="gdpr_v1"), path("v1/", include((router.urls, "v1"), namespace="v1")), path("v1/", include(applicant_app_router.urls)), diff --git a/frontend/benefit/applicant/src/components/applications/pageContent/PageContent.tsx b/frontend/benefit/applicant/src/components/applications/pageContent/PageContent.tsx index f89cbb4f35..071cdced11 100644 --- a/frontend/benefit/applicant/src/components/applications/pageContent/PageContent.tsx +++ b/frontend/benefit/applicant/src/components/applications/pageContent/PageContent.tsx @@ -209,7 +209,7 @@ const PageContent: React.FC = () => { selectedStep={currentStep - 1} onStepClick={(e) => e.stopPropagation()} css={stepperCss} - theme={theme.components.stepper} + theme={theme.components.stepper.black} /> diff --git a/frontend/benefit/handler/browser-tests/pages/1-new-application.testcafe.ts b/frontend/benefit/handler/browser-tests/pages/1-new-application.testcafe.ts index 58f95a4050..0b56f8f191 100644 --- a/frontend/benefit/handler/browser-tests/pages/1-new-application.testcafe.ts +++ b/frontend/benefit/handler/browser-tests/pages/1-new-application.testcafe.ts @@ -7,20 +7,11 @@ import fi from '../../public/locales/fi/common.json'; import { NEW_FORM_DATA as form } from '../constants/forms'; import MainIngress from '../page-model/MainIngress'; import handlerUser from '../utils/handlerUser'; +import { uploadFileAttachment } from '../utils/input'; import { getFrontendUrl } from '../utils/url.utils'; const url = getFrontendUrl(`/`); -const uploadFileAttachment = async ( - t: TestController, - selector: string, - filename = 'sample.pdf' -) => { - await t.scrollIntoView(Selector(selector).parent(), { offsetY: -200 }); - await t.setFilesToUpload(selector, filename); - await t.wait(100); -}; - fixture('Create new application') .page(url) .beforeEach(async (t) => { @@ -100,13 +91,9 @@ test('Fill form and submit', async (t: TestController) => { ); await uploadFileAttachment(t, '#upload_attachment_full_application'); - await t.wait(1000); await uploadFileAttachment(t, '#upload_attachment_employment_contract'); - await t.wait(1000); await uploadFileAttachment(t, '#upload_attachment_education_contract'); - await t.wait(1000); await uploadFileAttachment(t, '#upload_attachment_pay_subsidy_decision'); - await t.wait(1000); /** * Click through all applicant terms. diff --git a/frontend/benefit/handler/browser-tests/pages/2-edit-application.testcafe.ts b/frontend/benefit/handler/browser-tests/pages/2-edit-application.testcafe.ts index 683b9c6760..ea3ba9696f 100644 --- a/frontend/benefit/handler/browser-tests/pages/2-edit-application.testcafe.ts +++ b/frontend/benefit/handler/browser-tests/pages/2-edit-application.testcafe.ts @@ -7,31 +7,11 @@ import fi from '../../public/locales/fi/common.json'; import { EDIT_FORM_DATA as form, NEW_FORM_DATA } from '../constants/forms'; import MainIngress from '../page-model/MainIngress'; import handlerUser from '../utils/handlerUser'; +import { clearAndFill, uploadFileAttachment } from '../utils/input'; import { getFrontendUrl } from '../utils/url.utils'; const url = getFrontendUrl(`/`); -const uploadFileAttachment = async ( - t: TestController, - selector: string, - filename = 'sample2.pdf' -) => { - await t.scrollIntoView(Selector(selector).parent(), { offsetY: -200 }); - await t.setFilesToUpload(selector, filename); - await t.wait(100); -}; - -const clearAndFill = async ( - t: TestController, - selector: string, - value: string -) => { - await t.click(selector); - await t.selectText(selector); - await t.pressKey('delete'); - await t.typeText(selector, value ?? ''); -}; - fixture('Edit existing application') .page(url) .beforeEach(async (t) => { @@ -146,7 +126,11 @@ test('Open form and edit fields, then submit', async (t: TestController) => { format(new Date(), DATE_FORMATS.UI_DATE) ); - await uploadFileAttachment(t, '#upload_attachment_employment_contract'); + await uploadFileAttachment( + t, + '#upload_attachment_employment_contract', + 'sample2.pdf' + ); // Validate form and submit const validationButton = Selector(buttonSelector).withText( diff --git a/frontend/benefit/handler/browser-tests/pages/4-make-decision.testcafe.ts b/frontend/benefit/handler/browser-tests/pages/4-make-decision.testcafe.ts new file mode 100644 index 0000000000..05b87d96ef --- /dev/null +++ b/frontend/benefit/handler/browser-tests/pages/4-make-decision.testcafe.ts @@ -0,0 +1,135 @@ +import { clearDataToPrintOnFailure } from '@frontend/shared/browser-tests/utils/testcafe.utils'; +import { DATE_FORMATS } from '@frontend/shared/src/utils/date.utils'; +import { addMonths, format } from 'date-fns'; +import { Selector } from 'testcafe'; + +import fi from '../../public/locales/fi/common.json'; +import { EDIT_FORM_DATA as form } from '../constants/forms'; +import handlerUser from '../utils/handlerUser'; +import { clearAndFill } from '../utils/input'; +import { getFrontendUrl } from '../utils/url.utils'; + +const url = getFrontendUrl(`/`); + +fixture('Review edited application') + .page(url) + .beforeEach(async (t) => { + clearDataToPrintOnFailure(t); + await t.useRole(handlerUser); + await t.navigateTo('/'); + }); + +test('Handler makes a favorable decision', async (t: TestController) => { + const applicationLink = Selector('td') + .withText(`${form.employee.firstName} ${form.employee.lastName}`) + .sibling('td') + .nth(0) + .find('a'); + + await t.expect(applicationLink.visible).ok(); + await t.click(applicationLink); + + // Click though the calculations + await t + .expect(Selector('main').withText(fi.calculators.salary.header).exists) + .ok(); + const startDate = new Date(); + const endDate = addMonths(startDate, 1); + + // Select state aid max percentage + await t.click(Selector('#stateAidMaxPercentage-toggle-button')); + await t.click(Selector('#stateAidMaxPercentage-menu li:first-child')); + + // Fill in the dates + await clearAndFill(t, '#endDate', format(endDate, DATE_FORMATS.UI_DATE)); + await clearAndFill(t, '#startDate', format(startDate, DATE_FORMATS.UI_DATE)); + + // Click "Calculate" button + await t.click( + Selector('button').withText(fi.calculators.employment.calculate) + ); + + // Expect a "receipt" of calculation + await t + .expect( + Selector('[data-testid="calculation-results-total"]').withText( + 'Yhteensä ajanjaksolta' + ).exists + ) + .ok(); + + // Click "accepted" radio + await t.click(Selector('label').withText(fi.review.fields.support)); + + // Click "Make decision" button + await t.click(Selector('button').withText(fi.review.actions.done)); + + // Click final submit inside modal prompt + await t.click(Selector('[data-testid="submit"]')); + + // Wait for the successs notification to appear + await t + .expect(Selector('h1').withText(fi.notifications.accepted.title).exists) + .ok(); +}); + +test('Handler processes favorable decision to Ahjo / Talpa', async (t: TestController) => { + // Select all rows and add to batch + await t.click( + Selector('li').withText(fi.applications.list.headings.accepted) + ); + await t.click(Selector('button').withText('Valitse kaikki rivit')); + await t.click( + Selector('button').withText(fi.applications.list.actions.addToBatch) + ); + + // Navigate to batches + await t.click(Selector('a').withText(fi.header.navigation.batches)); + + // Click "Mark as ready for Ahjo" button + await t.click( + Selector('button').withText(fi.batches.actions.markAsReadyForAhjo) + ); + + // Confirm the action + await t + .expect( + Selector('[role="heading"]').withText( + fi.batches.notifications.statusChange.exported_ahjo_report.heading + ).exists + ) + .ok(); + + // Send to nexdt step + await t.click( + Selector('button:not([disabled])').withText( + fi.batches.actions.markAsRegisteredToAhjo + ) + ); + + // Click the submit button on modal prompt + await t.click(Selector('button').withText(fi.utility.confirm)); + await t.click(Selector('li').withText(fi.batches.tabs.inspection)); + + // Type in the inspection / P2P details + await t.typeText(Selector('[name="decision_maker_name"]'), 'Hissun kissun'); + await t.typeText(Selector('[name="decision_maker_title"]'), 'Vaapulavissun'); + await t.typeText(Selector('[name="section_of_the_law"]'), '1234'); + await t.typeText( + Selector('[name="expert_inspector_name"]'), + 'Entten Tentten' + ); + await t.typeText( + Selector('[name="expert_inspector_title"]'), + 'Teelikamentten' + ); + await t.typeText(Selector('[name="p2p_checker_name"]'), 'Eelin Keelin'); + + // Click the "Mark as ready for Talpa" button + await t.click(Selector('button').withText(fi.batches.actions.markToTalpa)); + await t.click(Selector('button').withText(fi.utility.confirm)); + + // See if the last tab is populated with the batch + await t.click(Selector('li').withText(fi.batches.tabs.completion)); + await t.expect(Selector('h2').withText('(1)').exists).ok(); +}); diff --git a/frontend/benefit/handler/browser-tests/pages/5-ahjo-process.testcafe.ts b/frontend/benefit/handler/browser-tests/pages/5-ahjo-process.testcafe.ts new file mode 100644 index 0000000000..b6226bbc9a --- /dev/null +++ b/frontend/benefit/handler/browser-tests/pages/5-ahjo-process.testcafe.ts @@ -0,0 +1,133 @@ +import { clearDataToPrintOnFailure } from '@frontend/shared/browser-tests/utils/testcafe.utils'; +import { ClientFunction, Selector } from 'testcafe'; + +import fi from '../../public/locales/fi/common.json'; +import MainIngress from '../page-model/MainIngress'; +import handlerUserAhjo from '../utils/handlerUserAhjo'; +import { clearAndFill } from '../utils/input'; +import { getFrontendUrl } from '../utils/url.utils'; + +const url = getFrontendUrl(`/`); + +fixture('Ahjo decision proposal for application') + .page(url) + .beforeEach(async (t) => { + clearDataToPrintOnFailure(t); + await t.useRole(handlerUserAhjo); + await t.navigateTo('/'); + await ClientFunction(() => + window.localStorage.setItem('newAhjoMode', '1') + )(); + }); + +test('Check for handling validation errors', async (t: TestController) => { + await ClientFunction(() => window.localStorage.setItem('newAhjoMode', '1'))(); + const mainIngress = new MainIngress(fi.mainIngress.heading, 'h1'); + await mainIngress.isLoaded(); + + // Open already created application in index page + const applicationLink = Selector('td') + .withText(`Aari Hömpömpö`) + .sibling('td') + .nth(0) + .find('a'); + await t.click(applicationLink); + + // // Start handling the application + const toastSelector = '.Toastify__toast-body[role="alert"]'; + const buttonSelector = 'main button'; + const handleButton = Selector(buttonSelector).withText(fi.utility.next); + await t.expect(handleButton.visible).ok(); + + // Check for empty status + let errorNotification = Selector(toastSelector).withText( + fi.review.decisionProposal.errors.fields.status + ); + await t.click(handleButton); + await t.expect(errorNotification.visible).ok(); + + // Check for empty log entry + errorNotification = Selector(toastSelector).withText( + fi.review.decisionProposal.errors.fields.logEntry + ); + await t.click(Selector('label').withText(fi.review.fields.noSupport)); + await t.click(handleButton); + await t.expect(errorNotification.visible).ok(); + + // Check for calculation error + await clearAndFill(t, '#monthlyPay', ' '); + errorNotification = Selector(toastSelector).withText( + fi.review.decisionProposal.errors.fields.calculation + ); + await t.click(handleButton); + await t.expect(errorNotification.visible).ok(); +}); + +test('Open form and create a decision proposal', async (t: TestController) => { + await ClientFunction(() => window.localStorage.setItem('newAhjoMode', '1'))(); + const mainIngress = new MainIngress(fi.mainIngress.heading, 'h1'); + await mainIngress.isLoaded(); + + // Open already created application in index page + const applicationLink = Selector('td') + .withText(`Aari Hömpömpö`) + .sibling('td') + .nth(0) + .find('a'); + await t.click(applicationLink); + // // Start handling the application + const buttonSelector = 'main button'; + const handleButton = Selector(buttonSelector).withText(fi.utility.next); + await t.expect(handleButton.visible).ok(); + + await t.click(Selector('label').withText(fi.review.fields.support)); + await t.click(handleButton); + + await t.click( + Selector('label').withText( + fi.review.decisionProposal.role.fields.decisionMaker.handler + ) + ); + + await t.click( + Selector('label').withText( + fi.review.decisionProposal.templates.fields.select.label + ) + ); + + await t.click( + Selector('ul > li').withText('FI: Myönteisen päätöksen Päätös-osion teksti') + ); + + // Has decision text in editor + await t + .expect(Selector('[data-testid="decisionText"] .tiptap').child().count) + .eql(2); + + // Has justification text in editor + await t + .expect(Selector('[data-testid="justificationText"] .tiptap').child().count) + .eql(10); + + await t.click(handleButton); + + await t + .expect(Selector('[data-testid="decision-text-preview"]').child().count) + .eql(2); + + await t + .expect( + Selector('[data-testid="justification-text-preview"]').child().count + ) + .eql(10); + + await t.click(Selector(buttonSelector).withText(fi.utility.send)); + await t.click(Selector('button').withText(fi.review.actions.accept)); + + await t + .expect( + Selector('h1').withText(fi.review.decisionProposal.submitted.title) + .visible + ) + .ok(); +}); diff --git a/frontend/benefit/handler/browser-tests/utils/handlerUserAhjo.ts b/frontend/benefit/handler/browser-tests/utils/handlerUserAhjo.ts new file mode 100644 index 0000000000..513756a8cc --- /dev/null +++ b/frontend/benefit/handler/browser-tests/utils/handlerUserAhjo.ts @@ -0,0 +1,11 @@ +import { ClientFunction, Role } from 'testcafe'; + +import { getFrontendUrl } from './url.utils'; + +const handlerUserAhjo = Role(getFrontendUrl('/'), async (t: TestController) => { + // eslint-disable-next-line scanjs-rules/property_localStorage, scanjs-rules/identifier_localStorage + await ClientFunction(() => window.localStorage.setItem('newAhjoMode', '1'))(); + await t.click('[data-testid="main-login-button"]'); +}); + +export default handlerUserAhjo; diff --git a/frontend/benefit/handler/browser-tests/utils/input.ts b/frontend/benefit/handler/browser-tests/utils/input.ts new file mode 100644 index 0000000000..2f575404f3 --- /dev/null +++ b/frontend/benefit/handler/browser-tests/utils/input.ts @@ -0,0 +1,30 @@ +import { Selector } from 'testcafe'; + +export const uploadFileAttachment = async ( + t: TestController, + inputId: string, + filename = 'sample.pdf' +): Promise => { + const filenameWithoutExtension = filename.replace(/\.\w+$/, ''); + await t.scrollIntoView(Selector(inputId).parent(), { offsetY: -200 }); + await t.setFilesToUpload(inputId, filename); + await t + .expect( + Selector(inputId) + .parent() + .parent() + .find(`a[aria-label^="${filenameWithoutExtension}"]`).visible + ) + .ok(); +}; + +export const clearAndFill = async ( + t: TestController, + selector: string, + value: string +): Promise => { + await t.click(selector); + await t.selectText(selector); + await t.pressKey('delete'); + await t.typeText(selector, value ?? ''); +}; diff --git a/frontend/benefit/handler/package.json b/frontend/benefit/handler/package.json index 60b706b575..feb2262aa0 100644 --- a/frontend/benefit/handler/package.json +++ b/frontend/benefit/handler/package.json @@ -18,6 +18,14 @@ "@frontend/benefit-shared": "*", "@frontend/shared": "*", "@sentry/browser": "^7.16.0", + "@tiptap/core": "^2.2.4", + "@tiptap/extension-document": "^2.2.4", + "@tiptap/extension-heading": "^2.2.4", + "@tiptap/extension-history": "^2.2.4", + "@tiptap/extension-paragraph": "^2.2.4", + "@tiptap/extension-text": "^2.2.4", + "@tiptap/pm": "^2.2.4", + "@tiptap/react": "^2.2.4", "axios": "^1.6.5", "camelcase-keys": "^7.0.2", "date-fns": "^2.24.0", @@ -32,9 +40,9 @@ "ibantools": "^4.3.4", "lodash": "^4.17.21", "next": "14.1.1", - "next-router-mock": "0.9.12", "next-compose-plugins": "^2.2.1", "next-i18next": "^13.0.3", + "next-router-mock": "0.9.12", "next-transpile-modules": "^9.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/frontend/benefit/handler/public/locales/en/common.json b/frontend/benefit/handler/public/locales/en/common.json index 31d94cf1de..80ac194ac7 100644 --- a/frontend/benefit/handler/public/locales/en/common.json +++ b/frontend/benefit/handler/public/locales/en/common.json @@ -86,6 +86,7 @@ }, "ahjoButton": { "label": "Aloita hakemuksen vieminen ahjoon", + "linkLabel": "Siirry Ahjoon", "message": "" } }, @@ -238,9 +239,17 @@ "cancelled": "Peruttu" }, "steps": { - "step1": "Hae työnantaja", - "step2": "Syötä hakemus", - "step3": "Yhteenveto" + "newApplication": { + "step1": "Hae työnantaja", + "step2": "Syötä hakemus", + "step3": "Yhteenveto" + }, + "handlingProcess": { + "step1": "Käsittely", + "step2": "Päätöksen valmistelu", + "step3": "Esikatsele ja lähetä" + }, + "stepperHeading": "Vaihe {{currentStep}} / {{ lastStep }}:" }, "actions": { "saveAndContinueLater": "Tallenna luonnoksena ja sulje", @@ -771,6 +780,9 @@ "notGranted": "Työsuhteeseen ei ole myönnetty mitään edeltävää tukea" } }, + "handling": { + "title": "Hakemuksen käsittely" + }, "summary": { "accepted": { "title": "Hakemuksen käsittely", @@ -820,6 +832,82 @@ "dateRange": "Helsinki-lisä kuukaudessa {{dateRange}}", "total": "Helsinki-lisä yhteensä koko ajalta ({{months}} kk)" } + }, + "decisionProposal": { + "errors": { + "title": "Vaadittavia tietoja puuttuu", + "fields": { + "calculation": "Tarkista laskelma", + "status": "Puoltotieto puuttuu", + "logEntry": "Päätöksen perustelu puuttuu", + "handler": "Päättäjän rooli puuttuu", + "decisionText": "Päätös ei voi olla tyhjä", + "justificationText": "Päätöksen perustelu ei voi olla tyhjä" + } + }, + "list": { + "title": "Päätösehdotuksen tiedot", + "text": { + "accepted": "Hakemus käsitellään myönteiseksi ja Helsinki-lisää myönnetään {{ months }} kuukaudelle aikavälille {{ startAndEndDate }}.", + "rejected": "Hakemus käsitellään kielteiseksi. Helsinki-lisää ei myönnetä hakijalle." + }, + "proposalStatus": { + "label": "Päätösehdotus", + "accepted": "Myönteinen", + "rejected": "Kielteinen" + }, + "employerName": "Hakija", + "employeeName": "Työllistettävä", + "totalAmount": "Myönnettävä tuki yhteensä", + "grantedAsDeMinimisAid": "Myönnetään de minimis -tukena" + }, + "calculationReview": { + "tableCaption": "Tukijaksot", + "dates": "Tukijakso", + "duration": "Kesto", + "perMonth": "Tuki kuukaudessa", + "amount": "Tuki yhteensä" + }, + "role": { + "title": "Valitse päättäjän rooli", + "fields": { + "decisionMaker": { + "label": "Päättäjän rooli", + "handler": "Helsinki-lisä-valmistelija", + "manager": "Tiimipäällikkö", + "tooltipText": "Hakemuksen käsittelijän ja päättäjän on oltava eri henkilöitä. Käsittelijä on henkilö, joka lähettää hakemuksen. Valitse päättäjäksi joku toinen henkilö." + } + } + }, + "templates": { + "title": "Valitse pohja päätökselle", + "fields": { + "usedLanguage": { + "label": "Yhteyshenkilön asiointikieli on" + }, + "select": { + "label": "Päätöstekstipohja", + "helperText": "Valitse sopiva päätöstekstipohja" + } + }, + "missingContent": { + "accepted": "Myöteiset päätöstekstipohjat puuttuvat kielelle {{ language }}.", + "rejected": "Kielteiset päätöstekstipohjat puuttuvat kielelle {{ language }}." + } + }, + "preview": { + "title": "Olet siirtämässä hakemuksen päätösjonoon.", + "proposalTexts": "Päätösehdotuksen teksti", + "decisionText": "Päätös", + "justificationText": "Päätöksen perustelut", + "staticTitle": "Avustukset työnantajille, Työllisyyspalvelut, Helsinki-lisä, ", + "application": "hakemus", + "ahjoIdentifier": "HEL 2024-000172" + }, + "submitted": { + "title": "Päätösehdotus on lähetetty Ahjoon", + "text": "Viimeistele päätöskäsittely Ahjossa." + } } }, "calculators": { @@ -993,7 +1081,10 @@ "start": "Alkamispäivä", "end": "Päättymispäivä", "perMonth": "/kk", - "empty": "Tyhjä" + "empty": "Tyhjä", + "next": "Seuraava", + "previous": "Edellinen", + "send": "Lähetä" }, "status": { "draft": "Luonnos", diff --git a/frontend/benefit/handler/public/locales/fi/common.json b/frontend/benefit/handler/public/locales/fi/common.json index b5d4baa197..75dc058cb1 100644 --- a/frontend/benefit/handler/public/locales/fi/common.json +++ b/frontend/benefit/handler/public/locales/fi/common.json @@ -86,6 +86,7 @@ }, "ahjoButton": { "label": "Aloita hakemuksen vieminen ahjoon", + "linkLabel": "Siirry Ahjoon", "message": "" } }, @@ -100,7 +101,7 @@ "languages": { "fi": "Suomi", "sv": "Ruotsi", - "en": "English" + "en": "Englanti" }, "supportedLanguages": { "fi": "Suomeksi", @@ -238,9 +239,17 @@ "cancelled": "Peruttu" }, "steps": { - "step1": "Hae työnantaja", - "step2": "Syötä hakemus", - "step3": "Yhteenveto" + "newApplication": { + "step1": "Hae työnantaja", + "step2": "Syötä hakemus", + "step3": "Yhteenveto" + }, + "handlingProcess": { + "step1": "Käsittely", + "step2": "Päätöksen valmistelu", + "step3": "Esikatsele ja lähetä" + }, + "stepperHeading": "Vaihe {{currentStep}} / {{ lastStep }}:" }, "actions": { "saveAndContinueLater": "Tallenna luonnoksena ja sulje", @@ -771,6 +780,9 @@ "notGranted": "Työsuhteeseen ei ole myönnetty mitään edeltävää tukea" } }, + "handling": { + "title": "Hakemuksen käsittely" + }, "summary": { "accepted": { "title": "Hakemuksen käsittely", @@ -820,6 +832,82 @@ "dateRange": "Helsinki-lisä kuukaudessa {{dateRange}}", "total": "Helsinki-lisä yhteensä koko ajalta ({{months}} kk)" } + }, + "decisionProposal": { + "errors": { + "title": "Vaadittavia tietoja puuttuu", + "fields": { + "calculation": "Tarkista laskelma", + "status": "Puoltotieto puuttuu", + "logEntry": "Päätöksen perustelu puuttuu", + "handler": "Päättäjän rooli puuttuu", + "decisionText": "Päätös ei voi olla tyhjä", + "justificationText": "Päätöksen perustelu ei voi olla tyhjä" + } + }, + "list": { + "title": "Päätösehdotuksen tiedot", + "text": { + "accepted": "Hakemus käsitellään myönteiseksi ja Helsinki-lisää myönnetään {{ months }} kuukaudelle aikavälille {{ startAndEndDate }}.", + "rejected": "Hakemus käsitellään kielteiseksi. Helsinki-lisää ei myönnetä hakijalle." + }, + "proposalStatus": { + "label": "Päätösehdotus", + "accepted": "Myönteinen", + "rejected": "Kielteinen" + }, + "employerName": "Hakija", + "employeeName": "Työllistettävä", + "totalAmount": "Myönnettävä tuki yhteensä", + "grantedAsDeMinimisAid": "Myönnetään de minimis -tukena" + }, + "calculationReview": { + "tableCaption": "Tukijaksot", + "dates": "Tukijakso", + "duration": "Kesto", + "perMonth": "Tuki kuukaudessa", + "amount": "Tuki yhteensä" + }, + "role": { + "title": "Valitse päättäjän rooli", + "fields": { + "decisionMaker": { + "label": "Päättäjän rooli", + "handler": "Helsinki-lisä-valmistelija", + "manager": "Tiimipäällikkö", + "tooltipText": "Hakemuksen käsittelijän ja päättäjän on oltava eri henkilöitä. Käsittelijä on henkilö, joka lähettää hakemuksen. Valitse päättäjäksi joku toinen henkilö." + } + } + }, + "templates": { + "title": "Valitse pohja päätökselle", + "fields": { + "usedLanguage": { + "label": "Yhteyshenkilön asiointikieli on" + }, + "select": { + "label": "Päätöstekstipohja", + "helperText": "Valitse sopiva päätöstekstipohja" + } + }, + "missingContent": { + "accepted": "Myönteiset päätöstekstipohjat puuttuvat kielelle {{ language }}.", + "rejected": "Kielteiset päätöstekstipohjat puuttuvat kielelle {{ language }}." + } + }, + "preview": { + "title": "Olet siirtämässä hakemuksen päätösjonoon.", + "proposalTexts": "Päätösehdotuksen teksti", + "decisionText": "Päätös", + "justificationText": "Päätöksen perustelut", + "staticTitle": "Avustukset työnantajille, Työllisyyspalvelut, Helsinki-lisä, ", + "application": "hakemus", + "ahjoIdentifier": "HEL 2024-000172" + }, + "submitted": { + "title": "Päätösehdotus on lähetetty Ahjoon", + "text": "Viimeistele päätöskäsittely Ahjossa." + } } }, "calculators": { @@ -993,7 +1081,10 @@ "start": "Alkamispäivä", "end": "Päättymispäivä", "perMonth": "/kk", - "empty": "Tyhjä" + "empty": "Tyhjä", + "next": "Seuraava", + "previous": "Edellinen", + "send": "Lähetä" }, "status": { "draft": "Luonnos", diff --git a/frontend/benefit/handler/public/locales/sv/common.json b/frontend/benefit/handler/public/locales/sv/common.json index 31d94cf1de..80ac194ac7 100644 --- a/frontend/benefit/handler/public/locales/sv/common.json +++ b/frontend/benefit/handler/public/locales/sv/common.json @@ -86,6 +86,7 @@ }, "ahjoButton": { "label": "Aloita hakemuksen vieminen ahjoon", + "linkLabel": "Siirry Ahjoon", "message": "" } }, @@ -238,9 +239,17 @@ "cancelled": "Peruttu" }, "steps": { - "step1": "Hae työnantaja", - "step2": "Syötä hakemus", - "step3": "Yhteenveto" + "newApplication": { + "step1": "Hae työnantaja", + "step2": "Syötä hakemus", + "step3": "Yhteenveto" + }, + "handlingProcess": { + "step1": "Käsittely", + "step2": "Päätöksen valmistelu", + "step3": "Esikatsele ja lähetä" + }, + "stepperHeading": "Vaihe {{currentStep}} / {{ lastStep }}:" }, "actions": { "saveAndContinueLater": "Tallenna luonnoksena ja sulje", @@ -771,6 +780,9 @@ "notGranted": "Työsuhteeseen ei ole myönnetty mitään edeltävää tukea" } }, + "handling": { + "title": "Hakemuksen käsittely" + }, "summary": { "accepted": { "title": "Hakemuksen käsittely", @@ -820,6 +832,82 @@ "dateRange": "Helsinki-lisä kuukaudessa {{dateRange}}", "total": "Helsinki-lisä yhteensä koko ajalta ({{months}} kk)" } + }, + "decisionProposal": { + "errors": { + "title": "Vaadittavia tietoja puuttuu", + "fields": { + "calculation": "Tarkista laskelma", + "status": "Puoltotieto puuttuu", + "logEntry": "Päätöksen perustelu puuttuu", + "handler": "Päättäjän rooli puuttuu", + "decisionText": "Päätös ei voi olla tyhjä", + "justificationText": "Päätöksen perustelu ei voi olla tyhjä" + } + }, + "list": { + "title": "Päätösehdotuksen tiedot", + "text": { + "accepted": "Hakemus käsitellään myönteiseksi ja Helsinki-lisää myönnetään {{ months }} kuukaudelle aikavälille {{ startAndEndDate }}.", + "rejected": "Hakemus käsitellään kielteiseksi. Helsinki-lisää ei myönnetä hakijalle." + }, + "proposalStatus": { + "label": "Päätösehdotus", + "accepted": "Myönteinen", + "rejected": "Kielteinen" + }, + "employerName": "Hakija", + "employeeName": "Työllistettävä", + "totalAmount": "Myönnettävä tuki yhteensä", + "grantedAsDeMinimisAid": "Myönnetään de minimis -tukena" + }, + "calculationReview": { + "tableCaption": "Tukijaksot", + "dates": "Tukijakso", + "duration": "Kesto", + "perMonth": "Tuki kuukaudessa", + "amount": "Tuki yhteensä" + }, + "role": { + "title": "Valitse päättäjän rooli", + "fields": { + "decisionMaker": { + "label": "Päättäjän rooli", + "handler": "Helsinki-lisä-valmistelija", + "manager": "Tiimipäällikkö", + "tooltipText": "Hakemuksen käsittelijän ja päättäjän on oltava eri henkilöitä. Käsittelijä on henkilö, joka lähettää hakemuksen. Valitse päättäjäksi joku toinen henkilö." + } + } + }, + "templates": { + "title": "Valitse pohja päätökselle", + "fields": { + "usedLanguage": { + "label": "Yhteyshenkilön asiointikieli on" + }, + "select": { + "label": "Päätöstekstipohja", + "helperText": "Valitse sopiva päätöstekstipohja" + } + }, + "missingContent": { + "accepted": "Myöteiset päätöstekstipohjat puuttuvat kielelle {{ language }}.", + "rejected": "Kielteiset päätöstekstipohjat puuttuvat kielelle {{ language }}." + } + }, + "preview": { + "title": "Olet siirtämässä hakemuksen päätösjonoon.", + "proposalTexts": "Päätösehdotuksen teksti", + "decisionText": "Päätös", + "justificationText": "Päätöksen perustelut", + "staticTitle": "Avustukset työnantajille, Työllisyyspalvelut, Helsinki-lisä, ", + "application": "hakemus", + "ahjoIdentifier": "HEL 2024-000172" + }, + "submitted": { + "title": "Päätösehdotus on lähetetty Ahjoon", + "text": "Viimeistele päätöskäsittely Ahjossa." + } } }, "calculators": { @@ -993,7 +1081,10 @@ "start": "Alkamispäivä", "end": "Päättymispäivä", "perMonth": "/kk", - "empty": "Tyhjä" + "empty": "Tyhjä", + "next": "Seuraava", + "previous": "Edellinen", + "send": "Lähetä" }, "status": { "draft": "Luonnos", diff --git a/frontend/benefit/handler/src/components/applicationForm/companySearch/CompanySearch.tsx b/frontend/benefit/handler/src/components/applicationForm/companySearch/CompanySearch.tsx index 3b4c324617..aa183abd24 100644 --- a/frontend/benefit/handler/src/components/applicationForm/companySearch/CompanySearch.tsx +++ b/frontend/benefit/handler/src/components/applicationForm/companySearch/CompanySearch.tsx @@ -59,6 +59,7 @@ const CompanySearch: React.FC = () => { > {companies.map(({ name, business_id: businessId }) => ( { - )} - <$ItemWrapper> - <$ItemHeader>{t(`${translationBase}.submittedAt`)} - <$ItemValue> - {data.submittedAt && formatDate(new Date(data.submittedAt))} - - - - <$Col> - - - - + + <$Col> + + + + + + + {data.status === APPLICATION_STATUSES.INFO_REQUIRED && ( + <$InfoNeededBar> + {t(`common:review.fields.editEndDate`, { + date: convertToUIDateFormat(data.additionalInformationNeededBy), + })} + + + )} ); }; diff --git a/frontend/benefit/handler/src/components/applicationList/ApplicationList.tsx b/frontend/benefit/handler/src/components/applicationList/ApplicationList.tsx index 6d85c782a9..2cbe3a45c7 100644 --- a/frontend/benefit/handler/src/components/applicationList/ApplicationList.tsx +++ b/frontend/benefit/handler/src/components/applicationList/ApplicationList.tsx @@ -1,3 +1,5 @@ +import 'react-loading-skeleton/dist/skeleton.css'; + import { ALL_APPLICATION_STATUSES, ROUTES } from 'benefit/handler/constants'; import { ApplicationListTableColumns, @@ -5,14 +7,9 @@ import { } from 'benefit/handler/types/applicationList'; import { APPLICATION_STATUSES } from 'benefit-shared/constants'; import { ApplicationListItemData } from 'benefit-shared/types/application'; -import { - IconSpeechbubbleText, - LoadingSpinner, - StatusLabel, - Table, - Tag, -} from 'hds-react'; +import { IconSpeechbubbleText, StatusLabel, Table, Tag } from 'hds-react'; import * as React from 'react'; +import LoadingSkeleton from 'react-loading-skeleton'; import { $Link } from 'shared/components/table/Table.sc'; import { convertToUIDateFormat, @@ -205,8 +202,17 @@ const ApplicationList: React.FC = ({ if (isLoading) { return ( <> - {heading && <$Heading>{`${heading}`}} - + {heading && ( + <$Heading + css={{ marginBottom: theme.spacing.xs }} + >{`${heading}`} + )} + + ); } diff --git a/frontend/benefit/handler/src/components/applicationReview/ApplicationReview.sc.ts b/frontend/benefit/handler/src/components/applicationReview/ApplicationReview.sc.ts index 346bb0e6ff..088a71945d 100644 --- a/frontend/benefit/handler/src/components/applicationReview/ApplicationReview.sc.ts +++ b/frontend/benefit/handler/src/components/applicationReview/ApplicationReview.sc.ts @@ -15,6 +15,12 @@ type TabButtonProps = { active?: boolean; }; +export const $ApplicationReview = styled.div` + hr { + border: 1px solid ${(props) => props.theme.colors.silver}; + } +`; + export const $MainHeader = styled.h1` font-size: ${(props) => props.theme.fontSize.heading.m}; `; @@ -126,3 +132,17 @@ export const $InfoNeededBar = styled.div` padding-left: ${(props) => props.theme.spacing.xs}; } `; + +export const $CalculationReviewTableWrapper = styled.div` + max-width: 714px; + margin-top: ${(props) => props.theme.spacing.xs}; + + caption { + font-weight: 500; + } + + table > tbody > tr:last-child td { + background-color: ${(props) => props.theme.colors.coatOfArmsLight}; + font-weight: 500; + } +`; diff --git a/frontend/benefit/handler/src/components/applicationReview/ApplicationReview.tsx b/frontend/benefit/handler/src/components/applicationReview/ApplicationReview.tsx index d6aa778545..2e615d3895 100644 --- a/frontend/benefit/handler/src/components/applicationReview/ApplicationReview.tsx +++ b/frontend/benefit/handler/src/components/applicationReview/ApplicationReview.tsx @@ -1,135 +1,151 @@ import ApplicationHeader from 'benefit/handler/components/applicationHeader/ApplicationHeader'; import { HANDLED_STATUSES } from 'benefit/handler/constants'; -import ReviewStateContext from 'benefit/handler/context/ReviewStateContext'; -import { - APPLICATION_ORIGINS, - APPLICATION_STATUSES, -} from 'benefit-shared/constants'; -import { IconLockOpen, LoadingSpinner } from 'hds-react'; +import { useDetermineAhjoMode } from 'benefit/handler/hooks/useDetermineAhjoMode'; +import { APPLICATION_STATUSES } from 'benefit-shared/constants'; +import { ErrorData } from 'benefit-shared/types/common'; +import { useRouter } from 'next/router'; import * as React from 'react'; +import LoadingSkeleton from 'react-loading-skeleton'; +import { useQueryClient } from 'react-query'; import Container from 'shared/components/container/Container'; +import { + $Grid, + $GridCell, +} from 'shared/components/forms/section/FormSection.sc'; import StickyActionBar from 'shared/components/stickyActionBar/StickyActionBar'; import { $StickyBarSpacing } from 'shared/components/stickyActionBar/StickyActionBar.sc'; -import { convertToUIDateFormat } from 'shared/utils/date.utils'; +import theme from 'shared/styles/theme'; +import { useApplicationStepper } from '../../hooks/applicationHandling/useHandlingStepper'; import HandlingApplicationActions from './actions/handlingApplicationActions/HandlingApplicationActions'; +import HandlingApplicationActionsAhjo from './actions/handlingApplicationActions/HandlingApplicationActionsAhjo'; import ReceivedApplicationActions from './actions/receivedApplicationActions/ReceivedApplicationActions'; -import ApplicationProcessingView from './applicationProcessingView/AplicationProcessingView'; -import { $InfoNeededBar } from './ApplicationReview.sc'; -import BenefitView from './benefitView/BenefitView'; -import CompanyInfoView from './companyInfoView/CompanyInfoView'; -import ConsentView from './consentView/ConsentView'; -import ContactPersonView from './contactPersonView/ContactPersonView'; -import CoOperationNegotiationsView from './coOperationNegotiationsView/CoOperationNegotiationsView'; -import DeminimisView from './deminimisView/DeminimisView'; -import EmployeeView from './employeeView/EmployeeView'; -import EmploymentView from './employmentView/EmpoymentView'; -import ArchivedView from './handledView/archivedView/ArchivedView'; -import HandledView from './handledView/HandledView'; +import { $ApplicationReview } from './ApplicationReview.sc'; +import ApplicationReviewStep1 from './handlingView/HandlingStep1'; +import ApplicationReviewStep2 from './handlingView/HandlingStep2'; +import ApplicationReviewStep3 from './handlingView/HandlingStep3'; +import ApplicationStepper from './handlingView/HandlingStepper'; import NotificationView from './notificationView/NotificationView'; -import PaperView from './paperView/PaperView'; -import SalaryBenefitCalculatorView from './salaryBenefitCalculatorView/SalaryBenefitCalculatorView'; import { useApplicationReview } from './useApplicationReview'; const ApplicationReview: React.FC = () => { - const { - application, - handledApplication, - isLoading, + const { application, isLoading, t } = useApplicationReview(); + + const isNewAhjoMode = useDetermineAhjoMode(); + + const { stepState, stepperDispatch } = useApplicationStepper( + application?.id ?? '', t, - isUploading, - handleUpload, - reviewState, - handleUpdateReviewState, - } = useApplicationReview(); + useQueryClient() + ); + + const [isRecalculationRequired, setIsRecalculationRequired] = + React.useState(false); + const [calculationsErrors, setCalculationErrors] = React.useState< + ErrorData | undefined | null + >(); + + const router = useRouter(); if (isLoading) { return ( - - - + <> + + + + <$Grid> + <$GridCell $colSpan={12}> + + + <$GridCell $colSpan={12}> + + + <$GridCell $colSpan={12}> + + + <$GridCell $colSpan={12}> + + + + + ); } - if (handledApplication?.status === application.status) { + if (router.query?.action === 'submit') { return ; } return ( - <> + <$ApplicationReview> - - {application.status === APPLICATION_STATUSES.INFO_REQUIRED && ( - <$InfoNeededBar> - {t(`common:review.fields.editEndDate`, { - date: convertToUIDateFormat( - application.additionalInformationNeededBy - ), - })} - - + + {isNewAhjoMode && + [ + APPLICATION_STATUSES.HANDLING, + APPLICATION_STATUSES.INFO_REQUIRED, + ].includes(application.status) && ( + )} - - {application.applicationOrigin === APPLICATION_ORIGINS.HANDLER && ( - - )} - - - - - - - - + )} + + {stepState.activeStepIndex === 1 && ( + + )} + {stepState.activeStepIndex === 2 && ( + + )} + + + {application.status === APPLICATION_STATUSES.RECEIVED && ( + - {application.status === APPLICATION_STATUSES.HANDLING && ( - <> - - - - )} - {application.status && - HANDLED_STATUSES.includes(application.status) && ( - - )} - {application.archived && } - - - {application.status === APPLICATION_STATUSES.RECEIVED && ( - )} - {(application.status === APPLICATION_STATUSES.HANDLING || - application.status === APPLICATION_STATUSES.INFO_REQUIRED || - (application.status && - HANDLED_STATUSES.includes(application.status))) && ( - )} - - <$StickyBarSpacing /> - - + + <$StickyBarSpacing /> + ); }; diff --git a/frontend/benefit/handler/src/components/applicationReview/CalculationReview.tsx b/frontend/benefit/handler/src/components/applicationReview/CalculationReview.tsx new file mode 100644 index 0000000000..de64341598 --- /dev/null +++ b/frontend/benefit/handler/src/components/applicationReview/CalculationReview.tsx @@ -0,0 +1,224 @@ +import AppContext from 'benefit/handler/context/AppContext'; +import { ApplicationListTableColumns } from 'benefit/handler/types/applicationList'; +import { APPLICATION_STATUSES } from 'benefit-shared/constants'; +import { Application, Row } from 'benefit-shared/types/application'; +import { IconCheckCircleFill, IconCrossCircleFill, Table } from 'hds-react'; +import clone from 'lodash/clone'; +import { TFunction, useTranslation } from 'next-i18next'; +import * as React from 'react'; +import Heading from 'shared/components/forms/heading/Heading'; +import { $GridCell } from 'shared/components/forms/section/FormSection.sc'; +import theme from 'shared/styles/theme'; +import { convertToUIDateFormat, diffMonths } from 'shared/utils/date.utils'; +import { formatFloatToCurrency } from 'shared/utils/string.utils'; + +import { $HorizontalList } from '../table/TableExtras.sc'; +import { $CalculationReviewTableWrapper } from './ApplicationReview.sc'; + +type ApplicationReviewStepProps = { + application: Application; +}; + +type BenefitRow = { + id: string; + dates: string; + amount: string; + amountNumber: string; + perMonth: string; + duration: number; + startDate: string; + endDate: string; +}; + +const createBenefitRow = (): BenefitRow => + clone({ + id: '', + dates: '', + amount: '', + amountNumber: '', + perMonth: '', + duration: 0, + startDate: '', + endDate: '', + }); + +const getTableCols = (t: TFunction): ApplicationListTableColumns[] => [ + { + key: 'dates', + headerName: t('common:review.decisionProposal.calculationReview.dates'), + }, + { + key: 'duration', + headerName: t('common:review.decisionProposal.calculationReview.duration'), + }, + { + key: 'perMonth', + headerName: t('common:review.decisionProposal.calculationReview.perMonth'), + }, + { + key: 'amount', + headerName: t('common:review.decisionProposal.calculationReview.amount'), + }, +]; + +const CalculationReview: React.FC = ({ + application, +}) => { + const { company, employee, calculation, decisionProposalDraft } = application; + const filteredData: Row[] = calculation.rows.filter((row) => + ['helsinki_benefit_monthly_eur', 'helsinki_benefit_sub_total_eur'].includes( + row.rowType + ) + ); + const { t } = useTranslation(); + const { handledApplication } = React.useContext(AppContext); + + let tableRow = createBenefitRow(); + + const tableRows = filteredData.reduce((acc: BenefitRow[], row: Row) => { + tableRow.id = row.id; + if (row.rowType === 'helsinki_benefit_monthly_eur') { + tableRow.perMonth = `${formatFloatToCurrency(row.amount)} / kk`; + } + if (row.rowType === 'helsinki_benefit_sub_total_eur') { + tableRow.dates = `${convertToUIDateFormat( + row.startDate + )} - ${convertToUIDateFormat(row.endDate)}`; + + tableRow.endDate = convertToUIDateFormat(row.endDate); + tableRow.startDate = convertToUIDateFormat(row.startDate); + + tableRow.duration = diffMonths( + new Date(row.endDate), + new Date(row.startDate) + ); + + tableRow.amount = formatFloatToCurrency(row.amount); + tableRow.amountNumber = row.amount; + acc.push(tableRow); + tableRow = createBenefitRow(); + } + return acc; + }, []); + + const totalSum = tableRows.reduce( + (acc: number, cur: BenefitRow) => acc + parseFloat(cur.amountNumber), + 0 + ); + + if (tableRows.length > 0) + tableRows.push({ + ...createBenefitRow(), + id: 'table-footer', + dates: `${tableRows.at(0).startDate} - ${tableRows.at(-1).endDate}`, + duration: tableRows.reduce( + (acc: number, cur: BenefitRow) => acc + cur.duration, + 0 + ), + amount: formatFloatToCurrency(totalSum), + }); + + return ( + <> + <$GridCell $colSpan={12}> + + {handledApplication.status === APPLICATION_STATUSES.ACCEPTED && ( +

+ {t('common:review.decisionProposal.list.text.accepted', { + months: tableRows.at(-1)?.duration, + startAndEndDate: `${tableRows.at(-1)?.dates}`, + })} +

+ )} + {handledApplication.status === APPLICATION_STATUSES.REJECTED && ( +

{t('common:review.decisionProposal.list.text.rejected')}

+ )} +
+ + + <$GridCell $colSpan={12}> + <$HorizontalList css="padding-left: 0;"> +
+
+ {t('common:review.decisionProposal.list.proposalStatus.label')} +
+
+ {decisionProposalDraft.status === + APPLICATION_STATUSES.ACCEPTED ? ( + <> + {' '} + {t( + 'common:review.decisionProposal.list.proposalStatus.accepted' + )} + + ) : ( + <> + {' '} + {t( + 'common:review.decisionProposal.list.proposalStatus.rejected' + )} + + )} +
+
+
+
{t('common:review.decisionProposal.list.employerName')}
+
{company.name}
+
+ {decisionProposalDraft.status === APPLICATION_STATUSES.REJECTED && ( +
+
{t('common:review.decisionProposal.list.employeeName')}
+
+ {employee.firstName} {employee.lastName} +
+
+ )} + {decisionProposalDraft.status === APPLICATION_STATUSES.ACCEPTED && ( + <> +
+
{t('common:review.decisionProposal.list.totalAmount')}
+
{formatFloatToCurrency(totalSum)}
+
+
+
+ {t( + 'common:review.decisionProposal.list.grantedAsDeMinimisAid' + )} +
+
+ {decisionProposalDraft.grantedAsDeMinimisAid + ? t('common:utility.yes') + : t('common:utility.no')} +
+
+ + )} + + + {handledApplication.status === APPLICATION_STATUSES.ACCEPTED && ( + <$GridCell $colSpan={12}> +
+ + <$CalculationReviewTableWrapper> + + + + )} + + ); +}; + +export default CalculationReview; diff --git a/frontend/benefit/handler/src/components/applicationReview/actions/handlingApplicationActions/HandlingApplicationActionsAhjo.tsx b/frontend/benefit/handler/src/components/applicationReview/actions/handlingApplicationActions/HandlingApplicationActionsAhjo.tsx new file mode 100644 index 0000000000..aeeb4f14e3 --- /dev/null +++ b/frontend/benefit/handler/src/components/applicationReview/actions/handlingApplicationActions/HandlingApplicationActionsAhjo.tsx @@ -0,0 +1,345 @@ +import Sidebar from 'benefit/handler/components/sidebar/Sidebar'; +import { HANDLED_STATUSES } from 'benefit/handler/constants'; +import { APPLICATION_STATUSES } from 'benefit-shared/constants'; +import { Application } from 'benefit-shared/types/application'; +import { + Button, + IconArrowLeft, + IconArrowRight, + IconInfoCircle, + IconLock, + IconPen, + IconTrash, +} from 'hds-react'; +import noop from 'lodash/noop'; +import { useRouter } from 'next/router'; +import * as React from 'react'; +import Modal from 'shared/components/modal/Modal'; +import showErrorToast from 'shared/components/toast/show-error-toast'; +import theme from 'shared/styles/theme'; +import { focusAndScroll } from 'shared/utils/dom.utils'; + +import useDecisionProposalDraftMutation from '../../../../hooks/applicationHandling/useDecisionProposalDraftMutation'; +import { + StepActionType, + StepStateType, +} from '../../../../hooks/applicationHandling/useHandlingStepper'; +import EditAction from '../editAction/EditAction'; +import CancelModalContent from './CancelModalContent/CancelModalContent'; +import DoneModalContent from './DoneModalContent/DoneModalContent'; +import { + $Column, + $CustomNotesActions, + $Wrapper, +} from './HandlingApplicationActions.sc'; +import { useHandlingApplicationActions } from './useHandlingApplicationActions'; + +export type Props = { + application: Application; + stepperDispatch: React.Dispatch; + stepState: StepStateType; + 'data-testid'?: string; + isRecalculationRequired: boolean; + isCalculationsErrors: boolean; +}; + +const HandlingApplicationActions: React.FC = ({ + application, + stepperDispatch, + stepState, + 'data-testid': dataTestId, + isRecalculationRequired, + isCalculationsErrors, +}) => { + const { + t, + toggleMessagesDrawerVisiblity, + openDialog, + closeDialog, + closeDoneDialog, + handleCancel, + isMessagesDrawerVisible, + translationsBase, + isConfirmationModalOpen, + isDoneConfirmationModalOpen, + handledApplication, + onDoneConfirmation, + } = useHandlingApplicationActions(application); + + const lastStep = + stepState.activeStepIndex === Number(stepState.steps?.length) - 1; + const router = useRouter(); + const { + data, + mutate: updateApplication, + isError, + } = useDecisionProposalDraftMutation(application); + + const [isSavingAndClosing, setIsSavingAndClosing] = React.useState(false); + + const effectSaveAndClose = (): void => { + if ( + data?.review_step === stepState.activeStepIndex + 1 && + isSavingAndClosing + ) { + setIsSavingAndClosing(false); + void router.push('/'); + } + }; + + const effectReviewStepChange = (): void => { + if (data?.review_step) { + stepperDispatch({ + type: 'completeStep', + payload: data.review_step - 2, + }); + } + }; + + const effectApplicationStatusChange = (): void => { + if ( + [APPLICATION_STATUSES.ACCEPTED, APPLICATION_STATUSES.REJECTED].includes( + application.status + ) && + stepState.activeStepIndex === 2 + ) { + router.query.action = 'submit'; + void router.push(router); + } + }; + + React.useEffect(effectApplicationStatusChange, [ + application.status, + router, + stepState.activeStepIndex, + ]); + React.useEffect(effectReviewStepChange, [data, stepperDispatch]); + React.useEffect(effectSaveAndClose, [ + data, + router, + stepState.activeStepIndex, + isSavingAndClosing, + ]); + React.useEffect(() => { + setIsSavingAndClosing(false); + }, [isError]); + + const validateNextStep = (currentStepIndex: number): boolean => { + if (application.status === APPLICATION_STATUSES.INFO_REQUIRED) { + focusAndScroll('header-info-needed'); + showErrorToast( + t('common:status.additional_information_needed'), + t(`common:applications.statuses.additionalInformationNeeded`) + ); + return true; + } + const missing = { + status: !handledApplication?.status, + calculation: + (application.calculation.rows.length === 0 && + handledApplication?.status === APPLICATION_STATUSES.ACCEPTED) || + isRecalculationRequired || + isCalculationsErrors, + logEntry: + handledApplication?.logEntryComment?.length <= 0 && + handledApplication?.status === APPLICATION_STATUSES.REJECTED, + handler: false, + // Use longer length to take HTML tags into account + decisionText: handledApplication?.decisionText?.length <= 10, + justificationText: handledApplication?.justificationText?.length <= 10, + }; + + const errorStep1 = + missing.status || missing.calculation || missing.logEntry; + + let errorStep2 = false; + if (currentStepIndex > 0) { + missing.handler = !['handler', 'manager'].includes( + handledApplication?.handlerRole + ); + + errorStep2 = + missing.decisionText || missing.justificationText || missing.handler; + } + + if (errorStep1 || errorStep2) { + const missingFields = Object.keys(missing).filter((key) => missing[key]); + let interval = 0; + missingFields.forEach((key) => { + setTimeout(() => { + showErrorToast( + t('common:review.decisionProposal.errors.title'), + t(`common:review.decisionProposal.errors.fields.${key}`) + ); + }, interval); + interval += 200; + }); + } + + return errorStep1 || errorStep2; + }; + + const handleNext = (finishProposal = false): void => { + if (finishProposal || stepState.activeStepIndex < 2) { + updateApplication({ + ...handledApplication, + reviewStep: Math.min(stepState.activeStepIndex + 2, 4), + applicationId: application.id, + }); + } else { + // Final step, just open confirmation modal + onDoneConfirmation(); + } + }; + + const handlePrev = (): void => { + updateApplication({ + ...handledApplication, + reviewStep: Math.max(0, stepState.activeStepIndex), + applicationId: application.id, + }); + }; + + const handleSaveAndClose = (): void => { + updateApplication({ + ...handledApplication, + reviewStep: stepState.activeStepIndex + 1, + applicationId: application.id, + }); + setIsSavingAndClosing(true); + }; + + const handleClose = (): void => void router.push('/'); + + return ( + <$Wrapper data-testid={dataTestId}> + <$Column> + + {application.status === APPLICATION_STATUSES.HANDLING && ( + + )} + + + + {![ + APPLICATION_STATUSES.CANCELLED, + APPLICATION_STATUSES.ACCEPTED, + APPLICATION_STATUSES.REJECTED, + ].includes(application.status) && + !application.batch && + !application.archived && ( + + )} + + + {application?.status === APPLICATION_STATUSES.HANDLING && ( + <$Column> + {stepState.activeStepIndex !== 0 && ( + + )} + + + )} + + {isConfirmationModalOpen && ( + } + submitButtonIcon={} + variant="danger" + customContent={ + + } + /> + )} + {isDoneConfirmationModalOpen && ( + } + className="" + variant="primary" + theme={theme.components.modal.coat} + customContent={ + handleNext(true)} + calculationRows={application.calculation?.rows} + /> + } + /> + )} + } + customItemsNotes={ + <$CustomNotesActions> + +

{t('common:messenger.showToHanlderOnly')}

+ + } + /> + + ); +}; + +export default HandlingApplicationActions; diff --git a/frontend/benefit/handler/src/components/applicationReview/applicationProcessingView/AplicationProcessingView.tsx b/frontend/benefit/handler/src/components/applicationReview/applicationProcessingView/ApplicationProcessingView.tsx similarity index 80% rename from frontend/benefit/handler/src/components/applicationReview/applicationProcessingView/AplicationProcessingView.tsx rename to frontend/benefit/handler/src/components/applicationReview/applicationProcessingView/ApplicationProcessingView.tsx index 9c542ac7ba..7a27321c46 100644 --- a/frontend/benefit/handler/src/components/applicationReview/applicationProcessingView/AplicationProcessingView.tsx +++ b/frontend/benefit/handler/src/components/applicationReview/applicationProcessingView/ApplicationProcessingView.tsx @@ -1,5 +1,6 @@ import ReviewSection from 'benefit/handler/components/reviewSection/ReviewSection'; import AppContext from 'benefit/handler/context/AppContext'; +import { useDetermineAhjoMode } from 'benefit/handler/hooks/useDetermineAhjoMode'; import { Application } from 'benefit/handler/types/application'; import { extractCalculatorRows } from 'benefit/handler/utils/calculator'; import { APPLICATION_STATUSES } from 'benefit-shared/constants'; @@ -35,6 +36,8 @@ const ApplicationProcessingView: React.FC<{ data: Application }> = ({ const { handledApplication, setHandledApplication } = React.useContext(AppContext); + const isNewAhjoMode = useDetermineAhjoMode(); + const toggleGrantedAsDeMinimisAid = (): void => { setHandledApplication({ ...handledApplication, @@ -42,6 +45,20 @@ const ApplicationProcessingView: React.FC<{ data: Application }> = ({ }); }; + React.useEffect(() => { + if (!handledApplication && isNewAhjoMode) { + setHandledApplication({ + status: data?.decisionProposalDraft?.status, + grantedAsDeMinimisAid: + !!data?.decisionProposalDraft?.grantedAsDeMinimisAid, + logEntryComment: data?.decisionProposalDraft?.logEntryComment, + handlerRole: data?.decisionProposalDraft?.handlerRole, + justificationText: data?.decisionProposalDraft?.justificationText, + decisionText: data?.decisionProposalDraft?.decisionText, + }); + } + }, [data, handledApplication, setHandledApplication, isNewAhjoMode]); + const onCommentsChange = ( event: React.ChangeEvent ): void => @@ -60,7 +77,9 @@ const ApplicationProcessingView: React.FC<{ data: Application }> = ({ return ( <$GridCell $colSpan={11}> - <$MainHeader>{t(`${translationsBase}.headings.heading10`)} + <$MainHeader css={{ margin: 'var(--spacing-xs) 0 var(--spacing-m)' }}> + {t(`${translationsBase}.headings.heading10`)} + <$Grid> <$GridCell $colSpan={11}> <$RadioButtonContainer> @@ -73,6 +92,7 @@ const ApplicationProcessingView: React.FC<{ data: Application }> = ({ onChange={(value) => { if (value) { setHandledApplication({ + ...handledApplication, logEntryComment: '', status: APPLICATION_STATUSES.REJECTED, grantedAsDeMinimisAid: false, @@ -114,7 +134,7 @@ const ApplicationProcessingView: React.FC<{ data: Application }> = ({ )} - <$GridCell $colSpan={11}> + <$GridCell $colSpan={11} css={{ marginBottom: 'var(--spacing-m)' }}> <$Grid> <$GridCell $colSpan={11}> <$RadioButtonContainer> @@ -127,6 +147,7 @@ const ApplicationProcessingView: React.FC<{ data: Application }> = ({ onChange={(value) => { if (value) { setHandledApplication({ + ...handledApplication, logEntryComment: '', status: APPLICATION_STATUSES.ACCEPTED, }); @@ -184,23 +205,25 @@ const ApplicationProcessingView: React.FC<{ data: Application }> = ({ ))} )} - <$GridCell - $colSpan={8} - $colStart={1} - style={{ paddingTop: theme.spacing.l }} - > -