diff --git a/examples/seed.py b/examples/seed.py index 92570d1..3401afb 100644 --- a/examples/seed.py +++ b/examples/seed.py @@ -27,7 +27,7 @@ from django.core.management import call_command from django.utils import timezone -from django_program.conference.models import Conference, Expense, ExpenseCategory +from django_program.conference.models import Conference, Expense, ExpenseCategory, KPITargets from django_program.pretalx.models import Room, ScheduleSlot, SessionRating, Speaker, Talk, TalkOverride from django_program.programs.models import Activity, ActivitySignup, Survey, SurveyResponse, TravelGrant from django_program.registration.badge import Badge, BadgeTemplate @@ -51,6 +51,17 @@ TicketType, Voucher, ) +from django_program.registration.purchase_order import ( + PurchaseOrder, + PurchaseOrderCreditNote, + PurchaseOrderPayment, +) +from django_program.registration.services.purchase_orders import ( + cancel_purchase_order, + create_purchase_order, + issue_credit_note, + record_payment, +) from django_program.sponsors.models import BulkPurchase, BulkPurchaseVoucher, Sponsor, SponsorBenefit, SponsorLevel User = get_user_model() @@ -153,6 +164,7 @@ def run(self) -> None: """Create a full conference with realistic registration data.""" self._create_superuser() conference = self._create_conference() + self._create_kpi_targets(conference) prev_conferences = self._create_previous_conferences() ticket_types = self._create_ticket_types(conference) addons = self._create_addons(conference) @@ -179,6 +191,7 @@ def run(self) -> None: self._create_more_carts(conference, users, ticket_types, addons) self._create_bulk_purchases(conference, sponsors, ticket_types, addons, users) self._create_letter_requests(conference, users) + self._create_purchase_orders(conference, users, ticket_types, addons) self._create_badges(conference) # Set up permission groups @@ -212,6 +225,7 @@ def _print_summary( n_grants = TravelGrant.objects.filter(conference=conference).count() n_surveys = Survey.objects.filter(conference=conference).count() n_bulk = BulkPurchase.objects.filter(conference=conference).count() + n_pos = PurchaseOrder.objects.filter(conference=conference).count() n_letters = LetterRequest.objects.filter(conference=conference).count() n_sponsors = Sponsor.objects.filter(conference=conference).count() n_badge_templates = BadgeTemplate.objects.filter(conference=conference).count() @@ -249,6 +263,7 @@ def _print_summary( print(f"\n {'--- Finance ---':{W}}") print(f" {'Sponsors':{W}} {n_sponsors}") print(f" {'Bulk purchases':{W}} {n_bulk}") + print(f" {'Purchase orders':{W}} {n_pos}") print(f" {'Expenses':{W}} {n_expenses}") print(f" {'Travel grants':{W}} {n_grants}") @@ -283,14 +298,24 @@ def _create_conference(self) -> Conference: # Use whatever conference bootstrap_conference created conference = Conference.objects.first() if conference: - # Ensure budget fields are populated + # Ensure budget and integration fields are populated for demo + changed = False if not conference.revenue_budget: - Conference.objects.filter(pk=conference.pk).update( - revenue_budget=Decimal("50000.00"), - target_attendance=150, - grant_budget=Decimal("15000.00"), - ) - conference.refresh_from_db() + conference.revenue_budget = Decimal("50000.00") + conference.target_attendance = 150 + conference.grant_budget = Decimal("15000.00") + changed = True + if not conference.stripe_secret_key: + conference.stripe_secret_key = "sk_test_demo_not_real" + conference.stripe_publishable_key = "pk_test_demo_not_real" + changed = True + if not conference.qbo_realm_id: + conference.qbo_realm_id = "1234567890" + conference.qbo_client_id = "demo-client-id" + conference.qbo_client_secret = "demo-client-secret" + changed = True + if changed: + conference.save() return conference # Fallback: create one if bootstrap wasn't run conference, _ = Conference.objects.get_or_create( @@ -311,6 +336,21 @@ def _create_conference(self) -> Conference: ) return conference + def _create_kpi_targets(self, conference: Conference) -> KPITargets: + """Create KPI target thresholds for the conference.""" + kpi, _ = KPITargets.objects.update_or_create( + conference=conference, + defaults={ + "target_conversion_rate": Decimal("3.50"), + "target_refund_rate": Decimal("5.00"), + "target_checkin_rate": Decimal("80.00"), + "target_fulfillment_rate": Decimal("90.00"), + "target_revenue_per_attendee": Decimal("333.00"), + "target_room_utilization": Decimal("28.00"), + }, + ) + return kpi + def _create_ticket_types(self, conference: Conference) -> list[TicketType]: """Ensure seed ticket types exist and have bulk_enabled / availability windows set.""" now = timezone.now() @@ -1557,6 +1597,334 @@ def _create_bulk_purchases( }, ) + def _create_purchase_orders( + self, + conference: Conference, + _users: list, + ticket_types: list[TicketType], + addons: list[AddOn], + ) -> None: + """Create purchase orders across all lifecycle states.""" + if PurchaseOrder.objects.filter(conference=conference).exists(): + print(" Purchase orders already exist, skipping.") + return + + admin = User.objects.filter(is_superuser=True).first() + today = datetime.datetime.now(tz=datetime.UTC).date() + + # Find a corporate ticket and a useful addon + corp_ticket = next((t for t in ticket_types if "corporate" in str(t.slug).lower()), ticket_types[0]) + workshop_addon = next( + (a for a in addons if str(a.slug) in ("workshop", "tutorial")), + addons[0] if addons else None, + ) + tshirt_addon = next( + (a for a in addons if "shirt" in str(a.slug).lower()), + addons[1] if len(addons) > 1 else None, + ) + tutorial_addon = next( + (a for a in addons if str(a.slug) == "tutorial"), + addons[0] if addons else None, + ) + + corp_price = corp_ticket.price + + # 1. Draft PO — Acme Corp, 10x Corporate, no payments + print(" Creating purchase orders...") + po_draft = create_purchase_order( + conference=conference, + organization_name="Acme Corp", + contact_email="procurement@acmecorp.example.com", + contact_name="Janet Reeves", + billing_address="100 Innovation Drive, Suite 400\nSan Francisco, CA 94105", + line_items=[ + { + "description": str(corp_ticket.name), + "quantity": 10, + "unit_price": corp_price, + "ticket_type": corp_ticket, + }, + ], + notes="Awaiting internal budget approval before sending.", + created_by=admin, + ) + print(f" {po_draft.reference}: Draft — Acme Corp (10x {corp_ticket.name})") + + # 2. Sent/awaiting payment — TechStart Inc, 5x Corporate + 5x Workshop + po_sent = create_purchase_order( + conference=conference, + organization_name="TechStart Inc", + contact_email="accounting@techstart.example.com", + contact_name="Marcus Chen", + billing_address="2200 Startup Blvd\nAustin, TX 78701", + line_items=[ + { + "description": str(corp_ticket.name), + "quantity": 5, + "unit_price": corp_price, + "ticket_type": corp_ticket, + }, + ] + + ( + [ + { + "description": str(workshop_addon.name), + "quantity": 5, + "unit_price": workshop_addon.price, + "addon": workshop_addon, + }, + ] + if workshop_addon + else [] + ), + notes="Invoice sent via email on request. Net 30 terms.", + created_by=admin, + ) + po_sent.status = PurchaseOrder.Status.SENT + po_sent.save(update_fields=["status", "updated_at"]) + print(f" {po_sent.reference}: Sent — TechStart Inc (5x tickets + 5x addons)") + + # 3. Partially paid — Global Systems Ltd, 20x Corporate, ~60% paid + po_partial = create_purchase_order( + conference=conference, + organization_name="Global Systems Ltd", + contact_email="finance@globalsystems.example.com", + contact_name="Priya Kapoor", + billing_address="45 Enterprise Way\nChicago, IL 60601", + line_items=[ + { + "description": str(corp_ticket.name), + "quantity": 20, + "unit_price": corp_price, + "ticket_type": corp_ticket, + }, + ], + notes="First wire received. Remainder expected by end of month.", + created_by=admin, + ) + partial_amount = (po_partial.total * Decimal("0.6")).quantize(Decimal("0.01")) + record_payment( + po_partial, + amount=partial_amount, + method=PurchaseOrderPayment.Method.WIRE, + reference="WIRE-GS-20270301", + payment_date=today - datetime.timedelta(days=12), + entered_by=admin, + note="First installment via international wire.", + ) + print( + f" {po_partial.reference}: Partially paid — Global Systems Ltd" + f" (20x, {partial_amount} of {po_partial.total})" + ) + + # 4. Fully paid — DataFlow Analytics, 8x Corporate + 8x T-shirt, two payments + line_items_df: list[dict[str, object]] = [ + { + "description": str(corp_ticket.name), + "quantity": 8, + "unit_price": corp_price, + "ticket_type": corp_ticket, + }, + ] + if tshirt_addon: + line_items_df.append( + { + "description": str(tshirt_addon.name), + "quantity": 8, + "unit_price": tshirt_addon.price, + "addon": tshirt_addon, + }, + ) + po_paid = create_purchase_order( + conference=conference, + organization_name="DataFlow Analytics", + contact_email="ap@dataflow.example.com", + contact_name="Tomás Rivera", + billing_address="800 Data Center Pkwy\nSeattle, WA 98101", + line_items=line_items_df, + notes="Paid in full across two installments.", + created_by=admin, + ) + first_payment = (po_paid.total * Decimal("0.7")).quantize(Decimal("0.01")) + second_payment = po_paid.total - first_payment + record_payment( + po_paid, + amount=first_payment, + method=PurchaseOrderPayment.Method.WIRE, + reference="WIRE-DF-20270215", + payment_date=today - datetime.timedelta(days=25), + entered_by=admin, + note="Initial wire transfer.", + ) + record_payment( + po_paid, + amount=second_payment, + method=PurchaseOrderPayment.Method.ACH, + reference="ACH-DF-20270228", + payment_date=today - datetime.timedelta(days=10), + entered_by=admin, + note="Final ACH payment.", + ) + print(f" {po_paid.reference}: Paid — DataFlow Analytics (8x tickets + 8x addons)") + + # 5. Overpaid — Innovation Hub Co, 3x Corporate, payment exceeds total by $50 + po_over = create_purchase_order( + conference=conference, + organization_name="Innovation Hub Co", + contact_email="billing@innovationhub.example.com", + contact_name="Sana Al-Rashid", + billing_address="350 Catalyst Lane\nBoston, MA 02101", + line_items=[ + { + "description": str(corp_ticket.name), + "quantity": 3, + "unit_price": corp_price, + "ticket_type": corp_ticket, + }, + ], + notes="Overpayment of $50 — refund or credit pending.", + created_by=admin, + ) + po_over.status = PurchaseOrder.Status.SENT + po_over.save(update_fields=["status", "updated_at"]) + record_payment( + po_over, + amount=po_over.total + Decimal("50.00"), + method=PurchaseOrderPayment.Method.WIRE, + reference="WIRE-IH-20270305", + payment_date=today - datetime.timedelta(days=8), + entered_by=admin, + note="Wire transfer exceeded invoice total.", + ) + print(f" {po_over.reference}: Overpaid — Innovation Hub Co (3x, +$50 over)") + + # 6. Cancelled — CloudNine Enterprises, 15x Corporate + po_cancelled = create_purchase_order( + conference=conference, + organization_name="CloudNine Enterprises", + contact_email="events@cloudnine.example.com", + contact_name="Derek O'Sullivan", + billing_address="900 Nimbus Ave\nDenver, CO 80202", + line_items=[ + { + "description": str(corp_ticket.name), + "quantity": 15, + "unit_price": corp_price, + "ticket_type": corp_ticket, + }, + ], + notes="Cancelled — company withdrew conference sponsorship.", + created_by=admin, + ) + cancel_purchase_order(po_cancelled) + print(f" {po_cancelled.reference}: Cancelled — CloudNine Enterprises (15x)") + + # 7. PO with credit note — Digital Frontier Inc, 12x Corporate, paid, then credit for 2 tickets + po_credit = create_purchase_order( + conference=conference, + organization_name="Digital Frontier Inc", + contact_email="finance@digitalfrontier.example.com", + contact_name="Elena Vasquez", + billing_address="1500 Binary Blvd\nPortland, OR 97201", + line_items=[ + { + "description": str(corp_ticket.name), + "quantity": 12, + "unit_price": corp_price, + "ticket_type": corp_ticket, + }, + ], + notes="2 attendees cancelled, credit note issued for their tickets.", + created_by=admin, + ) + po_credit.status = PurchaseOrder.Status.SENT + po_credit.save(update_fields=["status", "updated_at"]) + record_payment( + po_credit, + amount=po_credit.total, + method=PurchaseOrderPayment.Method.ACH, + reference="ACH-DF-20270220", + payment_date=today - datetime.timedelta(days=20), + entered_by=admin, + note="Full payment received.", + ) + credit_amount = corp_price * 2 + issue_credit_note( + po_credit, + amount=credit_amount, + reason="2 attendees unable to attend — tickets cancelled per organization request.", + issued_by=admin, + ) + print(f" {po_credit.reference}: Paid + credit note — Digital Frontier Inc (12x, credit for 2)") + + # 8. Multi-payment PO — Enterprise Solutions Group, 25x Corporate + 10x Tutorial, 3 payments + line_items_es: list[dict[str, object]] = [ + { + "description": str(corp_ticket.name), + "quantity": 25, + "unit_price": corp_price, + "ticket_type": corp_ticket, + }, + ] + if tutorial_addon: + line_items_es.append( + { + "description": str(tutorial_addon.name), + "quantity": 10, + "unit_price": tutorial_addon.price, + "addon": tutorial_addon, + }, + ) + po_multi = create_purchase_order( + conference=conference, + organization_name="Enterprise Solutions Group", + contact_email="accounts@enterprisesolutions.example.com", + contact_name="Wesley Park", + billing_address="7000 Commerce Tower, 12th Floor\nNew York, NY 10001", + line_items=line_items_es, + notes="Large corporate deal — staggered payments per contract terms.", + created_by=admin, + ) + po_multi.status = PurchaseOrder.Status.SENT + po_multi.save(update_fields=["status", "updated_at"]) + remaining = po_multi.total + payment_1 = (remaining * Decimal("0.4")).quantize(Decimal("0.01")) + payment_2 = (remaining * Decimal("0.35")).quantize(Decimal("0.01")) + payment_3 = remaining - payment_1 - payment_2 + record_payment( + po_multi, + amount=payment_1, + method=PurchaseOrderPayment.Method.CHECK, + reference="CHK-ES-10042", + payment_date=today - datetime.timedelta(days=30), + entered_by=admin, + note="First installment — check received.", + ) + record_payment( + po_multi, + amount=payment_2, + method=PurchaseOrderPayment.Method.WIRE, + reference="WIRE-ES-20270301", + payment_date=today - datetime.timedelta(days=15), + entered_by=admin, + note="Second installment — wire transfer.", + ) + record_payment( + po_multi, + amount=payment_3, + method=PurchaseOrderPayment.Method.ACH, + reference="ACH-ES-20270310", + payment_date=today - datetime.timedelta(days=5), + entered_by=admin, + note="Final installment — ACH.", + ) + print(f" {po_multi.reference}: Paid (3 payments) — Enterprise Solutions Group (25x tickets + 10x addons)") + + n_pos = PurchaseOrder.objects.filter(conference=conference).count() + n_payments = PurchaseOrderPayment.objects.filter(purchase_order__conference=conference).count() + n_credits = PurchaseOrderCreditNote.objects.filter(purchase_order__conference=conference).count() + print(f" Created {n_pos} purchase orders, {n_payments} payments, {n_credits} credit notes.") + def _create_letter_requests(self, conference: Conference, users: list) -> None: """Create visa invitation letter requests across various workflow statuses.""" from django_program.registration.services.letters import generate_invitation_letter @@ -1728,69 +2096,23 @@ def _create_badges(self, conference: Conference) -> None: if BadgeTemplate.objects.filter(conference=conference).exists(): return - # Template definitions: (name, slug, is_default, accent, bg, text, show_email, - # show_company, show_qr, banner_pos) + # Each template varies by accent color, which fields are visible, and + # banner position so the badge list / preview shows realistic variety. + # + # (name, slug, is_default, accent, bg, text, + # show_email, show_company, show_qr, banner_pos) + BPos = BadgeTemplate.BannerPosition template_defs = [ - ( - "Default Badge", - "default", - True, - "#4338CA", - "#FFFFFF", - "#000000", - False, - False, - True, - BadgeTemplate.BannerPosition.BELOW_HEADER, - ), - ( - "Speaker Badge", - "speaker", - False, - "#DC2626", - "#FEF2F2", - "#1F2937", - True, - True, - True, - BadgeTemplate.BannerPosition.ABOVE_NAME, - ), - ( - "Staff Badge", - "staff", - False, - "#059669", - "#F0FDF4", - "#1F2937", - True, - False, - True, - BadgeTemplate.BannerPosition.BELOW_NAME, - ), - ( - "Sponsor Badge", - "sponsor", - False, - "#D97706", - "#FFFBEB", - "#1F2937", - True, - True, - True, - BadgeTemplate.BannerPosition.BOTTOM, - ), - ( - "Press Badge", - "press", - False, - "#7C3AED", - "#F5F3FF", - "#1F2937", - False, - True, - False, - BadgeTemplate.BannerPosition.BELOW_HEADER, - ), + # Attendee — clean white, indigo accent, name + QR only + ("Default Badge", "default", True, "#4338CA", "#FFFFFF", "#000000", False, False, True, BPos.BELOW_HEADER), + # Speaker — white, red accent, show company & email + ("Speaker Badge", "speaker", False, "#B91C1C", "#FFFFFF", "#111827", True, True, True, BPos.BELOW_HEADER), + # Staff — white, emerald accent, no company/email + ("Staff Badge", "staff", False, "#047857", "#FFFFFF", "#111827", False, False, True, BPos.BELOW_HEADER), + # Sponsor — white, amber accent, show company + ("Sponsor Badge", "sponsor", False, "#B45309", "#FFFFFF", "#111827", False, True, True, BPos.BELOW_HEADER), + # Press — white, purple accent, show company, no QR + ("Press Badge", "press", False, "#6D28D9", "#FFFFFF", "#111827", False, True, False, BPos.BELOW_HEADER), ] templates: dict[str, BadgeTemplate] = {} diff --git a/pyproject.toml b/pyproject.toml index 7202bcc..816f4a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ classifiers = [ dependencies = [ "django>=5.2", "django-fernet-encrypted-fields>=0.3.1", + "httpx>=0.28.1", "pillow>=12.1.1", "pretalx-client>=0.1.0", "qrcode[pil]>=8.2", diff --git a/src/django_program/conference/migrations/0010_add_qbo_fields.py b/src/django_program/conference/migrations/0010_add_qbo_fields.py new file mode 100644 index 0000000..ae8dd95 --- /dev/null +++ b/src/django_program/conference/migrations/0010_add_qbo_fields.py @@ -0,0 +1,57 @@ +# Generated by Django 5.2.11 on 2026-03-19 18:36 + +import encrypted_fields.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("program_conference", "0009_featureflags_visa_letters_enabled"), + ] + + operations = [ + migrations.AddField( + model_name="conference", + name="qbo_access_token", + field=encrypted_fields.fields.EncryptedCharField( + blank=True, default=None, help_text="QBO OAuth2 access token.", max_length=2000, null=True + ), + ), + migrations.AddField( + model_name="conference", + name="qbo_client_id", + field=models.CharField( + blank=True, default="", help_text="QBO OAuth2 client ID for token refresh.", max_length=200 + ), + ), + migrations.AddField( + model_name="conference", + name="qbo_client_secret", + field=encrypted_fields.fields.EncryptedCharField( + blank=True, + default=None, + help_text="QBO OAuth2 client secret for token refresh.", + max_length=500, + null=True, + ), + ), + migrations.AddField( + model_name="conference", + name="qbo_realm_id", + field=models.CharField( + blank=True, default="", help_text="QuickBooks Online Company/Realm ID.", max_length=200 + ), + ), + migrations.AddField( + model_name="conference", + name="qbo_refresh_token", + field=encrypted_fields.fields.EncryptedCharField( + blank=True, default=None, help_text="QBO OAuth2 refresh token.", max_length=2000, null=True + ), + ), + migrations.AddField( + model_name="conference", + name="qbo_token_expires_at", + field=models.DateTimeField(blank=True, help_text="When the QBO access token expires.", null=True), + ), + ] diff --git a/src/django_program/conference/models.py b/src/django_program/conference/models.py index 80f2d71..2711a46 100644 --- a/src/django_program/conference/models.py +++ b/src/django_program/conference/models.py @@ -27,6 +27,45 @@ class Conference(models.Model): stripe_publishable_key = EncryptedCharField(max_length=200, blank=True, null=True, default=None) stripe_webhook_secret = EncryptedCharField(max_length=200, blank=True, null=True, default=None) + qbo_realm_id = models.CharField( + max_length=200, + blank=True, + default="", + help_text="QuickBooks Online Company/Realm ID.", + ) + qbo_access_token = EncryptedCharField( + max_length=2000, + blank=True, + null=True, + default=None, + help_text="QBO OAuth2 access token.", + ) + qbo_refresh_token = EncryptedCharField( + max_length=2000, + blank=True, + null=True, + default=None, + help_text="QBO OAuth2 refresh token.", + ) + qbo_token_expires_at = models.DateTimeField( + null=True, + blank=True, + help_text="When the QBO access token expires.", + ) + qbo_client_id = models.CharField( + max_length=200, + blank=True, + default="", + help_text="QBO OAuth2 client ID for token refresh.", + ) + qbo_client_secret = EncryptedCharField( + max_length=500, + blank=True, + null=True, + default=None, + help_text="QBO OAuth2 client secret for token refresh.", + ) + total_capacity = models.PositiveIntegerField( default=0, help_text="Maximum total tickets across all types. 0 means unlimited.", diff --git a/src/django_program/manage/forms.py b/src/django_program/manage/forms.py index c3bc077..cf2695a 100644 --- a/src/django_program/manage/forms.py +++ b/src/django_program/manage/forms.py @@ -11,7 +11,7 @@ from django import forms from django.core.validators import RegexValidator -from django_program.conference.models import Conference, Expense, ExpenseCategory, Section +from django_program.conference.models import Conference, Expense, ExpenseCategory, KPITargets, Section from django_program.pretalx.models import Room, ScheduleSlot, Talk from django_program.programs.models import Activity, TravelGrant, TravelGrantMessage from django_program.registration.badge import BadgeTemplate @@ -101,6 +101,21 @@ class Meta: } +class KPITargetsForm(forms.ModelForm): + """Form for editing per-conference KPI target thresholds.""" + + class Meta: + model = KPITargets + fields = [ + "target_conversion_rate", + "target_refund_rate", + "target_checkin_rate", + "target_fulfillment_rate", + "target_revenue_per_attendee", + "target_room_utilization", + ] + + class SectionForm(forms.ModelForm): """Form for editing a conference section.""" diff --git a/src/django_program/manage/templates/django_program/manage/base.html b/src/django_program/manage/templates/django_program/manage/base.html index 7b7f237..a5dcccd 100644 --- a/src/django_program/manage/templates/django_program/manage/base.html +++ b/src/django_program/manage/templates/django_program/manage/base.html @@ -159,6 +159,40 @@ color: var(--color-text-muted); padding: 0 0.75rem; margin-bottom: 0.4rem; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + user-select: none; + border-radius: var(--radius-sm); + transition: color 0.15s ease; + } + .sidebar-section-title:hover { + color: var(--color-text-secondary); + } + .sidebar-section-title .sidebar-collapse-icon { + width: 12px; + height: 12px; + flex-shrink: 0; + opacity: 0.5; + transition: transform 0.2s ease, opacity 0.15s ease; + } + .sidebar-section-title:hover .sidebar-collapse-icon { + opacity: 0.8; + } + .sidebar-section.collapsed .sidebar-collapse-icon { + transform: rotate(-90deg); + } + .sidebar-section.collapsed > .sidebar-nav { + max-height: 0; + overflow: hidden; + margin: 0; + opacity: 0; + } + .sidebar-section > .sidebar-nav { + max-height: 2000px; + opacity: 1; + transition: max-height 0.25s ease, opacity 0.2s ease; } .sidebar-sublabel { @@ -169,6 +203,39 @@ color: var(--color-text-muted); padding: 0.5rem 0.75rem 0.2rem; opacity: 0.7; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + user-select: none; + border-radius: var(--radius-sm); + transition: opacity 0.15s ease; + } + .sidebar-sublabel:hover { + opacity: 1; + } + .sidebar-sublabel .sidebar-collapse-icon { + width: 10px; + height: 10px; + flex-shrink: 0; + opacity: 0.5; + transition: transform 0.2s ease, opacity 0.15s ease; + } + .sidebar-sublabel:hover .sidebar-collapse-icon { + opacity: 0.8; + } + .sidebar-subgroup { + max-height: 500px; + opacity: 1; + overflow: hidden; + transition: max-height 0.25s ease, opacity 0.2s ease; + } + .sidebar-subgroup.collapsed { + max-height: 0; + opacity: 0; + } + .sidebar-sublabel.collapsed-label .sidebar-collapse-icon { + transform: rotate(-90deg); } .sidebar-utility-separator { @@ -999,8 +1066,8 @@ {% if conference %}