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""" +
+ {label} + + +
+ """ + ) + + +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 %} - - {% endif %} -{% endblock %} - - - -{% block content %} -
-
-

Opportunities - - {% if request.org_membership.is_viewer %} - - {% else %} - Add new - - {% endif %} - -

-
-
- - - - - - - - - - - - - {% for opportunity in page_obj %} - - - - - - - - - - {% empty %} - - - - {% endfor %} - -
{% sort_link 'name' 'Name' %}{% sort_link 'start_date' 'Start Date' %}{% sort_link 'end_date' 'End Date' %}StatusProgramManage
{{ 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 %} -
-
{% translate "No opportunities yet." %}
-
- {% 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 %} + + {% endfor %} + + + {% endif %} + {% endblock table.thead %} + + {% block table.tbody %} + +
+ {% for row in table.paginated_rows %} + {% block table.tbody.row %} +
+ {% for column, cell in row.items %} + + {% endfor %} + + {% endblock table.tbody.row %} + {% empty %} + {% if table.empty_text %} + {% block table.tbody.empty_text %} + + + + {% endblock table.tbody.empty_text %} + {% endif %} + {% endfor %} + + + {% endblock table.tbody %} + +
+ {% if column.orderable %} + {% sortable_header column.name column.header table.use_view_url %} + {% else %} + {{ column.header }} + {% endif %} +
+ {% if column.localize == None %} + {{ cell }} + {% else %} + {% if column.localize %} + {{ cell|localize }} + {% else %} + {{ cell|unlocalize }} + {% endif %} + {% endif %} +
+ {{ table.empty_text }} +
+
+ {% endblock table %} + {% block pagination %} + {% if table.page and table.paginator.num_pages > 1 %} + + {% 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}} + + + + 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;