From 010eb2b81f1579c1263020913f1ce51f28e97dfb Mon Sep 17 00:00:00 2001
From: Sandeep Chauhan <sandeepsajan0@gmail.com>
Date: Sun, 31 Mar 2024 01:26:33 +0530
Subject: [PATCH] Add status update batch action to invoices table (#3756)

Fixes part of #3537
---
 .../apply/activity/adapters/activity_feed.py  |  17 +++
 hypha/apply/activity/adapters/base.py         |   1 +
 .../migrations/0080_alter_event_type.py       |  84 +++++++++++++
 hypha/apply/activity/options.py               |   4 +
 .../includes/table_filter_and_search.html     |  62 +++++----
 hypha/apply/projects/forms/__init__.py        |   2 +
 hypha/apply/projects/forms/payment.py         | 118 +++++++++++-------
 hypha/apply/projects/service_utils.py         |   6 +
 hypha/apply/projects/tables.py                |  56 ++++++++-
 .../includes/batch_invoice_status_update.html |  13 ++
 .../application_projects/invoice_list.html    |   5 +-
 hypha/apply/projects/utils.py                 |   5 +
 hypha/apply/projects/views/payment.py         |  74 +++++++++--
 hypha/static_src/javascript/batch-actions.js  |  43 +++++++
 .../sass/components/_projects-table.scss      |  22 ++++
 15 files changed, 435 insertions(+), 77 deletions(-)
 create mode 100644 hypha/apply/activity/migrations/0080_alter_event_type.py
 create mode 100644 hypha/apply/projects/templates/application_projects/includes/batch_invoice_status_update.html

diff --git a/hypha/apply/activity/adapters/activity_feed.py b/hypha/apply/activity/adapters/activity_feed.py
index a8335198b..6131f0ffe 100644
--- a/hypha/apply/activity/adapters/activity_feed.py
+++ b/hypha/apply/activity/adapters/activity_feed.py
@@ -8,6 +8,7 @@ from hypha.apply.activity.models import ALL, APPLICANT, TEAM
 from hypha.apply.activity.options import MESSAGES
 from hypha.apply.projects.utils import (
     get_invoice_public_status,
+    get_invoice_status_display_value,
     get_project_public_status,
     get_project_status_display_value,
 )
@@ -66,6 +67,7 @@ class ActivityAdapter(AdapterBase):
         MESSAGES.DISABLED_REPORTING: _("Reporting disabled"),
         MESSAGES.BATCH_DELETE_SUBMISSION: "handle_batch_delete_submission",
         MESSAGES.BATCH_ARCHIVE_SUBMISSION: "handle_batch_archive_submission",
+        MESSAGES.BATCH_UPDATE_INVOICE_STATUS: "handle_batch_update_invoice_status",
         MESSAGES.ARCHIVE_SUBMISSION: _(
             "{user} has archived the submission: {source.title}"
         ),
@@ -164,6 +166,21 @@ class ActivityAdapter(AdapterBase):
             title=submissions_text
         )
 
+    def handle_batch_update_invoice_status(self, sources, invoices, **kwargs):
+        invoice_numbers = ", ".join(
+            [
+                invoice.invoice_number if invoice.invoice_number else ""
+                for invoice in invoices
+            ]
+        )
+        invoice_status = invoices[0].status if invoices else ""
+        return _(
+            "Successfully updated status to {invoice_status} for invoices: {invoice_numbers}"
+        ).format(
+            invoice_status=get_invoice_status_display_value(invoice_status),
+            invoice_numbers=invoice_numbers,
+        )
+
     def handle_paf_assignment(self, source, paf_approvals, **kwargs):
         if hasattr(paf_approvals, "__iter__"):  # paf_approvals has to be iterable
             users = ", ".join(
diff --git a/hypha/apply/activity/adapters/base.py b/hypha/apply/activity/adapters/base.py
index 535a3a2ea..6889e3427 100644
--- a/hypha/apply/activity/adapters/base.py
+++ b/hypha/apply/activity/adapters/base.py
@@ -36,6 +36,7 @@ neat_related = {
     MESSAGES.CREATE_REMINDER: "reminder",
     MESSAGES.DELETE_REMINDER: "reminder",
     MESSAGES.REVIEW_REMINDER: "reminder",
+    MESSAGES.BATCH_UPDATE_INVOICE_STATUS: "invoices",
 }
 
 
diff --git a/hypha/apply/activity/migrations/0080_alter_event_type.py b/hypha/apply/activity/migrations/0080_alter_event_type.py
new file mode 100644
index 000000000..452120d67
--- /dev/null
+++ b/hypha/apply/activity/migrations/0080_alter_event_type.py
@@ -0,0 +1,84 @@
+# Generated by Django 4.2.9 on 2024-02-08 04:44
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("activity", "0079_alter_activity_visibility"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="event",
+            name="type",
+            field=models.CharField(
+                choices=[
+                    ("UPDATE_LEAD", "updated lead"),
+                    ("BATCH_UPDATE_LEAD", "batch updated lead"),
+                    ("EDIT_SUBMISSION", "edited submission"),
+                    ("APPLICANT_EDIT", "edited applicant"),
+                    ("NEW_SUBMISSION", "submitted new submission"),
+                    ("DRAFT_SUBMISSION", "submitted new draft submission"),
+                    ("SCREENING", "screened"),
+                    ("TRANSITION", "transitioned"),
+                    ("BATCH_TRANSITION", "batch transitioned"),
+                    ("DETERMINATION_OUTCOME", "sent determination outcome"),
+                    ("BATCH_DETERMINATION_OUTCOME", "sent batch determination outcome"),
+                    ("INVITED_TO_PROPOSAL", "invited to proposal"),
+                    ("REVIEWERS_UPDATED", "updated reviewers"),
+                    ("BATCH_REVIEWERS_UPDATED", "batch updated reviewers"),
+                    ("PARTNERS_UPDATED", "updated partners"),
+                    ("PARTNERS_UPDATED_PARTNER", "partners updated partner"),
+                    ("READY_FOR_REVIEW", "marked ready for review"),
+                    ("BATCH_READY_FOR_REVIEW", "marked batch ready for review"),
+                    ("NEW_REVIEW", "added new review"),
+                    ("COMMENT", "added comment"),
+                    ("PROPOSAL_SUBMITTED", "submitted proposal"),
+                    ("OPENED_SEALED", "opened sealed submission"),
+                    ("REVIEW_OPINION", "reviewed opinion"),
+                    ("DELETE_SUBMISSION", "deleted submission"),
+                    ("DELETE_REVIEW", "deleted review"),
+                    ("DELETE_REVIEW_OPINION", "deleted review opinion"),
+                    ("CREATED_PROJECT", "created project"),
+                    ("UPDATED_VENDOR", "updated contracting information"),
+                    ("UPDATE_PROJECT_LEAD", "updated project lead"),
+                    ("EDIT_REVIEW", "edited review"),
+                    ("SEND_FOR_APPROVAL", "sent for approval"),
+                    ("APPROVE_PROJECT", "approved project"),
+                    ("ASSIGN_PAF_APPROVER", "assign paf approver"),
+                    ("APPROVE_PAF", "approved paf"),
+                    ("PROJECT_TRANSITION", "transitioned project"),
+                    ("REQUEST_PROJECT_CHANGE", "requested project change"),
+                    ("SUBMIT_CONTRACT_DOCUMENTS", "submitted contract documents"),
+                    ("UPLOAD_DOCUMENT", "uploaded document to project"),
+                    ("REMOVE_DOCUMENT", "removed document from project"),
+                    ("UPLOAD_CONTRACT", "uploaded contract to project"),
+                    ("APPROVE_CONTRACT", "approved contract"),
+                    ("CREATE_INVOICE", "created invoice for project"),
+                    ("UPDATE_INVOICE_STATUS", "updated invoice status"),
+                    ("APPROVE_INVOICE", "approve invoice"),
+                    ("DELETE_INVOICE", "deleted invoice"),
+                    ("SENT_TO_COMPLIANCE", "sent project to compliance"),
+                    ("UPDATE_INVOICE", "updated invoice"),
+                    ("SUBMIT_REPORT", "submitted report"),
+                    ("SKIPPED_REPORT", "skipped report"),
+                    ("REPORT_FREQUENCY_CHANGED", "changed report frequency"),
+                    ("DISABLED_REPORTING", "disabled reporting"),
+                    ("REPORT_NOTIFY", "notified report"),
+                    ("CREATE_REMINDER", "created reminder"),
+                    ("DELETE_REMINDER", "deleted reminder"),
+                    ("REVIEW_REMINDER", "reminder to review"),
+                    ("BATCH_DELETE_SUBMISSION", "batch deleted submissions"),
+                    ("BATCH_ARCHIVE_SUBMISSION", "batch archive submissions"),
+                    ("BATCH_INVOICE_STATUS_UPDATE", "batch update invoice status"),
+                    ("STAFF_ACCOUNT_CREATED", "created new account"),
+                    ("STAFF_ACCOUNT_EDITED", "edited account"),
+                    ("ARCHIVE_SUBMISSION", "archived submission"),
+                    ("UNARCHIVE_SUBMISSION", "unarchived submission"),
+                ],
+                max_length=50,
+                verbose_name="verb",
+            ),
+        ),
+    ]
diff --git a/hypha/apply/activity/options.py b/hypha/apply/activity/options.py
index 5aa17b9d5..2675ec57c 100644
--- a/hypha/apply/activity/options.py
+++ b/hypha/apply/activity/options.py
@@ -73,6 +73,10 @@ class MESSAGES(TextChoices):
         "BATCH_ARCHIVE_SUBMISSION",
         _("batch archive submissions"),
     )
+    BATCH_UPDATE_INVOICE_STATUS = (
+        "BATCH_INVOICE_STATUS_UPDATE",
+        _("batch update invoice status"),
+    )
     STAFF_ACCOUNT_CREATED = "STAFF_ACCOUNT_CREATED", _("created new account")
     STAFF_ACCOUNT_EDITED = "STAFF_ACCOUNT_EDITED", _("edited account")
     ARCHIVE_SUBMISSION = "ARCHIVE_SUBMISSION", _("archived submission")
diff --git a/hypha/apply/funds/templates/funds/includes/table_filter_and_search.html b/hypha/apply/funds/templates/funds/includes/table_filter_and_search.html
index f837c4bfe..6633c8728 100644
--- a/hypha/apply/funds/templates/funds/includes/table_filter_and_search.html
+++ b/hypha/apply/funds/templates/funds/includes/table_filter_and_search.html
@@ -27,35 +27,42 @@
                 <div class="actions-bar__inner actions-bar__inner--batch-actions">
                     <p class="actions-bar__total"><span class="js-total-actions">0</span> {% trans "Selected" %}</p>
 
-                    <button
-                        data-fancybox
-                        type="button"
-                        data-src="#batch-progress"
-                        class="button button--action js-batch-button js-batch-progress"
-                    >
-                        {% heroicon_mini "arrows-right-left" class="inline" aria_hidden=true %}
-                        {% trans "Status" %}
-                    </button>
+                    {% if not project_actions %}
+                        <button
+                            data-fancybox
+                            type="button"
+                            data-src="#batch-progress"
+                            class="button button--action js-batch-button js-batch-progress"
+                        >
+                            {% heroicon_mini "arrows-right-left" class="inline" aria_hidden=true %}
+                            {% trans "Status" %}
+                        </button>
 
-                    <button data-fancybox data-src="#batch-update-lead" class="button button--action js-batch-button" type="button">
-                        {% heroicon_micro "user-plus" class="inline" aria_hidden=true %}
-                        {% trans "Lead" %}
-                    </button>
+                        <button data-fancybox data-src="#batch-update-lead" class="button button--action js-batch-button" type="button">
+                            {% heroicon_micro "user-plus" class="inline" aria_hidden=true %}
+                            {% trans "Lead" %}
+                        </button>
 
-                    <button data-fancybox data-src="#batch-update-reviewers" class="button button--action js-batch-button" type="button">
-                        {% heroicon_micro "user-plus" class="inline" aria_hidden=true %}
-                        {% trans "Reviewers" %}
-                    </button>
+                        <button data-fancybox data-src="#batch-update-reviewers" class="button button--action js-batch-button" type="button">
+                            {% heroicon_micro "user-plus" class="inline" aria_hidden=true %}
+                            {% trans "Reviewers" %}
+                        </button>
 
-                    <button data-fancybox data-src="#batch-delete-submission" class="button button--action js-batch-button" type="button">
-                        {% heroicon_micro "trash" class="inline" aria_hidden=true %}
-                        {% trans "Delete" %}
-                    </button>
+                        <button data-fancybox data-src="#batch-delete-submission" class="button button--action js-batch-button" type="button">
+                            {% heroicon_micro "trash" class="inline" aria_hidden=true %}
+                            {% trans "Delete" %}
+                        </button>
 
-                    {% if can_bulk_archive %}
-                        <button data-fancybox data-src="#batch-archive-submission" class="button button--action js-batch-button" type="button">
-                            {% heroicon_micro "archive-box-arrow-down" class="inline" aria_hidden=true %}
-                            {% trans "Archive" %}
+                        {% if can_bulk_archive %}
+                            <button data-fancybox data-src="#batch-archive-submission" class="button button--action js-batch-button" type="button">
+                                {% heroicon_micro "archive-box-arrow-down" class="inline" aria_hidden=true %}
+                                {% trans "Archive" %}
+                            </button>
+                        {% endif %}
+                    {% elif invoice_actions %}
+                        <button data-fancybox data-src="#batch_update_invoice_status" class="button button--action js-batch-button js-batch-invoice-progress" type="button">
+                            {% heroicon_mini "arrows-right-left" class="inline" aria_hidden=true %}
+                            {% trans "Status" %}
                         </button>
                     {% endif %}
                 </div>
@@ -124,4 +131,9 @@
     {% include "funds/includes/batch_progress_form.html" %}
     {% include "funds/includes/batch_delete_submission_form.html" %}
     {% include "funds/includes/batch_archive_submission_form.html" %}
+    {% if project_actions %}
+        {% if invoice_actions %}
+            {% include "application_projects/includes/batch_invoice_status_update.html" %}
+        {% endif %}
+    {% endif %}
 {% endif %}
diff --git a/hypha/apply/projects/forms/__init__.py b/hypha/apply/projects/forms/__init__.py
index 2514a9b34..3405b7930 100644
--- a/hypha/apply/projects/forms/__init__.py
+++ b/hypha/apply/projects/forms/__init__.py
@@ -1,4 +1,5 @@
 from .payment import (
+    BatchUpdateInvoiceStatusForm,
     ChangeInvoiceStatusForm,
     CreateInvoiceForm,
     EditInvoiceForm,
@@ -39,6 +40,7 @@ __all__ = [
     "ApproveContractForm",
     "ApproversForm",
     "AssignApproversForm",
+    "BatchUpdateInvoiceStatusForm",
     "ChangePAFStatusForm",
     "ChangeProjectStatusForm",
     "CreateProjectForm",
diff --git a/hypha/apply/projects/forms/payment.py b/hypha/apply/projects/forms/payment.py
index a68c9978f..4896527c6 100644
--- a/hypha/apply/projects/forms/payment.py
+++ b/hypha/apply/projects/forms/payment.py
@@ -28,6 +28,7 @@ from ..models.payment import (
     invoice_status_user_choices,
 )
 from ..models.project import PacketFile
+from ..utils import get_invoice_status_display_value
 
 
 def filter_request_choices(choices, user_choices):
@@ -36,6 +37,50 @@ def filter_request_choices(choices, user_choices):
     ]
 
 
+def get_invoice_possible_transition_for_user(user, invoice):
+    user_choices = invoice_status_user_choices(user)
+    possible_status_transitions_lut = {
+        SUBMITTED: filter_request_choices(
+            [CHANGES_REQUESTED_BY_STAFF, APPROVED_BY_STAFF, DECLINED], user_choices
+        ),
+        RESUBMITTED: filter_request_choices(
+            [CHANGES_REQUESTED_BY_STAFF, APPROVED_BY_STAFF, DECLINED], user_choices
+        ),
+        CHANGES_REQUESTED_BY_STAFF: filter_request_choices([DECLINED], user_choices),
+        APPROVED_BY_STAFF: filter_request_choices(
+            [
+                CHANGES_REQUESTED_BY_FINANCE,
+                APPROVED_BY_FINANCE,
+            ],
+            user_choices,
+        ),
+        CHANGES_REQUESTED_BY_FINANCE: filter_request_choices(
+            [CHANGES_REQUESTED_BY_STAFF, DECLINED], user_choices
+        ),
+        APPROVED_BY_FINANCE: filter_request_choices([PAID], user_choices),
+        PAID: filter_request_choices([PAYMENT_FAILED], user_choices),
+        PAYMENT_FAILED: filter_request_choices([PAID], user_choices),
+    }
+    if settings.INVOICE_EXTENDED_WORKFLOW:
+        possible_status_transitions_lut.update(
+            {
+                CHANGES_REQUESTED_BY_FINANCE_2: filter_request_choices(
+                    [
+                        CHANGES_REQUESTED_BY_FINANCE,
+                        APPROVED_BY_FINANCE,
+                    ],
+                    user_choices,
+                ),
+                APPROVED_BY_FINANCE: filter_request_choices(
+                    [CHANGES_REQUESTED_BY_FINANCE_2, APPROVED_BY_FINANCE_2],
+                    user_choices,
+                ),
+                APPROVED_BY_FINANCE_2: filter_request_choices([PAID], user_choices),
+            }
+        )
+    return possible_status_transitions_lut.get(invoice.status, [])
+
+
 class ChangeInvoiceStatusForm(forms.ModelForm):
     name_prefix = "change_invoice_status_form"
 
@@ -47,49 +92,10 @@ class ChangeInvoiceStatusForm(forms.ModelForm):
         super().__init__(*args, **kwargs, instance=instance)
         self.initial["comment"] = ""
         status_field = self.fields["status"]
-        user_choices = invoice_status_user_choices(user)
-        possible_status_transitions_lut = {
-            SUBMITTED: filter_request_choices(
-                [CHANGES_REQUESTED_BY_STAFF, APPROVED_BY_STAFF, DECLINED], user_choices
-            ),
-            RESUBMITTED: filter_request_choices(
-                [CHANGES_REQUESTED_BY_STAFF, APPROVED_BY_STAFF, DECLINED], user_choices
-            ),
-            CHANGES_REQUESTED_BY_STAFF: filter_request_choices(
-                [DECLINED], user_choices
-            ),
-            APPROVED_BY_STAFF: filter_request_choices(
-                [
-                    CHANGES_REQUESTED_BY_FINANCE,
-                    APPROVED_BY_FINANCE,
-                ],
-                user_choices,
-            ),
-            CHANGES_REQUESTED_BY_FINANCE: filter_request_choices(
-                [CHANGES_REQUESTED_BY_STAFF, DECLINED], user_choices
-            ),
-            APPROVED_BY_FINANCE: filter_request_choices([PAID], user_choices),
-            PAID: filter_request_choices([PAYMENT_FAILED], user_choices),
-            PAYMENT_FAILED: filter_request_choices([PAID], user_choices),
-        }
-        if settings.INVOICE_EXTENDED_WORKFLOW:
-            possible_status_transitions_lut.update(
-                {
-                    CHANGES_REQUESTED_BY_FINANCE_2: filter_request_choices(
-                        [
-                            CHANGES_REQUESTED_BY_FINANCE,
-                            APPROVED_BY_FINANCE,
-                        ],
-                        user_choices,
-                    ),
-                    APPROVED_BY_FINANCE: filter_request_choices(
-                        [CHANGES_REQUESTED_BY_FINANCE_2, APPROVED_BY_FINANCE_2],
-                        user_choices,
-                    ),
-                    APPROVED_BY_FINANCE_2: filter_request_choices([PAID], user_choices),
-                }
-            )
-        status_field.choices = possible_status_transitions_lut.get(instance.status, [])
+
+        status_field.choices = get_invoice_possible_transition_for_user(
+            user, invoice=instance
+        )
 
 
 class InvoiceBaseForm(forms.ModelForm):
@@ -199,3 +205,29 @@ class SelectDocumentForm(forms.ModelForm):
     @transaction.atomic()
     def save(self, *args, **kwargs):
         return super().save(*args, **kwargs)
+
+
+class BatchUpdateInvoiceStatusForm(forms.Form):
+    invoice_action = forms.ChoiceField(label=_("Status"))
+    invoices = forms.CharField(
+        widget=forms.HiddenInput(attrs={"class": "js-invoices-id"})
+    )
+
+    def __init__(self, *args, **kwargs):
+        self.user = kwargs.pop("user")
+        super().__init__(*args, **kwargs)
+        if self.user.is_apply_staff:
+            self.fields["invoice_action"].choices = [
+                (DECLINED, get_invoice_status_display_value(DECLINED))
+            ]
+        elif self.user.is_finance:
+            self.fields["invoice_action"].choices = [
+                (DECLINED, get_invoice_status_display_value(DECLINED)),
+                (PAID, get_invoice_status_display_value(PAID)),
+                (PAYMENT_FAILED, get_invoice_status_display_value(PAYMENT_FAILED)),
+            ]
+
+    def clean_invoices(self):
+        value = self.cleaned_data["invoices"]
+        invoice_ids = [int(invoice) for invoice in value.split(",")]
+        return Invoice.objects.filter(id__in=invoice_ids)
diff --git a/hypha/apply/projects/service_utils.py b/hypha/apply/projects/service_utils.py
index b1ae26e20..db23ab543 100644
--- a/hypha/apply/projects/service_utils.py
+++ b/hypha/apply/projects/service_utils.py
@@ -152,3 +152,9 @@ def handle_tasks_on_invoice_update(old_status, invoice):
                 ),
                 related_obj=invoice,
             )
+
+
+def batch_update_invoices_status(invoices, user, status):
+    for invoice in invoices:
+        invoice.status = status
+        invoice.save(update_fields=["status"])
diff --git a/hypha/apply/projects/tables.py b/hypha/apply/projects/tables.py
index 2838b6792..4d351299a 100644
--- a/hypha/apply/projects/tables.py
+++ b/hypha/apply/projects/tables.py
@@ -1,20 +1,46 @@
+import json
 import textwrap
 
 import django_tables2 as tables
 from django.utils.safestring import mark_safe
+from django.utils.text import slugify
 from django.utils.translation import gettext_lazy as _
+from django_tables2.utils import A
 
+from hypha.apply.funds.tables import LabeledCheckboxColumn
+
+from .forms.payment import get_invoice_possible_transition_for_user
 from .models import Invoice, PAFApprovals, Project, Report
 
 
+def render_invoice_actions(table, record):
+    user = table.context["user"]
+    actions = get_invoice_possible_transition_for_user(user, invoice=record)
+    return json.dumps([str(slugify(action)) for action, _ in actions])
+
+
 class BaseInvoiceTable(tables.Table):
     invoice_number = tables.LinkColumn(
         "funds:projects:invoice-detail",
         verbose_name=_("Invoice Number"),
         args=[tables.utils.A("project__pk"), tables.utils.A("pk")],
+        attrs={
+            "td": {
+                "class": "js-title",  # using title as class because of batch-actions.js
+            },
+            "a": {
+                "data-tippy-content": lambda record: record.invoice_number,
+                "data-tippy-placement": "top",
+                # Use after:content-[''] after:block to hide the default browser tooltip on Safari
+                # https://stackoverflow.com/a/43915246
+                "class": "truncate inline-block w-[calc(100%-2rem)] after:content-[''] after:block",
+            },
+        },
     )
     project = tables.Column(verbose_name=_("Project Name"))
-    status = tables.Column()
+    status = tables.Column(
+        attrs={"td": {"data-actions": render_invoice_actions, "class": "js-actions"}},
+    )
     requested_at = tables.DateColumn(verbose_name=_("Submitted"))
 
     def render_project(self, value):
@@ -56,6 +82,34 @@ class InvoiceListTable(BaseInvoiceTable):
         attrs = {"class": "invoices-table"}
 
 
+class AdminInvoiceListTable(BaseInvoiceTable):
+    selected = LabeledCheckboxColumn(
+        accessor=A("pk"),
+        attrs={
+            "input": {"class": "js-batch-select"},
+            "th__input": {"class": "js-batch-select-all"},
+        },
+    )
+
+    class Meta:
+        fields = [
+            "selected",
+            "requested_at",
+            "invoice_number",
+            "status",
+            "project",
+        ]
+        model = Invoice
+        orderable = True
+        sequence = fields
+        order_by = ["-requested_at"]
+        template_name = "application_projects/tables/table.html"
+        attrs = {"class": "invoices-table"}
+        row_attrs = {
+            "data-record-id": lambda record: record.id,
+        }
+
+
 class BaseProjectsTable(tables.Table):
     title = tables.LinkColumn(
         "funds:projects:detail",
diff --git a/hypha/apply/projects/templates/application_projects/includes/batch_invoice_status_update.html b/hypha/apply/projects/templates/application_projects/includes/batch_invoice_status_update.html
new file mode 100644
index 000000000..75120ef70
--- /dev/null
+++ b/hypha/apply/projects/templates/application_projects/includes/batch_invoice_status_update.html
@@ -0,0 +1,13 @@
+{% load i18n %}
+<div class="modal" id="batch_update_invoice_status">
+    <h4 class="modal__header-bar modal__header-bar--no-bottom-space">{% trans "Update Invoices Status" %}</h4>
+    <div class="list-reveal">
+        <div class="list-reveal__item list-reveal__item--meta" aria-live="polite">
+            <span class="js-batch-title-count"></span>
+            <a href="#" class="list-reveal__link js-toggle-batch-list">{% trans "Show" %}</a>
+        </div>
+        <div class="list-reveal__list js-batch-titles is-closed" aria-live="polite"></div>
+    </div>
+    {% trans "Update status" as update %}
+    {% include 'funds/includes/delegated_form_base.html' with form=batch_invoice_status_form value=update %}
+</div>
diff --git a/hypha/apply/projects/templates/application_projects/invoice_list.html b/hypha/apply/projects/templates/application_projects/invoice_list.html
index bb724fe23..0197df611 100644
--- a/hypha/apply/projects/templates/application_projects/invoice_list.html
+++ b/hypha/apply/projects/templates/application_projects/invoice_list.html
@@ -22,7 +22,7 @@
 
         {% if table %}
             {% trans "invoices" as search_placeholder %}
-            {% include "funds/includes/table_filter_and_search.html" with filter_form=filter_form search_term=search_term use_search=True filter_action=filter_action use_batch_actions=True search_placeholder=search_placeholder %}
+            {% include "funds/includes/table_filter_and_search.html" with filter_form=filter_form search_term=search_term use_search=True filter_action=filter_action use_batch_actions=True project_actions=True invoice_actions=True search_placeholder=search_placeholder %}
             {% render_table table %}
         {% else %}
             <p>{% trans "No Invoices available" %}</p>
@@ -38,5 +38,8 @@
 
 {% block extra_js %}
     {{ filter.form.media.js }}
+    <script src="{% static 'js/jquery.fancybox.min.js' %}"></script>
+    <script src="{% static 'js/fancybox-global.js' %}"></script>
     <script src="{% static 'js/submission-filters.js' %}"></script>
+    <script src="{% static 'js/batch-actions.js' %}"></script>
 {% endblock %}
diff --git a/hypha/apply/projects/utils.py b/hypha/apply/projects/utils.py
index 8a88c021c..5a07ecb82 100644
--- a/hypha/apply/projects/utils.py
+++ b/hypha/apply/projects/utils.py
@@ -20,6 +20,7 @@ from .models.payment import (
     CHANGES_REQUESTED_BY_FINANCE_2,
     CHANGES_REQUESTED_BY_STAFF,
     DECLINED,
+    INVOICE_STATUS_CHOICES,
     PAID,
     PAYMENT_FAILED,
     RESUBMITTED,
@@ -159,6 +160,10 @@ def get_project_public_status(project_status):
     return dict(PROJECT_PUBLIC_STATUSES)[project_status]
 
 
+def get_invoice_status_display_value(invoice_status):
+    return dict(INVOICE_STATUS_CHOICES)[invoice_status]
+
+
 def get_invoice_table_status(invoice_status, is_applicant=False):
     if invoice_status in [SUBMITTED, RESUBMITTED]:
         if is_applicant:
diff --git a/hypha/apply/projects/views/payment.py b/hypha/apply/projects/views/payment.py
index 350545c23..ca348b84a 100644
--- a/hypha/apply/projects/views/payment.py
+++ b/hypha/apply/projects/views/payment.py
@@ -8,8 +8,15 @@ from django.db import transaction
 from django.shortcuts import get_object_or_404, redirect
 from django.utils import timezone
 from django.utils.decorators import method_decorator
+from django.utils.safestring import mark_safe
 from django.utils.translation import gettext as _
-from django.views.generic import CreateView, DeleteView, DetailView, UpdateView
+from django.views.generic import (
+    CreateView,
+    DeleteView,
+    DetailView,
+    FormView,
+    UpdateView,
+)
 from django_filters.views import FilterView
 from django_tables2 import SingleTableMixin
 
@@ -29,10 +36,20 @@ from hypha.apply.todo.views import (
 from hypha.apply.users.decorators import staff_or_finance_required
 from hypha.apply.users.groups import STAFF_GROUP_NAME
 from hypha.apply.utils.storage import PrivateMediaView
-from hypha.apply.utils.views import DelegateableView, DelegatedViewMixin, ViewDispatcher
+from hypha.apply.utils.views import (
+    DelegateableListView,
+    DelegateableView,
+    DelegatedViewMixin,
+    ViewDispatcher,
+)
 
 from ..filters import InvoiceListFilter
-from ..forms import ChangeInvoiceStatusForm, CreateInvoiceForm, EditInvoiceForm
+from ..forms import (
+    BatchUpdateInvoiceStatusForm,
+    ChangeInvoiceStatusForm,
+    CreateInvoiceForm,
+    EditInvoiceForm,
+)
 from ..models.payment import (
     APPROVED_BY_FINANCE,
     APPROVED_BY_STAFF,
@@ -42,8 +59,8 @@ from ..models.payment import (
     Invoice,
 )
 from ..models.project import PROJECT_ACTION_MESSAGE_TAG, Project
-from ..service_utils import handle_tasks_on_invoice_update
-from ..tables import InvoiceListTable
+from ..service_utils import batch_update_invoices_status, handle_tasks_on_invoice_update
+from ..tables import AdminInvoiceListTable
 
 
 @method_decorator(login_required, name="dispatch")
@@ -420,8 +437,51 @@ class InvoicePrivateMedia(UserPassesTestMixin, PrivateMediaView):
 
 
 @method_decorator(staff_or_finance_required, name="dispatch")
-class InvoiceListView(SingleTableMixin, FilterView):
+class BatchUpdateInvoiceStatusView(DelegatedViewMixin, FormView):
+    form_class = BatchUpdateInvoiceStatusForm
+    context_name = "batch_invoice_status_form"
+
+    def form_valid(self, form):
+        new_status = form.cleaned_data["invoice_action"]
+        invoices = form.cleaned_data["invoices"]
+        invoices_old_statuses = {invoice: invoice.status for invoice in invoices}
+        batch_update_invoices_status(
+            invoices=invoices,
+            user=self.request.user,
+            status=new_status,
+        )
+
+        # add activity feed for batch update invoice status
+        projects = Project.objects.filter(
+            id__in=[invoice.project.id for invoice in invoices]
+        )
+        messenger(
+            MESSAGES.BATCH_UPDATE_INVOICE_STATUS,
+            request=self.request,
+            user=self.request.user,
+            sources=projects,
+            related=invoices,
+        )
+
+        # update tasks for selected invoices
+        for invoice, old_status in invoices_old_statuses.items():
+            handle_tasks_on_invoice_update(old_status, invoice)
+        return super().form_valid(form)
+
+    def form_invalid(self, form):
+        messages.error(
+            self.request,
+            mark_safe(_("Sorry something went wrong") + form.errors.as_ul()),
+        )
+        return super().form_invalid(form)
+
+
+@method_decorator(staff_or_finance_required, name="dispatch")
+class InvoiceListView(SingleTableMixin, FilterView, DelegateableListView):
+    form_views = [
+        BatchUpdateInvoiceStatusView,
+    ]
     filterset_class = InvoiceListFilter
     model = Invoice
-    table_class = InvoiceListTable
+    table_class = AdminInvoiceListTable
     template_name = "application_projects/invoice_list.html"
diff --git a/hypha/static_src/javascript/batch-actions.js b/hypha/static_src/javascript/batch-actions.js
index 532c21ef0..e94bd4d67 100644
--- a/hypha/static_src/javascript/batch-actions.js
+++ b/hypha/static_src/javascript/batch-actions.js
@@ -6,10 +6,13 @@
     const $allCheckboxInput = $(".js-batch-select-all");
     const $batchButtons = $(".js-batch-button");
     const $batchProgress = $(".js-batch-progress");
+    const $batchInvoiceProgress = $(".js-batch-invoice-progress");
     const $actionOptions = $("#id_action option");
+    const $actionInvoiceOptions = $("#id_invoice_action option");
     const $batchTitlesList = $(".js-batch-titles");
     const $batchTitleCount = $(".js-batch-title-count");
     const $hiddenIDlist = $(".js-submissions-id");
+    const $hiddenInvoiceIDlist = $(".js-invoices-id");
     const $batchDetermineSend = $(".js-batch-determine-send");
     const $batchDetermineConfirm = $(".js-batch-determine-confirm");
     const $batchDetermineForm = $batchDetermineSend.parent("form");
@@ -48,6 +51,7 @@
         toggleBatchActions();
         updateCount();
         updateProgressButton();
+        updateInvoiceProgressButton();
     });
 
     $checkbox.change(function () {
@@ -63,6 +67,7 @@
         }
 
         updateProgressButton();
+        updateInvoiceProgressButton();
     });
 
     // append selected project titles to batch update reviewer modal
@@ -75,6 +80,9 @@
     $batchProgress.click(function () {
         updateProgressButton();
     });
+    $batchInvoiceProgress.click(function () {
+        updateInvoiceProgressButton();
+    });
 
     // show/hide the list of actions
     $toggleBatchList.click((e) => {
@@ -116,6 +124,41 @@
 
         $batchTitleCount.append(`${selectedIDs.length} submissions selected`);
         $hiddenIDlist.val(selectedIDs.join(","));
+        $hiddenInvoiceIDlist.val(selectedIDs.join(","));
+    }
+
+    function updateInvoiceProgressButton() {
+        var actions = $actionInvoiceOptions
+            .map(function () {
+                return this.value;
+            })
+            .get();
+        $checkbox.filter(":checked").each(function () {
+            let newActions = $(this)
+                .parents("tr")
+                .find(".js-actions")
+                .data("actions");
+            actions = actions.filter((action) => newActions.includes(action));
+        });
+
+        $actionInvoiceOptions.each(function () {
+            if (!actions.includes(this.value)) {
+                $(this).attr("disabled", "disabled");
+            } else {
+                $(this).removeAttr("disabled");
+            }
+        });
+        $actionInvoiceOptions.filter(":enabled:first").prop("selected", true);
+        if (actions.length === 0) {
+            $batchInvoiceProgress.attr("disabled", "disabled");
+            $batchInvoiceProgress.attr(
+                "data-tooltip",
+                "Status changes can't be applied to Invoices with this combination of statuses"
+            );
+        } else {
+            $batchInvoiceProgress.removeAttr("disabled");
+            $batchInvoiceProgress.removeAttr("data-tooltip");
+        }
     }
 
     function updateProgressButton() {
diff --git a/hypha/static_src/sass/components/_projects-table.scss b/hypha/static_src/sass/components/_projects-table.scss
index 525ba44ef..70ebf6a30 100644
--- a/hypha/static_src/sass/components/_projects-table.scss
+++ b/hypha/static_src/sass/components/_projects-table.scss
@@ -44,6 +44,17 @@
         @include media-query($table-breakpoint) {
             display: table-header-group;
         }
+
+        th {
+            &.selected {
+                @include table-checkbox;
+
+                @include media-query($table-breakpoint) {
+                    width: 50px;
+                    padding-right: 0;
+                }
+            }
+        }
     }
 
     tbody {
@@ -57,6 +68,17 @@
                     display: none;
                 }
             }
+
+            // batch action checkboxes
+            &.selected {
+                @include table-checkbox;
+                display: none;
+                padding-right: 0;
+
+                @include media-query($table-breakpoint) {
+                    display: table-cell;
+                }
+            }
         }
     }
 }
-- 
GitLab