diff --git a/commcare_connect/opportunity/helpers.py b/commcare_connect/opportunity/helpers.py
index 548264739..1699177ab 100644
--- a/commcare_connect/opportunity/helpers.py
+++ b/commcare_connect/opportunity/helpers.py
@@ -1,14 +1,35 @@
from collections import namedtuple
+from datetime import timedelta
-from django.db.models import Case, Count, F, Max, Min, Q, Sum, Value, When
+from django.db.models import (
+ Case,
+ Count,
+ DecimalField,
+ Exists,
+ ExpressionWrapper,
+ F,
+ IntegerField,
+ Max,
+ Min,
+ OuterRef,
+ Q,
+ Sum,
+ Value,
+ When,
+)
+from django.db.models.functions import Coalesce
+from django.utils.timezone import now
from commcare_connect.opportunity.models import (
+ CompletedModule,
CompletedWork,
CompletedWorkStatus,
Opportunity,
OpportunityAccess,
PaymentUnit,
UserInvite,
+ UserInviteStatus,
+ UserVisit,
VisitValidationStatus,
)
@@ -151,3 +172,76 @@ def get_payment_report_data(opportunity: Opportunity):
PaymentReportData(payment_unit.name, completed_work_count, user_payment_accrued, nm_payment_accrued)
)
return data, total_user_payment_accrued, total_nm_payment_accrued
+
+
+def get_opportunity_list_data(organization, program_manager=False):
+ today = now().date()
+ three_days_ago = now() - timedelta(days=3)
+
+ base_filter = Q(organization=organization)
+ if program_manager:
+ base_filter |= Q(managedopportunity__program__organization=organization)
+
+ queryset = Opportunity.objects.filter(base_filter).annotate(
+ program=F("managedopportunity__program__name"),
+ pending_invites=Count(
+ "userinvite",
+ filter=~Q(userinvite__status=UserInviteStatus.accepted),
+ distinct=True,
+ ),
+ pending_approvals=Count(
+ "uservisit",
+ filter=Q(uservisit__status=VisitValidationStatus.pending),
+ distinct=True,
+ ),
+ total_accrued=Coalesce(
+ Sum("opportunityaccess__payment_accrued", distinct=True), Value(0), output_field=DecimalField()
+ ),
+ total_paid=Coalesce(
+ Sum(
+ "opportunityaccess__payment__amount",
+ filter=Q(opportunityaccess__payment__confirmed=True),
+ distinct=True,
+ ),
+ Value(0),
+ output_field=DecimalField(),
+ ),
+ payments_due=ExpressionWrapper(
+ F("total_accrued") - F("total_paid"),
+ output_field=DecimalField(),
+ ),
+ inactive_workers=Count(
+ "opportunityaccess",
+ filter=Q(
+ ~Exists(
+ UserVisit.objects.filter(
+ opportunity_access=OuterRef("opportunityaccess"),
+ visit_date__gte=three_days_ago,
+ )
+ )
+ & ~Exists(
+ CompletedModule.objects.filter(
+ opportunity_access=OuterRef("opportunityaccess"),
+ date__gte=three_days_ago,
+ )
+ )
+ ),
+ distinct=True,
+ ),
+ status=Case(
+ When(Q(active=True) & Q(end_date__gte=today), then=Value(0)), # Active
+ When(Q(active=True) & Q(end_date__lt=today), then=Value(1)), # Ended
+ default=Value(2), # Inactive
+ output_field=IntegerField(),
+ ),
+ )
+
+ if program_manager:
+ queryset = queryset.annotate(
+ total_workers=Count("opportunityaccess", distinct=True),
+ active_workers=F("total_workers") - F("inactive_workers"),
+ total_deliveries=Sum("opportunityaccess__completedwork__saved_completed_count", distinct=True),
+ verified_deliveries=Sum("opportunityaccess__completedwork__saved_approved_count", distinct=True)
+ )
+
+ return queryset
diff --git a/commcare_connect/opportunity/tables.py b/commcare_connect/opportunity/tables.py
index cd3800e70..844c4cf1b 100644
--- a/commcare_connect/opportunity/tables.py
+++ b/commcare_connect/opportunity/tables.py
@@ -1,10 +1,14 @@
+import itertools
+
+import django_tables2 as tables
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Column, Layout, Row
+from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django_filters import ChoiceFilter, DateRangeFilter, FilterSet, ModelChoiceFilter
-from django_tables2 import columns, tables, utils
+from django_tables2 import columns, utils
from commcare_connect.opportunity.models import (
CatchmentArea,
@@ -549,3 +553,287 @@ def date_with_time_popup(table, date):
date.strftime("%d %b, %Y"),
date.strftime("%d %b %Y, %I:%M%p"),
)
+
+
+def header_with_tooltip(label, tooltip_text):
+ return mark_safe(
+ f"""
+
+ """
+ )
+
+
+class IndexColumn(tables.Column):
+ def __init__(self, *args, **kwargs):
+ kwargs.setdefault("verbose_name", "#")
+ kwargs.setdefault("orderable", False)
+ kwargs.setdefault("empty_values", ())
+ super().__init__(*args, **kwargs)
+
+ def render(self, value, record, bound_column, bound_row, **kwargs):
+ table = bound_row._table
+ page = getattr(table, "page", None)
+ if page:
+ start_index = (page.number - 1) * page.paginator.per_page + 1
+ else:
+ start_index = 1
+ if not hasattr(table, "_row_counter") or getattr(table, "_row_counter_start", None) != start_index:
+ table._row_counter = itertools.count(start=start_index)
+ table._row_counter_start = start_index
+ value = next(table._row_counter)
+ return value
+
+
+class BaseOpportunityList(tables.Table):
+ stats_style = "underline underline-offset-2 justify-center"
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.use_view_url = False
+
+ index = IndexColumn()
+ opportunity = tables.Column(accessor="name")
+ entity_type = tables.TemplateColumn(
+ verbose_name="",
+ orderable=False,
+ template_code="""
+
+ {% if record.is_test %}
+
+
+
+ Test Opportunity
+
+
+ {% else %}
+
+
+
+ {% endif %}
+
+ """,
+ )
+
+ status = tables.Column(verbose_name="Status", accessor="status", orderable=True)
+
+ program = tables.Column()
+ start_date = tables.DateColumn(format="d-M-Y")
+ end_date = tables.DateColumn(format="d-M-Y")
+
+ class Meta:
+ sequence = (
+ "index",
+ "opportunity",
+ "entity_type",
+ "status",
+ "program",
+ "start_date",
+ "end_date",
+ )
+
+ def render_status(self, value):
+ if value == 0:
+ badge_class = "badge badge-sm bg-green-600/20 text-green-600"
+ text = "Active"
+ elif value == 1:
+ badge_class = "badge badge-sm bg-orange-600/20 text-orange-600"
+ text = "Ended"
+ else:
+ badge_class = "badge badge-sm bg-slate-100 text-slate-400"
+ text = "Inactive"
+
+ return format_html(
+ ''
+ ' {}'
+ "
",
+ badge_class,
+ text,
+ )
+
+ def format_date(self, date):
+ return date.strftime("%d-%b-%Y") if date else '--'
+
+
+ def _render_div(self, value, extra_classes=""):
+ base_classes = "flex text-sm font-normal truncate text-brand-deep-purple " "overflow-clip overflow-ellipsis"
+ all_classes = f"{base_classes} {extra_classes}".strip()
+ return format_html('{}
', all_classes, value)
+
+ def render_opportunity(self, value):
+ return self._render_div(value, extra_classes="justify-start")
+
+ def render_program(self, value):
+ return self._render_div(value if value else "--", extra_classes="justify-start")
+
+ def render_start_date(self, value):
+ return self._render_div(self.format_date(value), extra_classes="justify-center")
+
+ def render_end_date(self, value):
+ return self._render_div(self.format_date(value), extra_classes="justify-center")
+
+
+class OpportunityTable(BaseOpportunityList):
+ pending_invites = tables.Column()
+ inactive_workers = tables.Column()
+ pending_approvals = tables.Column()
+ payments_due = tables.Column()
+ actions = tables.Column(empty_values=(), orderable=False, verbose_name="")
+
+ class Meta(BaseOpportunityList.Meta):
+ sequence = BaseOpportunityList.Meta.sequence + (
+ "pending_invites",
+ "inactive_workers",
+ "pending_approvals",
+ "payments_due",
+ "actions",
+ )
+
+ def render_pending_invites(self, value):
+ return self._render_div(value, extra_classes=self.stats_style)
+
+ def render_inactive_workers(self, value):
+ return self._render_div(value, extra_classes=self.stats_style)
+
+ def render_pending_approvals(self, value):
+ return self._render_div(value, extra_classes=self.stats_style)
+
+ def render_payments_due(self, value):
+ if value is None:
+ value = 0
+ return self._render_div(value, extra_classes=self.stats_style)
+
+ def render_actions(self, record):
+ actions = [
+ {
+ "title": "View Opportunity",
+ "url": reverse("opportunity:detail", args=[record.organization.slug, record.id]),
+ },
+ {
+ "title": "View Workers",
+ "url": reverse("opportunity:detail", args=[record.organization.slug, record.id]),
+ # "url": reverse("opportunity:tw_worker_list", args=[record.organization.slug, record.id]),
+ },
+ ]
+
+ if record.managed:
+ actions.append(
+ {
+ "title": "View Invoices",
+ "url": reverse("opportunity:detail", args=[record.organization.slug, record.id]),
+ # "url": reverse("opportunity:tw_invoice_list", args=[record.organization.slug, record.id]),
+ }
+ )
+
+ html = render_to_string(
+ "tailwind/components/dropdowns/text_button_dropdown.html",
+ context={
+ "text": "...",
+ "list": actions,
+ "styles": "text-sm",
+ },
+ )
+ return mark_safe(html)
+
+
+class ProgramManagerOpportunityTable(BaseOpportunityList):
+ active_workers = tables.Column(
+ verbose_name="Active Workers"
+ )
+ total_deliveries = tables.Column(
+ verbose_name="Total Deliveries"
+ )
+ verified_deliveries = tables.Column(
+ verbose_name="Verified Deliveries"
+ )
+ worker_earnings = tables.Column(verbose_name="Worker Earnings", accessor="total_accrued")
+ actions = tables.Column(empty_values=(), orderable=False, verbose_name="")
+
+ class Meta(BaseOpportunityList.Meta):
+ sequence = BaseOpportunityList.Meta.sequence + (
+ "active_workers",
+ "total_deliveries",
+ "verified_deliveries",
+ "worker_earnings",
+ "actions",
+ )
+
+ def render_active_workers(self, value):
+ return self._render_div(value, extra_classes=self.stats_style)
+
+ def render_total_deliveries(self, value):
+ return self._render_div(value, extra_classes=self.stats_style)
+
+ def render_verified_deliveries(self, value):
+ return self._render_div(value, extra_classes=self.stats_style)
+
+ def render_worker_earnings(self, value):
+ return self._render_div(value, extra_classes=self.stats_style)
+
+ def render_opportunity(self, record):
+ html = format_html(
+ """
+
+ """,
+ record.name,
+ record.organization.name,
+ )
+ return html
+
+ def render_actions(self, record):
+ actions = [
+ {
+ "title": "View Opportunity",
+ "url": reverse("opportunity:detail", args=[record.organization.slug, record.id]),
+ },
+ {
+ "title": "View Workers",
+ "url": reverse("opportunity:detail", args=[record.organization.slug, record.id]),
+ # "url": reverse("opportunity:tw_worker_list", args=[record.organization.slug, record.id]),
+ },
+ ]
+
+ if record.managed:
+ actions.append(
+ {
+ "title": "View Invoices",
+ "url": reverse("opportunity:detail", args=[record.organization.slug, record.id]),
+ # "url": reverse("opportunity:tw_invoice_list", args=[record.organization.slug, record.id]),
+ }
+ )
+
+ html = render_to_string(
+ "tailwind/components/dropdowns/text_button_dropdown.html",
+ context={
+ "text": "...",
+ "list": actions,
+ "styles": "text-sm",
+ },
+ )
+ return mark_safe(html)
diff --git a/commcare_connect/opportunity/tests/test_views.py b/commcare_connect/opportunity/tests/test_views.py
index 8e7fecea0..da58e180b 100644
--- a/commcare_connect/opportunity/tests/test_views.py
+++ b/commcare_connect/opportunity/tests/test_views.py
@@ -1,3 +1,5 @@
+from datetime import timedelta
+from decimal import Decimal
from http import HTTPStatus
import pytest
@@ -5,10 +7,12 @@
from django.urls import reverse
from django.utils.timezone import now
+from commcare_connect.opportunity.helpers import get_opportunity_list_data
from commcare_connect.opportunity.models import (
Opportunity,
OpportunityAccess,
OpportunityClaimLimit,
+ UserInviteStatus,
UserVisit,
VisitReviewStatus,
VisitValidationStatus,
@@ -17,7 +21,9 @@
OpportunityAccessFactory,
OpportunityClaimFactory,
OpportunityClaimLimitFactory,
+ PaymentFactory,
PaymentUnitFactory,
+ UserInviteFactory,
UserVisitFactory,
)
from commcare_connect.organization.models import Organization
@@ -215,3 +221,59 @@ def test_approve_visit(
)
assert response.redirect_chain[-1][0] == expected_redirect_url
assert response.status_code == HTTPStatus.OK
+
+
+@pytest.mark.django_db
+def test_get_opportunity_list_data_all_annotations(opportunity):
+ today = now().date()
+ three_days_ago = now() - timedelta(days=3)
+
+ opportunity.end_date = today + timedelta(days=1)
+ opportunity.active = True
+ opportunity.save()
+
+ # Create OpportunityAccesses
+ oa1 = OpportunityAccessFactory(opportunity=opportunity, accepted=True, payment_accrued=300)
+ oa2 = OpportunityAccessFactory(opportunity=opportunity, accepted=True, payment_accrued=200)
+ oa3 = OpportunityAccessFactory(opportunity=opportunity, accepted=True, payment_accrued=0)
+
+ # Payments
+ PaymentFactory(opportunity_access=oa1, amount_usd=100, confirmed=True)
+ PaymentFactory(opportunity_access=oa2, amount_usd=50, confirmed=True)
+ PaymentFactory(opportunity_access=oa1, amount_usd=999, confirmed=False)
+ PaymentFactory(opportunity_access=oa3, amount_usd=0, confirmed=True)
+
+ # Invites
+ for _ in range(3):
+ UserInviteFactory(opportunity=opportunity, status=UserInviteStatus.invited)
+ UserInviteFactory(opportunity=opportunity, status=UserInviteStatus.accepted)
+
+ # Visits
+ UserVisitFactory(
+ opportunity=opportunity, opportunity_access=oa1, status=VisitValidationStatus.pending, visit_date=now()
+ )
+
+ UserVisitFactory(
+ opportunity=opportunity,
+ opportunity_access=oa2,
+ status=VisitValidationStatus.approved,
+ visit_date=three_days_ago - timedelta(days=1),
+ )
+
+ UserVisitFactory(
+ opportunity=opportunity,
+ opportunity_access=oa3,
+ status=VisitValidationStatus.rejected,
+ visit_date=three_days_ago - timedelta(days=1),
+ )
+
+ queryset = get_opportunity_list_data(opportunity.organization)
+ opp = queryset[0]
+
+ assert opp.pending_invites == 3
+ assert opp.pending_approvals == 1
+ assert opp.total_accrued == Decimal("500")
+ assert opp.total_paid == Decimal("150")
+ assert opp.payments_due == Decimal("350")
+ assert opp.inactive_workers == 2
+ assert opp.status == 0
diff --git a/commcare_connect/opportunity/views.py b/commcare_connect/opportunity/views.py
index 4d0ad1fdb..3308ffe0b 100644
--- a/commcare_connect/opportunity/views.py
+++ b/commcare_connect/opportunity/views.py
@@ -22,8 +22,8 @@
from django.utils.translation import gettext as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET, require_http_methods, require_POST
-from django.views.generic import CreateView, DetailView, ListView, UpdateView
-from django_tables2 import RequestConfig, SingleTableView
+from django.views.generic import CreateView, DetailView, TemplateView, UpdateView
+from django_tables2 import RequestConfig, SingleTableMixin, SingleTableView
from django_tables2.export import TableExport
from geopy import distance
@@ -53,6 +53,7 @@
from commcare_connect.opportunity.helpers import (
get_annotated_opportunity_access,
get_annotated_opportunity_access_deliver_status,
+ get_opportunity_list_data,
get_payment_report_data,
)
from commcare_connect.opportunity.models import (
@@ -82,9 +83,11 @@
DeliverStatusTable,
LearnStatusTable,
OpportunityPaymentTable,
+ OpportunityTable,
PaymentInvoiceTable,
PaymentReportTable,
PaymentUnitTable,
+ ProgramManagerOpportunityTable,
SuspendedUsersTable,
UserPaymentsTable,
UserStatusTable,
@@ -119,8 +122,7 @@
update_payment_accrued,
)
from commcare_connect.organization.decorators import org_admin_required, org_member_required, org_viewer_required
-from commcare_connect.program.models import ManagedOpportunity, ProgramApplication
-from commcare_connect.program.tables import ProgramInvitationTable
+from commcare_connect.program.models import ManagedOpportunity
from commcare_connect.users.models import User
from commcare_connect.utils.commcarehq_api import get_applications_for_user_by_domain, get_domains_for_user
@@ -156,28 +158,21 @@ def get_table_kwargs(self):
return kwargs
-class OpportunityList(OrganizationUserMixin, ListView):
- model = Opportunity
- paginate_by = 10
+class OpportunityList(OrganizationUserMixin, SingleTableMixin, TemplateView):
+ template_name = "tailwind/pages/opportunities_list.html"
+ paginate_by = 15
- def get_queryset(self):
- ordering = self.request.GET.get("sort", "name")
- if ordering not in ["name", "-name", "start_date", "-start_date", "end_date", "-end_date"]:
- ordering = "name"
- return Opportunity.objects.filter(organization=self.request.org).order_by(ordering)
+ def get_table_class(self):
+ if self.request.org.program_manager:
+ return ProgramManagerOpportunityTable
+ return OpportunityTable
- def get_context_data(self, **kwargs):
- context = super().get_context_data(**kwargs)
- context["opportunity_init_url"] = reverse("opportunity:init", kwargs={"org_slug": self.request.org.slug})
-
- program_invitation_table = None
- if self.request.org_membership and self.request.org_membership.is_admin or self.request.user.is_superuser:
- program_invitations = ProgramApplication.objects.filter(organization=self.request.org)
- program_invitation_table = ProgramInvitationTable(program_invitations)
- context["program_invitation_table"] = program_invitation_table
- context["base_template"] = "opportunity/base.html"
- return context
+ def get_table_data(self):
+ org = self.request.org
+ query_set = get_opportunity_list_data(org, self.request.org.program_manager)
+ query_set = query_set.order_by("status", "start_date", "end_date")
+ return query_set
class OpportunityCreate(OrganizationUserMemberRoleMixin, CreateView):
diff --git a/commcare_connect/templates/opportunity/opportunity_list.html b/commcare_connect/templates/opportunity/opportunity_list.html
deleted file mode 100644
index 04be5a476..000000000
--- a/commcare_connect/templates/opportunity/opportunity_list.html
+++ /dev/null
@@ -1,117 +0,0 @@
-{% extends base_template %}
-{% load static %}
-{% load sort_link %}
-{% load i18n %}
-{% load django_tables2 %}
-{% block title %}{{ request.org }} - Opportunities{% endblock %}
-
-{% block breadcrumbs_inner %}
- {{ block.super }}
- {% if program %}
- {{ program.name }}
- {% endif %}
-{% endblock %}
-
-
-
-{% block content %}
-
-
-
Opportunities
-
- {% if request.org_membership.is_viewer %}
-
- {% else %}
- Add new
-
- {% endif %}
-
-
-
-
-
-
-
- {% sort_link 'name' 'Name' %} |
- {% sort_link 'start_date' 'Start Date' %} |
- {% sort_link 'end_date' 'End Date' %} |
- Status |
- Program |
- Manage |
-
-
-
- {% for opportunity in page_obj %}
-
- {{ opportunity.name }} |
- {{ opportunity.start_date|default:"Not Set" }} |
- {{ opportunity.end_date|default:"Not Set" }} |
-
- {% if opportunity.is_setup_complete %}
- {% if opportunity.is_active %}
- Active
- {% else %}
- Inactive
- {% endif %}
- {% else %}
- Pending Setup
- {% endif %}
- |
- {% if opportunity.managed %} {{ opportunity.managedopportunity.program.name }} {% else %} - {% endif %} |
-
-
-
- View
-
- {% if request.org_membership.is_viewer %}
-
- {% if not opportunity.managed %}
-
- {% endif %}
- {% else %}
- Edit
- {% if not opportunity.managed %}
- Add Budget
-
- {% endif %}
- {% endif %}
-
- |
-
-
- {% empty %}
-
- {% translate "No opportunities yet." %} |
-
- {% endfor %}
-
-
-
- {% include 'pagination.html' %}
-
-
-{% if program_invitation_table and program_invitation_table.data %}
-
-
-
{% translate "Program Invitations" %}
-
-
- {% render_table program_invitation_table %}
-
-
-{% endif %}
-{% endblock content %}
diff --git a/commcare_connect/templates/tailwind/base_table.html b/commcare_connect/templates/tailwind/base_table.html
new file mode 100644
index 000000000..aa24cb774
--- /dev/null
+++ b/commcare_connect/templates/tailwind/base_table.html
@@ -0,0 +1,115 @@
+{% load django_tables2 %}
+{% load i18n %}
+{% block table-wrapper %}
+{% load sort_link %}
+
+ {% block table %}
+
+
+ {% block table.thead %}
+ {% if table.show_header %}
+
+
+ {% for column in table.columns %}
+
+ {% if column.orderable %}
+ {% sortable_header column.name column.header table.use_view_url %}
+ {% else %}
+ {{ column.header }}
+ {% endif %}
+ |
+ {% endfor %}
+
+
+ {% endif %}
+ {% endblock table.thead %}
+
+ {% block table.tbody %}
+
+
+ {% for row in table.paginated_rows %}
+ {% block table.tbody.row %}
+
+ {% for column, cell in row.items %}
+
+ {% if column.localize == None %}
+ {{ cell }}
+ {% else %}
+ {% if column.localize %}
+ {{ cell|localize }}
+ {% else %}
+ {{ cell|unlocalize }}
+ {% endif %}
+ {% endif %}
+ |
+ {% endfor %}
+
+ {% endblock table.tbody.row %}
+ {% empty %}
+ {% if table.empty_text %}
+ {% block table.tbody.empty_text %}
+
+
+ {{ table.empty_text }}
+ |
+
+ {% endblock table.tbody.empty_text %}
+ {% endif %}
+ {% endfor %}
+
+
+ {% endblock table.tbody %}
+
+
+
+ {% endblock table %}
+ {% block pagination %}
+ {% if table.page and table.paginator.num_pages > 1 %}
+
+
+ {% if table.page.has_previous %}
+
+
+
+ {% else %}
+
+
+
+ {% endif %}
+
+
{% trans "Page" %}
+
of {{ table.paginator.num_pages }}
+
+ {% if table.page.has_next %}
+
+
+
+ {% else %}
+
+
+
+ {% endif %}
+
+
+ {% endif %}
+
+ {% endblock pagination %}
+
+{% endblock table-wrapper %}
diff --git a/commcare_connect/templates/tailwind/components/dropdowns/text_button_dropdown.html b/commcare_connect/templates/tailwind/components/dropdowns/text_button_dropdown.html
new file mode 100644
index 000000000..0e7e3e330
--- /dev/null
+++ b/commcare_connect/templates/tailwind/components/dropdowns/text_button_dropdown.html
@@ -0,0 +1,74 @@
+
+ {{text}}
+
+
+
+
{{ title|default:'' }}
+
+
+
diff --git a/commcare_connect/templates/tailwind/layouts/sidenav.html b/commcare_connect/templates/tailwind/layouts/sidenav.html
index d77ce6c92..24d00aaa7 100644
--- a/commcare_connect/templates/tailwind/layouts/sidenav.html
+++ b/commcare_connect/templates/tailwind/layouts/sidenav.html
@@ -41,10 +41,15 @@
- {% url 'program:home' request.org.slug as program_home_url %}
+ {% url 'program:list' request.org.slug as program_home_url %}
{% include "tailwind/components/sidenav-items.html" with name='Programs' icon='table-columns' target=program_home_url %}
- {% include "tailwind/components/sidenav-items.html" with name='Opportunities' icon='table-cells-large' target='/opportunities' %}
- {% include "tailwind/components/sidenav-items.html" with name='My Organization' icon='buildings' target='/organization' %}
+
+ {% url 'opportunity:list' request.org.slug as opportunity_url %}
+ {% include "tailwind/components/sidenav-items.html" with name='Opportunities' icon='table-cells-large' target=opportunity_url %}
+
+ {% url 'organization:home' request.org.slug as organization_home_url %}
+ {% include "tailwind/components/sidenav-items.html" with name='My Organization' icon='buildings' target=organization_home_url %}
+
diff --git a/commcare_connect/templates/tailwind/pages/opportunities_list.html b/commcare_connect/templates/tailwind/pages/opportunities_list.html
new file mode 100644
index 000000000..1ae632385
--- /dev/null
+++ b/commcare_connect/templates/tailwind/pages/opportunities_list.html
@@ -0,0 +1,29 @@
+{% extends 'tailwind/base.html' %}
+{% load render_table from django_tables2 %}
+{% block content %}
+{% load i18n %}
+
+
+
+
{% translate "Opportunities List" %}
+
+
+
+
+
+
+
+ {% render_table table %}
+
+
+
+
+{% endblock %}
diff --git a/commcare_connect/web/templatetags/sort_link.py b/commcare_connect/web/templatetags/sort_link.py
index f7e1bc1bf..0980ec975 100644
--- a/commcare_connect/web/templatetags/sort_link.py
+++ b/commcare_connect/web/templatetags/sort_link.py
@@ -1,5 +1,8 @@
+from urllib.parse import parse_qs, urlencode, urlparse
+
from django import template
from django.utils.html import format_html
+from django.utils.safestring import mark_safe
register = template.Library()
@@ -46,3 +49,46 @@ def update_query_params(context, **kwargs):
updated[key] = value
return updated.urlencode()
+
+
+@register.simple_tag(takes_context=True)
+def sortable_header(context, field, label, use_view_url=True):
+ request = context["request"]
+ current_sort = next_sort = None
+ icon_element = ''
+
+ if use_view_url:
+ referer = request.headers.get("referer", request.get_full_path())
+ parsed_url = urlparse(referer)
+ query_params = parse_qs(parsed_url.query)
+ path = parsed_url.path
+ current_sort = query_params.get("sort", [""])[0]
+
+ else:
+ path = request.path
+ query_params = request.GET.copy()
+ current_sort = query_params.get("sort", "")
+
+ if current_sort == field:
+ next_sort = f"-{field}"
+ icon_element = icon_element.format("fa-sort-asc text-brand-deep-purple")
+ elif current_sort == f"-{field}":
+ next_sort = ""
+ icon_element = icon_element.format("fa-sort-desc text-brand-deep-purple")
+ else:
+ next_sort = field
+ icon_element = icon_element.format("fa-sort text-gray-400")
+
+ if next_sort:
+ query_params["sort"] = next_sort
+ else:
+ query_params.pop("sort", None)
+
+ query_string = urlencode(query_params, doseq=True)
+ url = f"{path}?{query_string}" if query_string else path
+
+ return format_html(
+ '{}',
+ url,
+ mark_safe(f"{label} {icon_element}"),
+ )
diff --git a/config/settings/base.py b/config/settings/base.py
index c2ddd7feb..1311142a6 100644
--- a/config/settings/base.py
+++ b/config/settings/base.py
@@ -330,7 +330,7 @@
}
}
-DJANGO_TABLES2_TEMPLATE = "tables/tabbed_table.html"
+DJANGO_TABLES2_TEMPLATE = "tailwind/base_table.html"
DJANGO_TABLES2_TABLE_ATTRS = {
"class": "table table-bordered mb-0",
"thead": {
diff --git a/tailwind/tailwind.css b/tailwind/tailwind.css
index c1db7e398..549e9d6c1 100644
--- a/tailwind/tailwind.css
+++ b/tailwind/tailwind.css
@@ -1,10 +1,11 @@
@import 'tailwindcss';
@tailwind utilities;
+@source "./safelist.txt";
+
[x-cloak] {
display: none !important;
}
-
@theme {
--font-sans: 'Work Sans', 'sans-serif';
--breakpoint-sm: 5rem;
@@ -20,6 +21,23 @@
--color-brand-border-light: #e2e8f0;
}
+/* ------ Typography------ */
+
+.card_title {
+ @apply text-brand-deep-purple text-lg font-medium;
+}
+
+.card_description {
+ @apply text-sm font-normal text-gray-600;
+}
+.title {
+ @apply font-medium text-gray-900;
+}
+
+.hint {
+ @apply text-xs text-gray-400;
+}
+
/*-- status ----------------------------------*/
.status-active {
@apply h-2 w-4 rounded-full bg-green-600;
@@ -37,6 +55,202 @@
@apply bg-brand-marigold h-2 w-4 rounded-full;
}
+/*-- badges --------------------------------------*/
+.badge {
+ @apply inline-block text-nowrap rounded-full text-xs;
+}
+
+/* Size Variants */
+.badge-sm {
+ @apply px-2 py-0.5;
+}
+
+.badge-md {
+ @apply px-4 py-1;
+}
+
+/*-- buttons -------------------------------------*/
+
+.button {
+ @apply relative inline-block min-w-24 cursor-pointer space-x-2 text-nowrap rounded-lg text-sm font-medium transition-all duration-300;
+}
+
+.button:hover::after {
+ content: '';
+ @apply bg-brand-deep-purple/10 absolute left-0 top-0 z-0 h-full w-full rounded-lg;
+}
+
+.button-outline-rounded {
+ @apply text-brand-deep-purple rounded-full border border-gray-200;
+}
+
+.button-outline-rounded:not(:disabled):hover::after {
+ content: '';
+ @apply bg-brand-deep-purple/10 absolute left-0 top-0 z-0 h-full w-full rounded-full;
+}
+
+@layer components {
+ .button-icon {
+ @apply text-brand-deep-purple inline-flex size-8 cursor-pointer items-center justify-center rounded-full p-2;
+ }
+
+ .button-icon:hover {
+ @apply bg-slate-100;
+ }
+
+ .button-icon:focus {
+ @apply bg-brand-mango/10;
+ }
+}
+
+@layer components {
+ .button-icon-activatable {
+ @apply text-brand-deep-purple inline-flex size-8 cursor-pointer items-center justify-center rounded-full p-2;
+ }
+
+ .button-icon-activatable:focus {
+ @apply bg-brand-mango/10;
+ }
+}
+
+@layer components {
+ .button-text {
+ @apply text-brand-deep-purple flex cursor-pointer items-center gap-2 rounded-full px-4 py-1;
+ }
+
+ .button-text i {
+ @apply text-xs text-gray-400;
+ }
+
+ .button-text:hover {
+ @apply bg-gray-50;
+ }
+}
+
+/* Size Variants */
+
+.button-sm {
+ @apply px-4 py-1;
+}
+
+.button-md {
+ @apply px-4 py-3;
+}
+
+/* Dark Theme */
+.primary-dark {
+ @apply bg-brand-indigo text-white;
+}
+
+.positive-dark {
+ @apply bg-green-600 text-white;
+}
+
+.negative-dark {
+ @apply bg-red-600 text-white;
+}
+
+.warning-dark {
+ @apply bg-brand-marigold text-black;
+}
+
+/* Light Theme */
+.primary-light {
+ @apply bg-indigo-100 text-indigo-600;
+}
+
+.positive-light {
+ @apply bg-green-100 text-green-600;
+}
+
+.negative-light {
+ @apply bg-red-100 text-red-600;
+}
+
+.warning-light {
+ @apply text-brand-marigold bg-brand-marigold/20;
+}
+
+.neutral {
+ @apply bg-slate-100 text-slate-300;
+}
+
+.outline-style {
+ @apply text-brand-deep-purple rounded-lg border border-gray-200;
+}
+
+/*-- chips ---------------------------------------*/
+.chip {
+ @apply inline-block cursor-pointer text-nowrap rounded-full text-sm font-medium transition-all duration-300;
+}
+
+.chip-md {
+ @apply px-4 py-1;
+}
+
+/*---- table class -----------*/
+
+@layer components {
+ .base-table {
+ @apply w-full table-auto border-collapse text-gray-700;
+ }
+
+ .base-table thead {
+ @apply text-brand-deep-purple sticky top-0 z-10 text-sm;
+ }
+
+ .base-table thead th {
+ @apply relative h-10 whitespace-nowrap bg-gray-200 p-4 text-left font-medium;
+ }
+
+ .base-table thead::after {
+ content: ' ';
+ @apply absolute -bottom-1.5 left-0 h-1.5 w-full bg-stone-100;
+ }
+
+ .base-table thead th:first-child {
+ @apply rounded-l-lg;
+ }
+ .base-table thead th:last-child {
+ @apply rounded-r-lg;
+ }
+
+ .base-table tbody {
+ @apply relative pt-4 text-sm;
+ }
+
+ .base-table tbody::before {
+ content: '';
+ @apply absolute left-0 top-0 -z-20 h-full w-full rounded-lg bg-white shadow-lg;
+ }
+
+ .base-table tbody tr {
+ @apply relative;
+ }
+
+ .base-table tbody tr::after {
+ content: '';
+ @apply -translate-1/2 absolute left-1/2 top-1/2 -z-10 h-[90%] w-[99%] -translate-y-1/2 rounded-lg bg-gray-100 opacity-0;
+ }
+
+ .base-table tbody tr:first-child::after {
+ content: '';
+ @apply -translate-1/2 absolute left-1/2 top-[53.8%] -z-10 h-[80%] w-[99%] -translate-y-1/2 rounded-lg bg-gray-100 opacity-0;
+ }
+
+ .base-table tbody tr:hover::after {
+ @apply opacity-100;
+ }
+
+ /* .base-table tbody tr:hover{
+ @apply bg-gray-100 rounded-lg;
+ } */
+
+ .base-table tbody td {
+ @apply whitespace-nowrap border-b-[1px] border-gray-100 p-4 text-left;
+ }
+}
+
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;