diff --git a/hypha/apply/projects/admin.py b/hypha/apply/projects/admin.py
index 68b7440e44..ed07ab63f6 100644
--- a/hypha/apply/projects/admin.py
+++ b/hypha/apply/projects/admin.py
@@ -15,6 +15,7 @@
from .models import (
ContractDocumentCategory,
DocumentCategory,
+ InvoiceTag,
ProjectForm,
ProjectReportForm,
ProjectSettings,
@@ -95,12 +96,20 @@ class ProjectSettingsAdmin(SettingModelAdmin):
model = ProjectSettings
+class InvoiceTagAdmin(ModelAdmin):
+ model = InvoiceTag
+ menu_label = _("Invoice Tags")
+ menu_icon = "tag"
+ list_display = ("name",)
+
+
class ProjectAdminGroup(ModelAdminGroup):
menu_label = _("Projects")
menu_icon = str(AdminIcon.PROJECT)
items = (
ContractDocumentCategoryAdmin,
DocumentCategoryAdmin,
+ InvoiceTagAdmin,
ProjectFormAdmin,
ProjectReportFormAdmin,
ProjectSOWFormAdmin,
diff --git a/hypha/apply/projects/forms/__init__.py b/hypha/apply/projects/forms/__init__.py
index 85642d0089..c83844cdcc 100644
--- a/hypha/apply/projects/forms/__init__.py
+++ b/hypha/apply/projects/forms/__init__.py
@@ -3,6 +3,7 @@
ChangeInvoiceStatusForm,
CreateInvoiceForm,
EditInvoiceForm,
+ InvoiceTagsForm,
SelectDocumentForm,
)
from .project import (
@@ -50,4 +51,5 @@
"CreateInvoiceForm",
"ChangeInvoiceStatusForm",
"EditInvoiceForm",
+ "InvoiceTagsForm",
]
diff --git a/hypha/apply/projects/forms/payment.py b/hypha/apply/projects/forms/payment.py
index 1c4d67c613..7fffef4637 100644
--- a/hypha/apply/projects/forms/payment.py
+++ b/hypha/apply/projects/forms/payment.py
@@ -8,6 +8,7 @@
from django.utils.translation import gettext_lazy as _
from django_file_form.forms import FileFormMixin
+from hypha.apply.funds.widgets import MultiCheckboxesWidget
from hypha.apply.stream_forms.fields import MultiFileField, SingleFileField
from ..models.payment import (
@@ -22,6 +23,7 @@
RESUBMITTED,
SUBMITTED,
Invoice,
+ InvoiceTag,
SupportingDocument,
invoice_status_user_choices,
)
@@ -226,3 +228,16 @@ 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)
+
+
+class InvoiceTagsForm(forms.ModelForm):
+ tags = forms.ModelMultipleChoiceField(
+ queryset=InvoiceTag.objects.all(),
+ widget=MultiCheckboxesWidget,
+ required=False,
+ label=_("Tags"),
+ )
+
+ class Meta:
+ model = Invoice
+ fields = ["tags"]
diff --git a/hypha/apply/projects/migrations/0104_invoice_tags.py b/hypha/apply/projects/migrations/0104_invoice_tags.py
new file mode 100644
index 0000000000..0cf54673fb
--- /dev/null
+++ b/hypha/apply/projects/migrations/0104_invoice_tags.py
@@ -0,0 +1,42 @@
+# Generated by Django 5.2.15 on 2026-06-23 05:58
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("application_projects", "0103_alter_contract_options_and_more"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="InvoiceTag",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("name", models.CharField(max_length=100, unique=True)),
+ ],
+ options={
+ "verbose_name": "invoice tag",
+ "verbose_name_plural": "invoice tags",
+ "ordering": ["name"],
+ },
+ ),
+ migrations.AddField(
+ model_name="invoice",
+ name="tags",
+ field=models.ManyToManyField(
+ blank=True,
+ related_name="invoices",
+ to="application_projects.invoicetag",
+ verbose_name="tags",
+ ),
+ ),
+ ]
diff --git a/hypha/apply/projects/models/__init__.py b/hypha/apply/projects/models/__init__.py
index 181b7c93c9..50170e64ac 100644
--- a/hypha/apply/projects/models/__init__.py
+++ b/hypha/apply/projects/models/__init__.py
@@ -1,4 +1,4 @@
-from .payment import Invoice, SupportingDocument
+from .payment import Invoice, InvoiceTag, SupportingDocument
from .project import (
Contract,
ContractDocumentCategory,
@@ -28,5 +28,6 @@
"DocumentCategory",
"ContractDocumentCategory",
"Invoice",
+ "InvoiceTag",
"SupportingDocument",
]
diff --git a/hypha/apply/projects/models/payment.py b/hypha/apply/projects/models/payment.py
index 87f8106905..02eefad257 100644
--- a/hypha/apply/projects/models/payment.py
+++ b/hypha/apply/projects/models/payment.py
@@ -12,6 +12,19 @@
from hypha.apply.utils.storage import PrivateStorage
+
+class InvoiceTag(models.Model):
+ name = models.CharField(max_length=100, unique=True)
+
+ class Meta:
+ verbose_name = _("invoice tag")
+ verbose_name_plural = _("invoice tags")
+ ordering = ["name"]
+
+ def __str__(self):
+ return self.name
+
+
SUBMITTED = "submitted"
RESUBMITTED = "resubmitted"
CHANGES_REQUESTED_BY_STAFF = "changes_requested_staff"
@@ -138,6 +151,12 @@ class Invoice(models.Model):
)
status_field = State(default=SUBMITTED, states=INVOICE_STATUS_CHOICES)
requested_at = models.DateTimeField(auto_now_add=True)
+ tags = models.ManyToManyField(
+ InvoiceTag,
+ blank=True,
+ related_name="invoices",
+ verbose_name=_("tags"),
+ )
objects = InvoiceQueryset.as_manager()
wagtail_reference_index_ignore = True
diff --git a/hypha/apply/projects/tables.py b/hypha/apply/projects/tables.py
index 17bfe21ce5..5ce1ff6f86 100644
--- a/hypha/apply/projects/tables.py
+++ b/hypha/apply/projects/tables.py
@@ -55,6 +55,19 @@ class Meta:
"tabindex": "0", # Accessibility
}
+ def render_tags(self, record):
+ tags = record.tags.all()
+ if not tags:
+ return mark_safe("—")
+ badges = "".join(
+ format_html(
+ "{}",
+ tag.name,
+ )
+ for tag in tags
+ )
+ return mark_safe(f"
{badges}
")
+
def render_requested_at(self, record):
return format_html(
"{}",
@@ -101,6 +114,7 @@ def render_project(self, record):
class FinanceInvoiceTable(BaseInvoiceTable):
vendor_name = tables.Column(verbose_name=_("Vendor Name"), empty_values=())
+ tags = tables.Column(verbose_name=_("Tags"), orderable=False, empty_values=())
selected = LabeledCheckboxColumn(
accessor=A("pk"),
attrs={
@@ -118,6 +132,7 @@ class Meta(BaseInvoiceTable.Meta):
"status",
"requested_at",
"invoice_amount",
+ "tags",
]
model = Invoice
orderable = True
@@ -135,6 +150,7 @@ def render_vendor_name(self, record):
class AdminInvoiceListTable(BaseInvoiceTable):
project = tables.Column(verbose_name=_("Project Name"))
+ tags = tables.Column(verbose_name=_("Tags"), orderable=False, empty_values=())
selected = LabeledCheckboxColumn(
accessor=A("pk"),
attrs={
@@ -154,6 +170,7 @@ class Meta(BaseInvoiceTable.Meta):
"status",
"requested_at",
"project",
+ "tags",
]
model = Invoice
orderable = True
diff --git a/hypha/apply/projects/templates/application_projects/invoice_detail.html b/hypha/apply/projects/templates/application_projects/invoice_detail.html
index f790670b3b..51deb1ac30 100644
--- a/hypha/apply/projects/templates/application_projects/invoice_detail.html
+++ b/hypha/apply/projects/templates/application_projects/invoice_detail.html
@@ -49,6 +49,11 @@
{% trans "Fund" %}: {{ object.project.submission.page }}
+
diff --git a/hypha/apply/projects/templates/application_projects/modals/invoice_tag.html b/hypha/apply/projects/templates/application_projects/modals/invoice_tag.html
new file mode 100644
index 0000000000..c897b08465
--- /dev/null
+++ b/hypha/apply/projects/templates/application_projects/modals/invoice_tag.html
@@ -0,0 +1,21 @@
+{% load i18n %}
+{% trans "Update tags" %}
+
+
diff --git a/hypha/apply/projects/templates/application_projects/partials/invoice_detail_actions.html b/hypha/apply/projects/templates/application_projects/partials/invoice_detail_actions.html
index 37eb2a1027..579702619f 100644
--- a/hypha/apply/projects/templates/application_projects/partials/invoice_detail_actions.html
+++ b/hypha/apply/projects/templates/application_projects/partials/invoice_detail_actions.html
@@ -47,6 +47,18 @@
{% endif %}
+
+ {% if user.is_apply_staff or user.is_finance %}
+
+ {% endif %}
+
{% can_delete object user as user_can_delete_request %}
{% if user_can_delete_request %}
diff --git a/hypha/apply/projects/templates/application_projects/partials/invoice_tags.html b/hypha/apply/projects/templates/application_projects/partials/invoice_tags.html
new file mode 100644
index 0000000000..1eb1064452
--- /dev/null
+++ b/hypha/apply/projects/templates/application_projects/partials/invoice_tags.html
@@ -0,0 +1,6 @@
+{% load i18n %}
+{% with tags=object.tags.all %}
+ {% if tags %}
+ {% trans "Tags" %}: {{ tags|join:", " }}
+ {% endif %}
+{% endwith %}
diff --git a/hypha/apply/projects/urls.py b/hypha/apply/projects/urls.py
index c5cdc419cd..2c545d8adf 100644
--- a/hypha/apply/projects/urls.py
+++ b/hypha/apply/projects/urls.py
@@ -31,6 +31,7 @@
SendForApprovalView,
SkipPAFApprovalProcessView,
SubmitContractDocumentsView,
+ TagInvoiceView,
UpdateAssignApproversView,
UpdateLeadView,
UpdatePAFApproversView,
@@ -41,6 +42,7 @@
partial_get_invoice_detail_actions,
partial_get_invoice_status,
partial_get_invoice_status_table,
+ partial_get_invoice_tags,
partial_project_information,
partial_project_lead,
partial_project_title,
@@ -252,6 +254,16 @@
partial_get_invoice_detail_actions,
name="partial-invoice-detail-actions",
),
+ path(
+ "tags/",
+ TagInvoiceView.as_view(),
+ name="invoice-tags",
+ ),
+ path(
+ "partial/tags/",
+ partial_get_invoice_tags,
+ name="partial-invoice-tags",
+ ),
path(
"documents/invoice/",
InvoicePrivateMedia.as_view(),
diff --git a/hypha/apply/projects/views/__init__.py b/hypha/apply/projects/views/__init__.py
index 84396463fe..b18bb2df5e 100644
--- a/hypha/apply/projects/views/__init__.py
+++ b/hypha/apply/projects/views/__init__.py
@@ -7,6 +7,7 @@
InvoiceListView,
InvoicePrivateMedia,
InvoiceView,
+ TagInvoiceView,
)
from .project import (
AdminProjectDetailView,
@@ -45,6 +46,7 @@
partial_get_invoice_detail_actions,
partial_get_invoice_status,
partial_get_invoice_status_table,
+ partial_get_invoice_tags,
partial_project_information,
partial_project_lead,
partial_project_title,
@@ -59,6 +61,7 @@
"partial_get_invoice_status_table",
"partial_get_invoice_status",
"partial_get_invoice_detail_actions",
+ "partial_get_invoice_tags",
"partial_contracting_documents",
"BatchUpdateInvoiceStatusView",
"ChangeInvoiceStatusView",
@@ -98,4 +101,5 @@
"EditInvoiceView",
"DeleteInvoiceView",
"InvoicePrivateMedia",
+ "TagInvoiceView",
]
diff --git a/hypha/apply/projects/views/payment.py b/hypha/apply/projects/views/payment.py
index 8e4c521a3d..393ee66b54 100644
--- a/hypha/apply/projects/views/payment.py
+++ b/hypha/apply/projects/views/payment.py
@@ -61,6 +61,7 @@
ChangeInvoiceStatusForm,
CreateInvoiceForm,
EditInvoiceForm,
+ InvoiceTagsForm,
)
from ..models.payment import (
APPROVED_BY_FINANCE,
@@ -588,9 +589,54 @@ class InvoiceListView(SingleTableMixin, FilterView, DelegateableListView):
template_name = "application_projects/invoice_list.html"
def get_queryset(self):
- return super().get_queryset().select_related("project", "project__user")
+ return (
+ super()
+ .get_queryset()
+ .select_related("project", "project__user")
+ .prefetch_related("tags")
+ )
def get_table_class(self):
if self.request.user.is_finance:
return FinanceInvoiceTable
return super().get_table_class()
+
+
+@method_decorator(staff_or_finance_required, name="dispatch")
+class TagInvoiceView(InvoiceAccessMixin, View):
+ form_class = InvoiceTagsForm
+ template_name = "application_projects/modals/invoice_tag.html"
+
+ def dispatch(self, request, *args, **kwargs):
+ self.object = get_object_or_404(Invoice, id=kwargs.get("invoice_pk"))
+ return super().dispatch(request, *args, **kwargs)
+
+ def get(self, *args, **kwargs):
+ form = self.form_class(instance=self.object)
+ return render(
+ self.request,
+ self.template_name,
+ {"form": form, "object": self.object},
+ )
+
+ def post(self, *args, **kwargs):
+ form = self.form_class(self.request.POST, instance=self.object)
+ if form.is_valid():
+ form.save()
+ return HttpResponse(
+ status=204,
+ headers={
+ "HX-Trigger": json.dumps(
+ {
+ "invoicesUpdated": None,
+ "showMessage": _("Invoice tags updated."),
+ }
+ )
+ },
+ )
+ return render(
+ self.request,
+ self.template_name,
+ {"form": form, "object": self.object},
+ status=400,
+ )
diff --git a/hypha/apply/projects/views/project_partials.py b/hypha/apply/projects/views/project_partials.py
index 3f9c00433f..6c63fcf366 100644
--- a/hypha/apply/projects/views/project_partials.py
+++ b/hypha/apply/projects/views/project_partials.py
@@ -157,3 +157,13 @@ def partial_get_invoice_detail_actions(request: HttpRequest, pk: int, invoice_pk
"application_projects/partials/invoice_detail_actions.html",
context={"object": invoice, "user": user},
)
+
+
+@login_required
+def partial_get_invoice_tags(request: HttpRequest, pk: int, invoice_pk: int):
+ invoice = get_object_or_404(Invoice, pk=invoice_pk)
+ return render(
+ request,
+ "application_projects/partials/invoice_tags.html",
+ context={"object": invoice},
+ )
diff --git a/hypha/templates/base.html b/hypha/templates/base.html
index 6b4392b606..3a7ecc75ed 100644
--- a/hypha/templates/base.html
+++ b/hypha/templates/base.html
@@ -142,6 +142,7 @@
shouldSort: false,
allowHTML: true,
removeItemButton: true,
+ placeholderValue: selectElement.dataset.placeholder || "",
});
selectElement.hasChoicesInstance = true;
};
@@ -149,7 +150,7 @@
const choicesElements = document.querySelectorAll(".choices__input--cloned")
choicesElements.forEach((choiceElement) => {
- const inputPlaceholder = choiceElement.getAttribute("placeholder");
+ const inputPlaceholder = choiceElement.getAttribute("placeholder") ?? "";
// Get the computed min-width of the input element otherwise it reset to 1
const minWidth = window.getComputedStyle(choiceElement).getPropertyValue("min-width");
choiceElement.addEventListener("focus", () => {