Commit 2a4d40e4 authored by gustixjerk's avatar gustixjerk

feat: integrate dynamic backend ads CRUD into mobile home carousel, finalize...

feat: integrate dynamic backend ads CRUD into mobile home carousel, finalize mobile PRD features, and build release APK
parent 12872cd6
...@@ -27,8 +27,9 @@ DEV_OTP_ECHO=True ...@@ -27,8 +27,9 @@ DEV_OTP_ECHO=True
# Midtrans Snap (pembayaran tiket) — daftar di dashboard.midtrans.com # Midtrans Snap (pembayaran tiket) — daftar di dashboard.midtrans.com
MIDTRANS_IS_PRODUCTION=False MIDTRANS_IS_PRODUCTION=False
MIDTRANS_SERVER_KEY=SB-Mid-server-xxxxGANTIxxxx #MIDTRANS_SERVER_KEY=Mid-client-5v9oM46baaELmx3r
MIDTRANS_CLIENT_KEY=SB-Mid-client-xxxxGANTIxxxx #MIDTRANS_CLIENT_KEY=Mid-server-t1jR1s6o8SRoeV-qOTBs3Br5
MIDTRANS_MERCHANT_ID=G001234567 MIDTRANS_SERVER_KEY=Mid-client-w-fAhCG4Qr28GF99
MIDTRANS_CLIENT_KEY=Mid-server-T7dcoKiswH_IEjeUb-DX2A8q
# Biarkan default: auto sandbox/production sesuai MIDTRANS_IS_PRODUCTION # Biarkan default: auto sandbox/production sesuai MIDTRANS_IS_PRODUCTION
# MIDTRANS_SNAP_BASE=https://app.sandbox.midtrans.com # MIDTRANS_SNAP_BASE=https://app.sandbox.midtrans.com
\ No newline at end of file
...@@ -7,7 +7,7 @@ from rest_framework.test import APIClient, APITestCase ...@@ -7,7 +7,7 @@ from rest_framework.test import APIClient, APITestCase
from apps.common.testing import create_pengcab, make_member, make_staff, member_client from apps.common.testing import create_pengcab, make_member, make_staff, member_client
from apps.common.utils import generate_qr_token from apps.common.utils import generate_qr_token
from apps.events.models import Event, EventCategory, TicketTier from apps.events.models import Event, EventCategory, TicketTier
from apps.ticketing.models import Order, Ticket from apps.ticketing.models import Order, Payment, Ticket
class AuthFlowTests(APITestCase): class AuthFlowTests(APITestCase):
...@@ -153,3 +153,54 @@ class StaffTests(APITestCase): ...@@ -153,3 +153,54 @@ class StaffTests(APITestCase):
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["revenue_total"], 50000) self.assertEqual(r.data["revenue_total"], 50000)
self.assertIn("members_unverified", r.data) self.assertIn("members_unverified", r.data)
def test_dashboard_finance_aggregates(self):
staff = make_staff("+6281200000099")
client = member_client("+6281200000099")
user, _ = make_member("+6281400000022", "Pembeli")
cat = EventCategory.objects.create(code="fin", name="Enduro")
ev = Event.objects.create(
title="GP Keuangan",
slug="gp-keuangan",
category=cat,
status=Event.Status.PUBLISHED,
start_date="2026-12-01",
end_date="2026-12-02",
)
tier = TicketTier.objects.create(event=ev, code="fin", name="Umum", price=100000)
order = Order.objects.create(
code="IMI-FIN-0001", user=user, event=ev, status="paid",
subtotal=100000, discount_amount=0,
app_fee_amount=5000, imi_fee_amount=10000,
service_fee=1500, tax_amount=3500, total=120000,
buyer_name="Pembeli",
)
Payment.objects.create(order=order, method="va", status="verified", amount=120000)
# order lunas lain tanpa pembayaran terverifikasi -> tidak match
order2 = Order.objects.create(
code="IMI-FIN-0002", user=user, event=ev, status="paid",
subtotal=50000, discount_amount=0,
app_fee_amount=2500, imi_fee_amount=5000,
service_fee=1000, tax_amount=2000, total=60000,
buyer_name="Pembeli Dua",
)
r = client.get("/api/v1/dashboard/stats")
self.assertEqual(r.status_code, 200)
# Revenue sharing
self.assertEqual(r.data["rs_orders"], 2)
self.assertEqual(r.data["rs_taxable"], 150000)
self.assertEqual(r.data["rs_app_fee"], 7500)
self.assertEqual(r.data["rs_imi_fee"], 15000)
self.assertEqual(r.data["rs_service_fee"], 2500)
self.assertEqual(r.data["rs_tax"], 5500)
self.assertEqual(r.data["rs_total"], 180000)
# Settlement
self.assertEqual(r.data["st_settled"], 1)
self.assertEqual(r.data["st_settled_amount"], 120000)
# Rekonsiliasi
self.assertEqual(r.data["rc_orders"], 2)
self.assertEqual(r.data["rc_payments"], 1)
self.assertEqual(r.data["rc_orders_unmatched"], 1)
self.assertEqual(r.data["rc_payments_unmatched"], 0)
self.assertEqual(r.data["rc_diff"], 60000)
\ No newline at end of file
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from django.db import transaction from django.db import transaction
from django.db.models import Count, Sum from django.db.models import Count, F, Sum
from django.utils import timezone from django.utils import timezone
from rest_framework import status, viewsets from rest_framework import status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
...@@ -415,7 +415,7 @@ class DashboardStatsView(APIView): ...@@ -415,7 +415,7 @@ class DashboardStatsView(APIView):
from apps.content.models import Article, Banner from apps.content.models import Article, Banner
from apps.events.models import Event from apps.events.models import Event
from apps.partners.models import Benefit from apps.partners.models import Benefit
from apps.ticketing.models import CheckIn, Order, Ticket from apps.ticketing.models import CheckIn, Order, Payment, Ticket
now = timezone.now() now = timezone.now()
member_qs = Member.objects.exclude(account_type=Member.AccountType.PARTNER) member_qs = Member.objects.exclude(account_type=Member.AccountType.PARTNER)
...@@ -438,6 +438,50 @@ class DashboardStatsView(APIView): ...@@ -438,6 +438,50 @@ class DashboardStatsView(APIView):
"banners_active": Banner.objects.filter(is_active=True).count(), "banners_active": Banner.objects.filter(is_active=True).count(),
"points_issued": sum(m.points_balance for m in member_qs), "points_issued": sum(m.points_balance for m in member_qs),
} }
# ---- Finance: Revenue Sharing, Settlement & Reconciliation ----
order_qs = Order.objects.filter(status=Order.Status.PAID)
share = order_qs.aggregate(
taxable=Sum(F("subtotal") - F("discount_amount")),
app_fee=Sum("app_fee_amount"),
imi_fee=Sum("imi_fee_amount"),
service_fee=Sum("service_fee"),
tax=Sum("tax_amount"),
total=Sum("total"),
)
settled_qs = Payment.objects.filter(status=Payment.Status.VERIFIED)
settled = settled_qs.aggregate(n=Count("id"), amount=Sum("amount"))
def _or0(value):
return value or 0
orders_paid = order_qs.count()
payments_settled = _or0(settled["n"])
orders_unmatched = order_qs.exclude(id__in=settled_qs.values("order_id")).count()
payments_unmatched = settled_qs.exclude(order_id__in=order_qs.values("id")).count()
stats.update(
{
# Revenue sharing dari order lunas
"rs_orders": orders_paid,
"rs_taxable": _or0(share["taxable"]),
"rs_app_fee": _or0(share["app_fee"]),
"rs_imi_fee": _or0(share["imi_fee"]),
"rs_service_fee": _or0(share["service_fee"]),
"rs_tax": _or0(share["tax"]),
"rs_total": _or0(share["total"]),
# Settlement dari pembayaran terverifikasi
"st_settled": payments_settled,
"st_settled_amount": _or0(settled["amount"]),
"st_pending": Payment.objects.filter(status=Payment.Status.PENDING).count(),
# Rekonsiliasi order lunas vs pembayaran terverifikasi
"rc_orders": orders_paid,
"rc_payments": payments_settled,
"rc_orders_unmatched": orders_unmatched,
"rc_payments_unmatched": payments_unmatched,
"rc_diff": _or0(share["total"]) - _or0(settled["amount"]),
}
)
# distribusi by account_type # distribusi by account_type
by_type = member_qs.values("account_type").annotate(total=Count("id")) by_type = member_qs.values("account_type").annotate(total=Count("id"))
stats["members_by_type"] = {r["account_type"]: r["total"] for r in by_type} stats["members_by_type"] = {r["account_type"]: r["total"] for r in by_type}
......
...@@ -61,6 +61,8 @@ CONTENT_CATEGORIES = [ ...@@ -61,6 +61,8 @@ CONTENT_CATEGORIES = [
] ]
# Konstanta bisnis # Konstanta bisnis
APP_FEE_PCT = 5 # fee / bagian tim aplikasi (% dari nilai tiket setelah diskon)
IMI_FEE_PCT = 3 # fee / bagian IMI (% dari nilai tiket setelah diskon)
MEMBER_DISCOUNT_PERCENT = 15 # diskon anggota KTA di checkout tiket MEMBER_DISCOUNT_PERCENT = 15 # diskon anggota KTA di checkout tiket
MAX_TICKETS_PER_ORDER = 4 MAX_TICKETS_PER_ORDER = 4
SERVICE_FEE_TICKET = 5_000 # biaya layanan & asuransi sirkuit (flat) SERVICE_FEE_TICKET = 5_000 # biaya layanan & asuransi sirkuit (flat)
......
...@@ -95,6 +95,42 @@ def compute_ticket_price( ...@@ -95,6 +95,42 @@ def compute_ticket_price(
} }
def compute_selling_price(
base: int,
discount_amount: int = 0,
service_fee: int = 0,
tax_rate: float = IDR_TAX_RATE,
app_fee_pct: float = 0,
imi_fee_pct: float = 0,
) -> dict:
"""Rumus harga jual & bagi hasil tiket.
Sesuai kesepakatan: harga jual = harga tiket + fee% tim aplikasi + fee% IMI + pajak.
- nilai tiket (setelah diskon) -> dasar perhitungan fee
- fee tim aplikasi / IMI = persen dari nilai tiket setelah diskon
- pajak dikenakan atas nilai tiket + kedua fee (PB1), dibulatkan ke ratusan
"""
taxable = max(base - discount_amount, 0)
app_fee = round_idr(taxable * app_fee_pct / 100)
imi_fee = round_idr(taxable * imi_fee_pct / 100)
tax_base = taxable + app_fee + imi_fee
tax = round_idr(tax_base * tax_rate)
total = taxable + app_fee + imi_fee + service_fee + tax
return {
"base": base,
"discount_amount": discount_amount,
"taxable": taxable,
"app_fee": app_fee,
"imi_fee": imi_fee,
"tax_pct": round(tax_rate * 100, 2),
"app_fee_pct": round(app_fee_pct, 2),
"imi_fee_pct": round(imi_fee_pct, 2),
"service_fee": service_fee,
"tax_amount": tax,
"total": total,
}
def humanize_rupiah(value: int) -> str: def humanize_rupiah(value: int) -> str:
return f"Rp {value:,.0f}".replace(",", ".") return f"Rp {value:,.0f}".replace(",", ".")
......
from django.contrib import admin from django.contrib import admin
from .models import Article, Banner, Category, MediaAsset from .models import Advertisement, Article, Banner, Category, MediaAsset
@admin.register(Category) @admin.register(Category)
...@@ -29,3 +29,11 @@ class BannerAdmin(admin.ModelAdmin): ...@@ -29,3 +29,11 @@ class BannerAdmin(admin.ModelAdmin):
list_display = ["title", "link_type", "link_value", "ordinal", "is_active"] list_display = ["title", "link_type", "link_value", "ordinal", "is_active"]
list_filter = ["link_type", "is_active"] list_filter = ["link_type", "is_active"]
search_fields = ["title"] search_fields = ["title"]
@admin.register(Advertisement)
class AdvertisementAdmin(admin.ModelAdmin):
list_display = ["title", "category", "ordinal", "is_active", "updated_at"]
list_filter = ["is_active"]
search_fields = ["title", "category", "subtitle"]
list_editable = ["ordinal", "is_active"]
\ No newline at end of file
# Generated by Django 4.2.16 on 2026-10-07 09:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('content', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Advertisement',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('subtitle', models.CharField(blank=True, default='', max_length=300)),
('category', models.CharField(blank=True, default='', help_text='Label badge, mis. PROTEKSI ATLET BALAP / OFFICIAL RACING OIL', max_length=60)),
('details', models.TextField(blank=True, default='', help_text='Deskripsi lengkap yang muncul di sheet detail')),
('action_text', models.CharField(blank=True, default='Pelajari Program', max_length=60)),
('url', models.CharField(blank=True, default='', help_text='URL tujuan saat tombol diklik', max_length=500)),
('image_url', models.CharField(blank=True, default='', help_text='URL gambar iklan (boleh kosong)', max_length=500)),
('badge_color', models.CharField(blank=True, default='#10B981', help_text='Warna aksen badge (hex), mis. #10B981', max_length=9)),
('ordinal', models.PositiveIntegerField(default=0, help_text='Urutan tampil (kecil = pertama)')),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['ordinal', 'id'],
},
),
]
...@@ -98,3 +98,29 @@ class Banner(models.Model): ...@@ -98,3 +98,29 @@ class Banner(models.Model):
def __str__(self): def __str__(self):
return self.title or f"Banner #{self.pk}" return self.title or f"Banner #{self.pk}"
class Advertisement(models.Model):
"""Iklan slider "Penawaran & Mitra Resmi" di footer beranda."""
title = models.CharField(max_length=200)
subtitle = models.CharField(max_length=300, blank=True, default="")
category = models.CharField(
max_length=60, blank=True, default="",
help_text="Label badge, mis. PROTEKSI ATLET BALAP / OFFICIAL RACING OIL",
)
details = models.TextField(blank=True, default="", help_text="Deskripsi lengkap yang muncul di sheet detail")
action_text = models.CharField(max_length=60, blank=True, default="Pelajari Program")
url = models.CharField(max_length=500, blank=True, default="", help_text="URL tujuan saat tombol diklik")
image_url = models.CharField(max_length=500, blank=True, default="", help_text="URL gambar iklan (boleh kosong)")
badge_color = models.CharField(max_length=9, blank=True, default="#10B981", help_text="Warna aksen badge (hex), mis. #10B981")
ordinal = models.PositiveIntegerField(default=0, help_text="Urutan tampil (kecil = pertama)")
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["ordinal", "id"]
def __str__(self):
return self.title
\ No newline at end of file
from rest_framework import serializers from rest_framework import serializers
from .models import Article, Banner, Category, MediaAsset from .models import Advertisement, Article, Banner, Category, MediaAsset
class CategorySerializer(serializers.ModelSerializer): class CategorySerializer(serializers.ModelSerializer):
...@@ -47,3 +47,19 @@ class BannerSerializer(serializers.ModelSerializer): ...@@ -47,3 +47,19 @@ class BannerSerializer(serializers.ModelSerializer):
fields = ["id", "title", "image", "link_type", "link_value", "ordinal", fields = ["id", "title", "image", "link_type", "link_value", "ordinal",
"is_active", "starts_at", "ends_at"] "is_active", "starts_at", "ends_at"]
read_only_fields = ["id"] read_only_fields = ["id"]
class AdvertisementSerializer(serializers.ModelSerializer):
class Meta:
model = Advertisement
fields = [
"id", "title", "category", "subtitle", "details", "action_text",
"url", "image_url", "badge_color", "ordinal", "is_active",
"created_at", "updated_at",
]
read_only_fields = ["id", "created_at", "updated_at"]
def validate_title(self, value):
if not value.strip():
raise serializers.ValidationError("Judul iklan tidak boleh kosong.")
return value.strip()
\ No newline at end of file
...@@ -6,7 +6,7 @@ from django.utils import timezone ...@@ -6,7 +6,7 @@ from django.utils import timezone
from rest_framework.test import APIClient, APITestCase from rest_framework.test import APIClient, APITestCase
from apps.common.testing import create_pengcab, make_staff, member_client from apps.common.testing import create_pengcab, make_staff, member_client
from apps.content.models import Article, Banner, Category from apps.content.models import Advertisement, Article, Banner, Category
class ContentTests(APITestCase): class ContentTests(APITestCase):
...@@ -70,3 +70,71 @@ class ContentTests(APITestCase): ...@@ -70,3 +70,71 @@ class ContentTests(APITestCase):
r = self.client.get("/api/v1/articles/") r = self.client.get("/api/v1/articles/")
titles = [a["title"] for a in r.data["results"]] titles = [a["title"] for a in r.data["results"]]
self.assertIn("Berita Baru", titles) self.assertIn("Berita Baru", titles)
class AdTests(APITestCase):
def setUp(self):
self.client = APIClient()
self.active = Advertisement.objects.create(
title="BPJS Ketenagakerjaan",
category="PROTEKSI ATLET BALAP",
subtitle="JKK tanpa batas biaya",
action_text="Pelajari Program",
url="https://www.bpjsketenagakerjaan.go.id",
ordinal=1,
is_active=True,
)
Advertisement.objects.create(title="Iklan Nonaktif", ordinal=0, is_active=False)
def test_public_hanya_iklan_aktif_urut_ordinal(self):
r = self.client.get("/api/v1/ads/")
self.assertEqual(r.status_code, 200)
self.assertEqual(len(r.data), 1)
self.assertEqual(r.data[0]["title"], "BPJS Ketenagakerjaan")
self.assertIn("badge_color", r.data[0])
def test_manage_perlu_staff(self):
r = self.client.get("/api/v1/ads/manage/")
self.assertIn(r.status_code, (401, 403))
staff = make_staff()
staff_client = member_client(staff.phone)
r = staff_client.get("/api/v1/ads/manage/")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["count"], 2)
def test_crud_iklan(self):
staff_client = member_client(make_staff().phone)
r = staff_client.post(
"/api/v1/ads/manage/",
{
"title": "Motul 300V",
"category": "OFFICIAL RACING OIL",
"subtitle": "Pelumas ester core",
"details": "Detail panjang",
"action_text": "Beli",
"url": "https://www.motul.com/id/id",
"image_url": "https://example.com/ad.png",
"badge_color": "#E11D48",
"ordinal": 0,
"is_active": True,
},
format="json",
)
self.assertEqual(r.status_code, 201, r.data)
ad_id = r.data["id"]
self.assertEqual(r.data["badge_color"], "#E11D48")
rp = staff_client.patch(f"/api/v1/ads/manage/{ad_id}/", {"ordinal": 5}, format="json")
self.assertEqual(rp.status_code, 200, rp.data)
self.assertEqual(rp.data["ordinal"], 5)
r2 = staff_client.delete(f"/api/v1/ads/manage/{ad_id}/")
self.assertEqual(r2.status_code, 204)
def test_nonaktif_tidak_muncul_publik_setelah_update(self):
staff_client = member_client(make_staff().phone)
r = staff_client.patch(f"/api/v1/ads/manage/{self.active.id}/", {"is_active": False}, format="json")
self.assertEqual(r.status_code, 200, r.data)
r = self.client.get("/api/v1/ads/")
self.assertEqual(len(r.data), 0)
\ No newline at end of file
...@@ -7,6 +7,8 @@ router = DefaultRouter() ...@@ -7,6 +7,8 @@ router = DefaultRouter()
# Prefix spesifik didaftarkan lebih dulu (hindari tertelan regex detail articles/{pk}). # Prefix spesifik didaftarkan lebih dulu (hindari tertelan regex detail articles/{pk}).
router.register("articles/manage", views.ArticleManageViewSet, basename="article-manage") router.register("articles/manage", views.ArticleManageViewSet, basename="article-manage")
router.register("articles", views.PublicArticleViewSet, basename="article") router.register("articles", views.PublicArticleViewSet, basename="article")
router.register("ads/manage", views.AdManageViewSet, basename="ad-manage")
router.register("ads", views.PublicAdViewSet, basename="ad")
router.register("categories", views.CategoryViewSet) router.register("categories", views.CategoryViewSet)
router.register("media", views.MediaAssetViewSet, basename="media") router.register("media", views.MediaAssetViewSet, basename="media")
router.register("banners", views.BannerViewSet, basename="banner") router.register("banners", views.BannerViewSet, basename="banner")
......
...@@ -11,8 +11,9 @@ from rest_framework.response import Response ...@@ -11,8 +11,9 @@ from rest_framework.response import Response
from apps.common.permissions import ContentStaff from apps.common.permissions import ContentStaff
from apps.common.utils import build_slug from apps.common.utils import build_slug
from .models import Article, Banner, Category, MediaAsset from .models import Advertisement, Article, Banner, Category, MediaAsset
from .serializers import ( from .serializers import (
AdvertisementSerializer,
ArticleSerializer, ArticleSerializer,
BannerSerializer, BannerSerializer,
CategorySerializer, CategorySerializer,
...@@ -106,3 +107,21 @@ class BannerViewSet(viewsets.ReadOnlyModelViewSet): ...@@ -106,3 +107,21 @@ class BannerViewSet(viewsets.ReadOnlyModelViewSet):
qs = qs.filter(Q(starts_at__isnull=True) | Q(starts_at__lte=now)) qs = qs.filter(Q(starts_at__isnull=True) | Q(starts_at__lte=now))
qs = qs.filter(Q(ends_at__isnull=True) | Q(ends_at__gte=now)) qs = qs.filter(Q(ends_at__isnull=True) | Q(ends_at__gte=now))
return qs return qs
class PublicAdViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Advertisement.objects.none()
serializer_class = AdvertisementSerializer
permission_classes = [AllowAny]
pagination_class = None
def get_queryset(self):
return Advertisement.objects.filter(is_active=True)
class AdManageViewSet(viewsets.ModelViewSet):
queryset = Advertisement.objects.all()
serializer_class = AdvertisementSerializer
permission_classes = [ContentStaff]
filterset_fields = ["is_active"]
search_fields = ["title", "category", "subtitle"]
\ No newline at end of file
...@@ -143,7 +143,7 @@ class AdminTicketTierWriteSerializer(serializers.ModelSerializer): ...@@ -143,7 +143,7 @@ class AdminTicketTierWriteSerializer(serializers.ModelSerializer):
"id", "event", "code", "name", "description", "unit_label", "price", "id", "event", "code", "name", "description", "unit_label", "price",
"sort_order", "status", "quota", "low_stock_threshold", "requires_license", "sort_order", "status", "quota", "low_stock_threshold", "requires_license",
"member_only", "max_per_order", "is_free_child", "sale_start", "sale_end", "member_only", "max_per_order", "is_free_child", "sale_start", "sale_end",
"benefits", "sold_count", "benefits",
] ]
read_only_fields = ["sold_count"] read_only_fields = ["sold_count"]
......
...@@ -7,9 +7,9 @@ from datetime import timedelta ...@@ -7,9 +7,9 @@ from datetime import timedelta
from django.utils import timezone from django.utils import timezone
from rest_framework.test import APIClient, APITestCase from rest_framework.test import APIClient, APITestCase
from apps.common.testing import make_staff, member_client from apps.common.testing import make_member, make_staff, member_client
from .models import Event, EventCategory from .models import Event, EventCategory, TicketTier
def make_category(): def make_category():
...@@ -55,3 +55,99 @@ class EventListVisibilityTests(APITestCase): ...@@ -55,3 +55,99 @@ class EventListVisibilityTests(APITestCase):
r2 = client.get("/api/v1/events/", {"status": "draft"}) r2 = client.get("/api/v1/events/", {"status": "draft"})
self.assertEqual(len(r2.data["results"]), 1) self.assertEqual(len(r2.data["results"]), 1)
self.assertEqual(r2.data["results"][0]["slug"], "draft-event") self.assertEqual(r2.data["results"][0]["slug"], "draft-event")
class TicketTierAdminWriteTests(APITestCase):
"""Regression: get_permissions harus mengembalikan instance, bukan class,
agar has_permission(request, view) terpanggil benar pada action write."""
def setUp(self):
cat = make_category()
self.event = make_event(cat, "tier-event", Event.Status.DRAFT)
def admin_client(self, phone):
return member_client(phone)
def test_staff_bisa_create_patch_delete_tier(self):
client = self.admin_client(make_staff("+6281200000099").phone)
r = client.post(
"/api/v1/ticket-tiers/",
{
"event": self.event.id,
"name": "Early Bird",
"code": "early-bird",
"price": 100000,
"quota": 50,
"status": "enabled",
"benefits": [{"text": "Paddock"}],
},
format="json",
)
self.assertEqual(r.status_code, 201, r.data)
tier_id = r.data["id"]
self.assertEqual(r.data["sold_count"], 0)
rp = client.patch(f"/api/v1/ticket-tiers/{tier_id}/", {"status": "disabled"}, format="json")
self.assertEqual(rp.status_code, 200, rp.data)
self.assertEqual(rp.data["status"], "disabled")
rd = client.delete(f"/api/v1/ticket-tiers/{tier_id}/")
self.assertEqual(rd.status_code, 204)
def test_member_tanpa_role_tidak_boleh_membuat_tier(self):
user, _ = make_member("+6281200000005")
client = member_client(user.phone)
r = client.post(
"/api/v1/ticket-tiers/",
{"event": self.event.id, "name": "x", "code": "x", "price": 10000},
format="json",
)
self.assertEqual(r.status_code, 403, r.data)
class EventAdminWriteTests(APITestCase):
"""Regression: payload dari form web-admin (collect()) harus diterima:
slug kosong di-auto, text kosong via "", kolom nullable via null."""
def setUp(self):
self.cat = make_category()
self.client = member_client(make_staff("+6281200000098").phone)
def test_create_event_payload_webadmin(self):
r = self.client.post(
"/api/v1/events/",
{
"title": "Payung Test",
"slug": "",
"category": self.cat.id,
"status": "draft",
"start_date": "2026-10-10",
"end_date": "2026-10-11",
"featured": False,
"is_official": True,
"max_ticket_per_user": 4,
"sale_start_at": None,
"sale_end_at": None,
"circuit": None,
"description": "",
"venue_name": "",
"organizer": "",
},
format="json",
)
self.assertEqual(r.status_code, 201, r.data)
self.assertEqual(r.data["slug"], "payung-test")
def test_patch_event_membersihkan_datetime_nan(self):
e = make_event(self.cat, "patch-datetime", Event.Status.DRAFT)
r = self.client.patch(
f"/api/v1/events/{e.slug}/",
{"sale_start_at": None, "sale_end_at": None, "circuit": None, "venue_name": ""},
format="json",
)
self.assertEqual(r.status_code, 200, r.data)
e.refresh_from_db()
self.assertIsNone(e.sale_start_at)
self.assertIsNone(e.sale_end_at)
self.assertIsNone(e.circuit)
self.assertEqual(e.venue_name, "")
\ No newline at end of file
...@@ -103,7 +103,7 @@ class TicketTierViewSet(viewsets.ModelViewSet): ...@@ -103,7 +103,7 @@ class TicketTierViewSet(viewsets.ModelViewSet):
def get_permissions(self): def get_permissions(self):
if self.action in ["list", "retrieve"]: if self.action in ["list", "retrieve"]:
return [AllowAny()] return [AllowAny()]
return [_CONTENT_STAFF] return [_CONTENT_STAFF()]
def get_queryset(self): def get_queryset(self):
qs = super().get_queryset() qs = super().get_queryset()
......
# Finance: aturan harga & bagi hasil (fee tim aplikasi / IMI / pajak).
\ No newline at end of file
from django.contrib import admin
from .models import PricingConfig
@admin.register(PricingConfig)
class PricingConfigAdmin(admin.ModelAdmin):
list_display = ["key", "app_fee_pct", "imi_fee_pct", "tax_rate_pct", "service_fee", "updated_at"]
\ No newline at end of file
from django.apps import AppConfig
class FinanceConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.finance"
verbose_name = "Keuangan & Bagi Hasil"
\ No newline at end of file
# Generated by Django 4.2.16 on 2026-10-07 08:30
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='PricingConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.SlugField(default='default', editable=False, max_length=20, unique=True)),
('app_fee_pct', models.DecimalField(decimal_places=2, default=5.0, help_text='Fee / bagian tim aplikasi (%)', max_digits=5)),
('imi_fee_pct', models.DecimalField(decimal_places=2, default=3.0, help_text='Fee / bagian IMI (%)', max_digits=5)),
('tax_rate_pct', models.DecimalField(decimal_places=2, default=10.0, help_text='Pajak Hiburan Daerah PB1 (%)', max_digits=5)),
('service_fee', models.PositiveIntegerField(default=5000, help_text='Biaya layanan & asuransi sirkuit (Rp)')),
('updated_at', models.DateTimeField(auto_now=True)),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Konfigurasi Harga & Bagi Hasil',
'verbose_name_plural': 'Konfigurasi Harga & Bagi Hasil',
},
),
]
"""Konfigurasi harga global untuk tiket.
Rumus harga jual (sesuai kesepakatan tim aplikasi & IMI):
harga jual = harga tiket + fee% tim aplikasi + fee% tim IMI + pajak
Definisi pembagian nilai transaksi:
- bagian tim aplikasi = fee tim aplikasi (persen dari nilai tiket setelah diskon)
- bagian IMI = fee IMI (persen dari nilai tiket setelah diskon)
"""
from django.conf import settings
from django.db import models
class PricingConfig(models.Model):
"""Konfigurasi tunggal (row singleton `key="default"`) untuk aturan harga."""
key = models.SlugField(max_length=20, unique=True, editable=False, default="default")
app_fee_pct = models.DecimalField(
max_digits=5, decimal_places=2, default=5.0, help_text="Fee / bagian tim aplikasi (%)"
)
imi_fee_pct = models.DecimalField(
max_digits=5, decimal_places=2, default=3.0, help_text="Fee / bagian IMI (%)"
)
tax_rate_pct = models.DecimalField(
max_digits=5, decimal_places=2, default=10.0, help_text="Pajak Hiburan Daerah PB1 (%)"
)
service_fee = models.PositiveIntegerField(default=5_000, help_text="Biaya layanan & asuransi sirkuit (Rp)")
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL
)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Konfigurasi Harga & Bagi Hasil"
verbose_name_plural = "Konfigurasi Harga & Bagi Hasil"
@classmethod
def get_default(cls) -> "PricingConfig":
obj, _ = cls.objects.get_or_create(key="default")
return obj
def __str__(self):
return f"Harga: app {self.app_fee_pct}% + imi {self.imi_fee_pct}% + pajak {self.tax_rate_pct}%"
\ No newline at end of file
from rest_framework import serializers
from .models import PricingConfig
class PricingConfigSerializer(serializers.ModelSerializer):
app_fee_pct = serializers.DecimalField(max_digits=5, decimal_places=2, coerce_to_string=False)
imi_fee_pct = serializers.DecimalField(max_digits=5, decimal_places=2, coerce_to_string=False)
tax_rate_pct = serializers.DecimalField(max_digits=5, decimal_places=2, coerce_to_string=False)
class Meta:
model = PricingConfig
fields = ["id", "app_fee_pct", "imi_fee_pct", "tax_rate_pct", "service_fee", "updated_at"]
read_only_fields = ["id", "updated_at"]
\ No newline at end of file
"""Test konfigurasi harga & bagi hasil (finance)."""
from __future__ import annotations
from rest_framework.test import APITestCase
from apps.common.testing import login_token, make_member, make_staff, member_client
from apps.finance.models import PricingConfig
class PricingConfigTests(APITestCase):
PREFIX = "/api/v1/finance/pricing-config/"
def setUp(self):
self.staff = make_staff()
token = login_token(self.client, "+6281200000001")
self.auth = {"HTTP_AUTHORIZATION": f"Bearer {token}"}
def test_get_default_config(self):
r = self.client.get(self.PREFIX, **self.auth)
self.assertEqual(r.status_code, 200, r.data)
self.assertEqual(float(r.data["app_fee_pct"]), 5.0)
self.assertEqual(float(r.data["imi_fee_pct"]), 3.0)
self.assertEqual(float(r.data["tax_rate_pct"]), 10.0)
self.assertEqual(r.data["service_fee"], 5_000)
def test_update_config(self):
r = self.client.put(
self.PREFIX,
{"app_fee_pct": 7, "imi_fee_pct": 4, "tax_rate_pct": 11, "service_fee": 6_000},
format="json",
**self.auth,
)
self.assertEqual(r.status_code, 200, r.data)
self.assertEqual(float(r.data["app_fee_pct"]), 7.0)
self.assertEqual(r.data["service_fee"], 6_000)
config = PricingConfig.get_default()
self.assertEqual(float(config.imi_fee_pct), 4.0)
self.assertEqual(float(config.tax_rate_pct), 11.0)
def test_preview_rumus_harga_jual(self):
r = self.client.post(f"{self.PREFIX}preview/", {"price": 100_000}, format="json", **self.auth)
self.assertEqual(r.status_code, 200, r.data)
data = r.data
# fee app 5% = 5.000, fee IMI 3% = 3.000, service 5.000,
# pajak 10% atas (100.000 + 5.000 + 3.000) = 10.800
self.assertEqual(data["app_fee"], 5_000)
self.assertEqual(data["imi_fee"], 3_000)
self.assertEqual(data["tax_amount"], 10_800)
self.assertEqual(data["total"], 123_800)
def test_preview_ikut_diskon(self):
r = self.client.post(
f"{self.PREFIX}preview/", {"price": 100_000, "discount_amount": 10_000},
format="json", **self.auth,
)
self.assertEqual(r.status_code, 200, r.data)
# basis 90.000: app 4.500, imi 2.700, tax 10% dari 97.200 = 9.700 (+service 5.000)
self.assertEqual(r.data["taxable"], 90_000)
self.assertEqual(r.data["app_fee"], 4_500)
self.assertEqual(r.data["imi_fee"], 2_700)
self.assertEqual(r.data["tax_amount"], 9_700)
self.assertEqual(r.data["total"], 111_900)
def test_member_tidak_bisa_akses(self):
_, member = make_member("+6281900000001")
client = member_client("+6281900000001")
r = client.get(self.PREFIX)
self.assertIn(r.status_code, (401, 403))
r = client.post(f"{self.PREFIX}preview/", {"price": 100_000}, format="json")
self.assertIn(r.status_code, (401, 403))
\ No newline at end of file
from django.urls import path
from . import views
app_name = "finance"
urlpatterns = [
path("finance/pricing-config/preview/", views.PricingPreviewView.as_view(), name="pricing-preview"),
path("finance/pricing-config/", views.PricingConfigView.as_view(), name="pricing-config"),
]
\ No newline at end of file
"""API finance: baca/ubah konfigurasi harga & preview rumus harga jual."""
from __future__ import annotations
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.common.permissions import AnyStaff
from apps.common.utils import compute_selling_price
from .models import PricingConfig
from .serializers import PricingConfigSerializer
class PricingConfigView(APIView):
"""GET/PUT konfigurasi harga & bagi hasil (lahan singleton)."""
permission_classes = [AnyStaff]
def get(self, request):
config = PricingConfig.get_default()
return Response(PricingConfigSerializer(config).data)
def put(self, request):
config = PricingConfig.get_default()
serializer = PricingConfigSerializer(config, data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save(updated_by=request.user)
return Response(serializer.data)
class PricingPreviewView(APIView):
"""POST {price, discount_amount?} -> rincian harga jual dengan aturan aktif.
Rumus: harga jual = harga tiket + fee% tim aplikasi + fee% IMI + pajak.
"""
permission_classes = [AnyStaff]
def post(self, request):
config = PricingConfig.get_default()
try:
price = int(round(float(request.data.get("price") or 0)))
discount = int(round(float(request.data.get("discount_amount") or 0)))
except (TypeError, ValueError):
return Response({"price": ["Harga harus berupa angka."]}, status=status.HTTP_400_BAD_REQUEST)
breakdown = compute_selling_price(
base=price,
discount_amount=discount,
service_fee=config.service_fee,
tax_rate=float(config.tax_rate_pct) / 100,
app_fee_pct=float(config.app_fee_pct),
imi_fee_pct=float(config.imi_fee_pct),
)
return Response(
{
"price": price,
"discount_amount": discount,
"app_fee_pct": float(config.app_fee_pct),
"imi_fee_pct": float(config.imi_fee_pct),
"tax_rate_pct": float(config.tax_rate_pct),
"service_fee": config.service_fee,
**breakdown,
}
)
\ No newline at end of file
# Generated by Django 4.2.16 on 2026-10-07 08:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ticketing', '0002_payment_payment_type_payment_qr_string_and_more'),
]
operations = [
migrations.AddField(
model_name='order',
name='app_fee_amount',
field=models.PositiveIntegerField(default=0, help_text='Fee / bagian tim aplikasi (Rp)'),
),
migrations.AddField(
model_name='order',
name='imi_fee_amount',
field=models.PositiveIntegerField(default=0, help_text='Fee / bagian IMI (Rp)'),
),
]
...@@ -27,6 +27,8 @@ class Order(models.Model): ...@@ -27,6 +27,8 @@ class Order(models.Model):
subtotal = models.PositiveIntegerField(default=0) subtotal = models.PositiveIntegerField(default=0)
discount_amount = models.PositiveIntegerField(default=0) discount_amount = models.PositiveIntegerField(default=0)
app_fee_amount = models.PositiveIntegerField(default=0, help_text="Fee / bagian tim aplikasi (Rp)")
imi_fee_amount = models.PositiveIntegerField(default=0, help_text="Fee / bagian IMI (Rp)")
service_fee = models.PositiveIntegerField(default=0) service_fee = models.PositiveIntegerField(default=0)
tax_amount = models.PositiveIntegerField(default=0) tax_amount = models.PositiveIntegerField(default=0)
total = models.PositiveIntegerField(default=0) total = models.PositiveIntegerField(default=0)
......
...@@ -34,12 +34,15 @@ class OrderSerializer(serializers.ModelSerializer): ...@@ -34,12 +34,15 @@ class OrderSerializer(serializers.ModelSerializer):
subtotal_display = serializers.SerializerMethodField() subtotal_display = serializers.SerializerMethodField()
discount_display = serializers.SerializerMethodField() discount_display = serializers.SerializerMethodField()
tax_display = serializers.SerializerMethodField() tax_display = serializers.SerializerMethodField()
app_fee_display = serializers.SerializerMethodField()
imi_fee_display = serializers.SerializerMethodField()
class Meta: class Meta:
model = Order model = Order
fields = [ fields = [
"id", "code", "event", "event_title", "status", "status_display", "id", "code", "event", "event_title", "status", "status_display",
"subtotal", "subtotal_display", "discount_amount", "discount_display", "subtotal", "subtotal_display", "discount_amount", "discount_display",
"app_fee_amount", "app_fee_display", "imi_fee_amount", "imi_fee_display",
"service_fee", "tax_amount", "tax_display", "total", "total_display", "service_fee", "tax_amount", "tax_display", "total", "total_display",
"currency", "member_discount_pct", "voucher_discount_amount", "buyer_name", "currency", "member_discount_pct", "voucher_discount_amount", "buyer_name",
"buyer_email", "buyer_phone", "payment_method", "expires_at", "paid_at", "buyer_email", "buyer_phone", "payment_method", "expires_at", "paid_at",
...@@ -56,6 +59,12 @@ class OrderSerializer(serializers.ModelSerializer): ...@@ -56,6 +59,12 @@ class OrderSerializer(serializers.ModelSerializer):
def get_tax_display(self, obj): def get_tax_display(self, obj):
return humanize_rupiah(obj.tax_amount) return humanize_rupiah(obj.tax_amount)
def get_app_fee_display(self, obj):
return humanize_rupiah(obj.app_fee_amount)
def get_imi_fee_display(self, obj):
return humanize_rupiah(obj.imi_fee_amount)
class CreateOrderSerializer(serializers.Serializer): class CreateOrderSerializer(serializers.Serializer):
event_id = serializers.IntegerField() event_id = serializers.IntegerField()
......
...@@ -14,10 +14,8 @@ from apps.common.constants import ( ...@@ -14,10 +14,8 @@ from apps.common.constants import (
MAX_TICKETS_PER_ORDER, MAX_TICKETS_PER_ORDER,
MEMBER_DISCOUNT_PERCENT, MEMBER_DISCOUNT_PERCENT,
ORDER_HOLD_MINUTES, ORDER_HOLD_MINUTES,
SERVICE_FEE_TICKET,
TAX_RATE_HIBURAN,
) )
from apps.common.utils import compute_ticket_price, generate_qr_token from apps.common.utils import compute_selling_price, generate_qr_token
from . import midtrans from . import midtrans
from .models import CheckIn, Order, OrderLine, Payment, Ticket from .models import CheckIn, Order, OrderLine, Payment, Ticket
...@@ -60,6 +58,7 @@ def create_order(user, data: dict) -> Order: ...@@ -60,6 +58,7 @@ def create_order(user, data: dict) -> Order:
""" """
from apps.accounts.models import Member from apps.accounts.models import Member
from apps.events.models import Event, SessionPrice, TicketTier from apps.events.models import Event, SessionPrice, TicketTier
from apps.finance.models import PricingConfig
event = data.get("event_id") event = data.get("event_id")
try: try:
...@@ -138,11 +137,15 @@ def create_order(user, data: dict) -> Order: ...@@ -138,11 +137,15 @@ def create_order(user, data: dict) -> Order:
voucher_discount = round(subtotal * voucher.discount_value / 100) voucher_discount = round(subtotal * voucher.discount_value / 100)
discount_amount += voucher_discount discount_amount += voucher_discount
price = compute_ticket_price( # aturan harga & bagi hasil aktif (fee tim aplikasi, fee IMI, pajak, biaya layanan)
subtotal=subtotal, config = PricingConfig.get_default()
price = compute_selling_price(
base=subtotal,
discount_amount=discount_amount, discount_amount=discount_amount,
service_fee=SERVICE_FEE_TICKET, service_fee=config.service_fee,
tax_rate=TAX_RATE_HIBURAN, tax_rate=float(config.tax_rate_pct) / 100,
app_fee_pct=float(config.app_fee_pct),
imi_fee_pct=float(config.imi_fee_pct),
) )
order = Order.objects.create( order = Order.objects.create(
...@@ -153,6 +156,8 @@ def create_order(user, data: dict) -> Order: ...@@ -153,6 +156,8 @@ def create_order(user, data: dict) -> Order:
status=Order.Status.PENDING, status=Order.Status.PENDING,
subtotal=subtotal, subtotal=subtotal,
discount_amount=discount_amount, discount_amount=discount_amount,
app_fee_amount=price["app_fee"],
imi_fee_amount=price["imi_fee"],
service_fee=price["service_fee"], service_fee=price["service_fee"],
tax_amount=price["tax_amount"], tax_amount=price["tax_amount"],
total=price["total"], total=price["total"],
......
...@@ -91,12 +91,15 @@ class CheckoutFlowTests(APITestCase): ...@@ -91,12 +91,15 @@ class CheckoutFlowTests(APITestCase):
r = self._create_order(self.festival, self.session.id, qty=2) r = self._create_order(self.festival, self.session.id, qty=2)
self.assertEqual(r.status_code, 201, r.data) self.assertEqual(r.status_code, 201, r.data)
order = r.data order = r.data
# 2 x 90.000, diskon member 15%, service 5.000, PB1 10% atas nilai setelah diskon # 2 x 90.000, diskon member 15%, fee app 5% + fee IMI 3%, service 5.000, PB1 10%
# atas nilai tiket + kedua fee
self.assertEqual(order["subtotal"], 180_000) self.assertEqual(order["subtotal"], 180_000)
self.assertEqual(order["discount_amount"], 27_000) self.assertEqual(order["discount_amount"], 27_000)
self.assertEqual(order["app_fee_amount"], 7_600)
self.assertEqual(order["imi_fee_amount"], 4_600)
self.assertEqual(order["service_fee"], 5_000) self.assertEqual(order["service_fee"], 5_000)
self.assertEqual(order["tax_amount"], 15_300) self.assertEqual(order["tax_amount"], 16_500)
self.assertEqual(order["total"], 173_300) self.assertEqual(order["total"], 186_700)
self.assertEqual(order["status"], Order.Status.PENDING) self.assertEqual(order["status"], Order.Status.PENDING)
self.festival.refresh_from_db() self.festival.refresh_from_db()
...@@ -105,7 +108,7 @@ class CheckoutFlowTests(APITestCase): ...@@ -105,7 +108,7 @@ class CheckoutFlowTests(APITestCase):
with mock.patch.object(midtrans_client, "create_transaction", return_value=SNAP_CREATE): with mock.patch.object(midtrans_client, "create_transaction", return_value=SNAP_CREATE):
r = self.client.post(f"/api/v1/orders/{order['id']}/payment/", {}, format="json") r = self.client.post(f"/api/v1/orders/{order['id']}/payment/", {}, format="json")
self.assertEqual(r.status_code, 201, r.data) self.assertEqual(r.status_code, 201, r.data)
self.assertEqual(r.data["amount"], 173_300) self.assertEqual(r.data["amount"], 186_700)
self.assertEqual(r.data["method"], Payment.Method.SNAP) self.assertEqual(r.data["method"], Payment.Method.SNAP)
self.assertEqual(r.data["snap_token"], "snap-token-abc") self.assertEqual(r.data["snap_token"], "snap-token-abc")
self.assertIn("app.sandbox.midtrans.com", r.data["snap_redirect_url"]) self.assertIn("app.sandbox.midtrans.com", r.data["snap_redirect_url"])
......
...@@ -65,6 +65,7 @@ INSTALLED_APPS = [ ...@@ -65,6 +65,7 @@ INSTALLED_APPS = [
"apps.partners", "apps.partners",
"apps.content", "apps.content",
"apps.notifications", "apps.notifications",
"apps.finance",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
...@@ -176,6 +177,7 @@ SPECTACULAR_SETTINGS = { ...@@ -176,6 +177,7 @@ SPECTACULAR_SETTINGS = {
{"name": "partners", "description": "Partner, benefit/katalog voucher, poin"}, {"name": "partners", "description": "Partner, benefit/katalog voucher, poin"},
{"name": "content", "description": "News/artikel, banner, media"}, {"name": "content", "description": "News/artikel, banner, media"},
{"name": "notifications", "description": "Notifikasi pengguna"}, {"name": "notifications", "description": "Notifikasi pengguna"},
{"name": "finance", "description": "Aturan harga, bagi hasil, laporan keuangan"},
{"name": "dashboard", "description": "Statistik & reporting untuk admin"}, {"name": "dashboard", "description": "Statistik & reporting untuk admin"},
], ],
"ENUM_NAME_OVERRIDES": { "ENUM_NAME_OVERRIDES": {
......
...@@ -13,6 +13,7 @@ api_v1_patterns = [ ...@@ -13,6 +13,7 @@ api_v1_patterns = [
path("", include("apps.partners.urls")), path("", include("apps.partners.urls")),
path("", include("apps.content.urls")), path("", include("apps.content.urls")),
path("", include("apps.notifications.urls")), path("", include("apps.notifications.urls")),
path("", include("apps.finance.urls")),
] ]
urlpatterns = [ urlpatterns = [
......
# PRD — Aplikasi Digital IMI Jawa Barat
**Version:** 0.2
**Status:** Draft
**Platform:** Android, iOS, Web Mobile/Public, Web Admin
---
## 1. Overview
### 1.1 Background
Aplikasi Digital IMI Jawa Barat merupakan platform digital untuk menghubungkan **IMI Jawa Barat, anggota, event, media, partner, dan berbagai program kemitraan** dalam satu ekosistem.
Aplikasi ini diharapkan menjadi pusat aktivitas digital anggota sekaligus membantu IMI Jawa Barat mengelola membership, event, ticketing, konten, partnership, benefit, dan informasi secara lebih terintegrasi.
### 1.2 Goals
Produk ini memiliki tujuan utama:
1. Digitalisasi proses membership IMI Jawa Barat.
2. Menyediakan digital membership card.
3. Memudahkan anggota mendapatkan informasi dan mengikuti event.
4. Menyediakan ticketing dan digital check-in.
5. Menyediakan media/news sebagai sumber informasi IMI Jawa Barat.
6. Menyediakan benefit dan promo dari partner.
7. Mendukung proses pengelolaan partnership.
8. Menyediakan dashboard untuk monitoring dan reporting.
### 1.3 Target User
| User | Kebutuhan Utama |
|---|---|
| Anggota | Membership, digital card, event, ticket, benefit |
| Publik | Informasi IMI, event, media, partner |
| Panitia/Event Organizer | Pengelolaan event dan ticketing |
| Partner | Partnership, campaign, benefit |
| Admin IMI Jabar | Pengelolaan seluruh platform |
---
# 2. Requirement
## 2.1 Mobile App — Android & iOS
### Authentication
- Registrasi akun
- Login
- Logout
- Lupa/reset password
- Verifikasi akun
### Membership
- Profil anggota
- Nomor anggota
- Status membership
- Masa berlaku membership
- Digital membership card
- QR Code membership
- Informasi benefit anggota
### Home
Home menjadi halaman utama anggota yang menampilkan informasi penting seperti:
- Banner/promo
- Event terdekat
- Berita terbaru
- Benefit partner
- Digital membership card
- Ticket yang dimiliki
- Notifikasi
### Event
- Daftar event
- Filter/kategori event
- Detail event
- Jadwal
- Lokasi
- Informasi penyelenggara
- Harga ticket
- Pembelian ticket
- Riwayat event
### Ticket
- Daftar ticket
- Detail ticket
- E-ticket
- QR Code ticket
- Status pembayaran
- Status check-in
- Riwayat ticket
### Benefit
- Daftar partner
- Daftar benefit/promo
- Detail benefit
- Cara mendapatkan benefit
- Redeem benefit
- Riwayat redemption
### Media
- News
- Artikel
- Foto
- Video
- Press release
- Agenda/informasi IMI
### Notification
Notifikasi dapat digunakan untuk:
- Informasi event
- Ticket
- Membership
- Benefit/promo
- Informasi IMI Jawa Barat
### Profile
- Data pribadi
- Data membership
- Pengaturan akun
- Pengaturan notifikasi
- Logout
---
## 2.2 Web Mobile / Public
Web public digunakan oleh anggota maupun masyarakat umum tanpa harus selalu menggunakan aplikasi.
Fitur utama:
- Home
- Informasi IMI Jawa Barat
- Event
- Event detail
- Media/news
- Partner
- Benefit
- Landing page event/campaign
- Informasi membership
---
## 2.3 Web Admin
Web Admin digunakan oleh pengelola IMI Jawa Barat untuk mengelola seluruh data dan aktivitas platform.
### Dashboard
Menampilkan ringkasan:
- Jumlah anggota
- Event
- Ticket terjual
- Check-in
- Partner
- Benefit
- Aktivitas platform
### Member Management
- Daftar anggota
- Detail anggota
- Verifikasi anggota
- Status membership
- Membership card
### Event Management
- Membuat event
- Edit event
- Publish/unpublish event
- Event category
- Jadwal dan lokasi
- Pengelolaan ticket
- Monitoring peserta
### Ticket Management
- Jenis ticket
- Harga ticket
- Kuota
- Penjualan
- Check-in
- Monitoring ticket
### Content Management
- News
- Artikel
- Foto
- Video
- Banner
- Informasi/agenda
### Partner Management
- Data partner
- Profil partner
- Kategori partner
- Status partnership
### Benefit Management
- Benefit
- Promo
- Campaign
- Periode benefit
- Mekanisme redemption
### User & Role Management
Pengelolaan user internal dan hak akses berdasarkan role.
Contoh role:
- Super Admin
- Admin
- Event/Panitia
- Content Admin
- Partnership Admin
- Reporting
---
# 3. Core Feature
## 3.1 Membership
Alur utama:
```text
Registrasi
↓
Verifikasi
↓
Data Anggota
↓
Membership Aktif
↓
Digital Membership Card
```
## 3.2 Event & Ticketing
Alur utama:
```text
Admin membuat Event
↓
Publish
↓
User melihat Event
↓
Membeli Ticket
↓
Payment
↓
E-Ticket + QR
↓
Check-in
↓
Reporting
```
## 3.3 Partnership & Benefit
Alur utama:
```text
Partner
↓
Pengajuan Partnership
↓
Verifikasi
↓
Kerja Sama
↓
Campaign / Benefit
↓
Digunakan Anggota
↓
Reporting
```
## 3.4 Media
Konten dapat digunakan untuk menyampaikan:
- Berita IMI Jawa Barat
- Informasi event
- Artikel otomotif
- Dokumentasi kegiatan
- Informasi partner
- Pengumuman
## 3.5 Notification
Notification menjadi media komunikasi antara IMI Jawa Barat dengan user.
---
# 4. User Flow
## 4.1 Registration
```text
User
↓
Registrasi
↓
Verifikasi
↓
Lengkapi Profil
↓
Membership
↓
Masuk Home
```
## 4.2 Ticket Purchase
```text
User
↓
Pilih Event
↓
Lihat Detail
↓
Pilih Ticket
↓
Pembayaran
↓
Ticket Berhasil
↓
QR Ticket
```
## 4.3 Event Check-in
```text
User datang ke Event
↓
Menunjukkan QR Ticket
↓
Panitia Scan QR
↓
Validasi Ticket
↓
Check-in Berhasil
```
## 4.4 Benefit Redemption
```text
User
↓
Pilih Benefit
↓
Lihat Detail
↓
Redeem
↓
Validasi oleh Partner
↓
Benefit Digunakan
```
---
# 5. Architecture
Secara sederhana arsitektur aplikasi:
```text
┌──────────────────────┐
│ Mobile App │
│ Android / iOS │
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ Web Mobile / Public │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Backend / API │
│ Business Logic │
└──────────┬───────────┘
│
┌─────┴─────┐
▼ ▼
┌─────────┐ ┌──────────────┐
│Database │ │File Storage │
└─────────┘ └──────────────┘
│
┌─────┼──────────────┐
▼ ▼ ▼
Payment Notification Analytics
Gateway / FCM
┌──────────────────────┐
│ Web Admin │
└──────────┬───────────┘
│
└──────> Backend / API
```
Prinsip utama:
- Mobile App dan Web menggunakan backend yang sama.
- Business logic berada di backend.
- Data terpusat.
- File/media menggunakan storage terpisah.
- Integrasi eksternal menggunakan API.
- Web Admin menggunakan Role Based Access Control (RBAC).
---
# 6. Database Schema
PRD ini hanya mendefinisikan entity utama. Detail field dan ERD dibuat pada dokumen teknis berikutnya.
### User & Membership
```text
User
├── Member
│ └── Membership Card
│
└── User Role
```
### Event & Ticket
```text
Event
├── Event Category
├── Event Ticket
│ └── Ticket
│ └── Check-in
```
### Partnership
```text
Partner
├── Partnership
├── Campaign
└── Benefit
└── Redemption
```
### Content
```text
Content
├── Article / News
├── Photo
├── Video
└── Banner
```
### Supporting
```text
User
├── Notification
└── Audit Log
```
### Entity Utama
| Entity | Fungsi |
|---|---|
| User | Akun pengguna |
| Member | Data anggota IMI |
| Membership Card | Kartu digital anggota |
| Event | Data event |
| Event Ticket | Jenis/kelas ticket |
| Ticket | Ticket milik user |
| Check-in | Riwayat kehadiran |
| Partner | Data partner |
| Partnership | Data kerja sama |
| Benefit | Benefit/promo partner |
| Redemption | Penggunaan benefit |
| Content | Konten platform |
| Notification | Notifikasi user |
| User Role | Hak akses user |
| Audit Log | Catatan aktivitas sistem |
---
# 7. Tech Stack
Stack berikut merupakan rekomendasi awal dan masih dapat disesuaikan dengan kondisi tim dan infrastruktur.
| Layer | Recommendation |
|---|---|
| Mobile | Flutter |
| Public Web | Next.js / React |
| Admin Web | React + TypeScript |
| Backend | NestJS + TypeScript |
| Database | PostgreSQL |
| File Storage | Object Storage |
| Push Notification | Firebase Cloud Messaging |
| Payment | Payment Gateway |
| QR | QR Code / QR Scanner |
| Analytics | Analytics Platform |
| Monitoring | Application Monitoring |
Prinsip pemilihan teknologi:
- Satu backend untuk seluruh platform.
- Mudah dikembangkan dan dipelihara.
- Mendukung integrasi dengan layanan eksternal.
- Scalable untuk pertumbuhan jumlah anggota dan event.
---
# 8. Scope Awal — MVP
## Mobile App
MVP mencakup:
- Login & Registration
- Home
- Membership
- Digital Membership Card
- Event
- Ticket
- QR Ticket
- Media
- Notification
- Profile
## Web Public
- Home
- Event
- Event Detail
- Media
- Informasi IMI
- Informasi Membership
## Web Admin
- Login
- Dashboard
- Member Management
- Event Management
- Ticket Management
- Content Management
- User & Role Management
## Fase Berikutnya
Fitur berikut dapat dikembangkan setelah MVP:
- Partner Management
- Partnership
- Benefit
- Benefit Redemption
- Campaign
- Loyalty
- Advanced Reporting
- Partner Self Service
- Fitur CRM Partnership
---
# 9. Catatan
Dokumen ini merupakan **PRD tingkat tinggi** yang digunakan sebagai dasar alignment antara:
- Product Owner
- Business / Stakeholder
- UI/UX
- Development
- QA
PRD ini **belum mengunci detail teknis maupun desain**, seperti:
- Detail UI
- Detail field database
- API specification
- Business rule detail
- Role & permission detail
- Payment flow detail
- Error handling
- Security requirement
- Performance requirement
Dokumen lanjutan yang dapat dibuat setelah PRD:
1. UI/UX Requirement
2. User Flow Detail
3. Functional Specification
4. Database ERD
5. API Specification
6. Role & Permission Matrix
7. Business Rules
8. UAT / Test Scenario
/Users/trendy/Documents/IMIapps/backend/.venv/lib/python3.9/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020
warnings.warn(
Operations to perform:
Apply all migrations: accounts, admin, auth, content, contenttypes, events, finance, notifications, partners, sessions, ticketing
Running migrations:
Applying contenttypes.0001_initial... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0001_initial... OK
Applying auth.0002_alter_permission_name_max_length... OK
Applying auth.0003_alter_user_email_max_length... OK
Applying auth.0004_alter_user_username_opts... OK
Applying auth.0005_alter_user_last_login_null... OK
Applying auth.0006_require_contenttypes_0002... OK
Applying auth.0007_alter_validators_add_error_messages... OK
Applying auth.0008_alter_user_username_max_length... OK
Applying auth.0009_alter_user_last_name_max_length... OK
Applying auth.0010_alter_group_name_max_length... OK
Applying auth.0011_update_proxy_permissions... OK
Applying auth.0012_alter_user_first_name_max_length... OK
Applying accounts.0001_initial... OK
Applying admin.0001_initial... OK
Applying admin.0002_logentry_remove_auto_add... OK
Applying admin.0003_logentry_add_action_flag_choices... OK
Applying events.0001_initial... OK
Applying content.0001_initial... OK
Applying content.0002_advertisement... OK
Applying finance.0001_initial... OK
Applying notifications.0001_initial... OK
Applying partners.0001_initial... OK
Applying partners.0002_alter_redemption_member_voucher... OK
Applying sessions.0001_initial... OK
Applying ticketing.0001_initial... OK
Applying ticketing.0002_payment_payment_type_payment_qr_string_and_more... OK
Applying ticketing.0003_order_app_fee_amount_order_imi_fee_amount... OK
/Users/trendy/Documents/IMIapps/backend/.venv/lib/python3.9/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020
warnings.warn(
/Users/trendy/Documents/IMIapps/backend/.venv/lib/python3.9/site-packages/django/db/models/fields/__init__.py:1535: RuntimeWarning: DateTimeField Event.sale_end_at received a naive datetime (2026-10-13 00:00:00) while time zone support is active.
warnings.warn(
Pengcab: 27 (baru 27)
Admin dibuat: +6281200000001 / Admin123!
Demo member: +6281200000002 / Member123! (GOLD, 1.450 poin)
Seed selesai.
"""Independent PRD probes. Run from backend with audit directory on PYTHONPATH."""
from rest_framework.test import APITestCase, APIClient
from apps.common.testing import make_staff, make_user
from apps.accounts.models import AuditLog
from apps.ticketing.tests import _event_fixtures, SNAP_CREATE, _settled_status, _signed_notification
from apps.common.testing import make_member, member_client
from apps.ticketing.midtrans import client as gateway
from apps.ticketing.models import Order, Ticket
from unittest.mock import patch
class FinancePRDProbes(APITestCase):
def setUp(self):
self.staff = make_staff()
self.client.force_authenticate(self.staff)
self.url = '/api/v1/finance/pricing-config/'
self.cfg = dict(app_fee_pct=5, imi_fee_pct=3, tax_rate_pct=10, service_fee=5000)
def test_negative_share_rejected(self):
r = self.client.put(self.url, {**self.cfg, 'app_fee_pct': -5}, format='json')
self.assertEqual(r.status_code, 400, r.data)
def test_invalid_allocation_rejected(self):
r = self.client.put(self.url, {**self.cfg, 'app_fee_pct': 60, 'imi_fee_pct': 45}, format='json')
self.assertEqual(r.status_code, 400, r.data)
def test_content_staff_cannot_configure_finance(self):
self.client.force_authenticate(make_user('+6281555500001', role='content'))
r = self.client.put(self.url, self.cfg, format='json')
self.assertEqual(r.status_code, 403, r.data)
def test_reporting_staff_cannot_configure_finance(self):
self.client.force_authenticate(make_user('+6281555500002', role='reporting'))
r = self.client.put(self.url, self.cfg, format='json')
self.assertEqual(r.status_code, 403, r.data)
def test_negative_price_rejected(self):
r = self.client.post(self.url + 'preview/', {'price': -100000}, format='json')
self.assertEqual(r.status_code, 400, r.data)
def test_price_update_has_audit(self):
before = AuditLog.objects.count()
r = self.client.put(self.url, {**self.cfg, 'app_fee_pct': 7}, format='json')
self.assertEqual(r.status_code, 200)
self.assertGreater(AuditLog.objects.count(), before)
def test_required_finance_routes_exist(self):
for path in ('revenue-sharing/models/', 'transactions/', 'settlements/', 'reconciliations/', 'reports/revenue/', 'reports/settlement/', 'reports/reconciliation/'):
with self.subTest(path=path):
self.assertNotEqual(self.client.get('/api/v1/' + path).status_code, 404)
class TicketIntegrityProbes(APITestCase):
def setUp(self):
self.staff = make_staff()
self.user, self.member = make_member('+6281555566666')
self.event, self.tier, _, self.session = _event_fixtures()
self.client = member_client(self.user.phone)
def order(self, lines=None):
r = self.client.post('/api/v1/orders/', {'event_id': self.event.id, 'terms_accepted': True, 'lines': lines or [{'tier_id': self.tier.id, 'quantity': 1}]}, format='json')
self.assertEqual(r.status_code, 201, r.data)
return Order.objects.get(pk=r.data['id'])
def pay(self, order):
with patch.object(gateway, 'create_transaction', return_value=SNAP_CREATE):
self.client.post(f'/api/v1/orders/{order.pk}/payment/', {}, format='json')
self.client.post('/api/v1/payments/midtrans/notification', _signed_notification(order, _settled_status()), format='json')
def test_refunded_ticket_cannot_check_in(self):
order = self.order()
self.pay(order)
self.client.post('/api/v1/payments/midtrans/notification', _signed_notification(order, {'transaction_status': 'refund', 'status_code': '200'}), format='json')
ticket = Ticket.objects.get(order=order)
self.client.force_authenticate(self.staff)
r = self.client.post('/api/v1/scan/check-in', {'scan_code': ticket.qr_token}, format='json')
self.assertEqual(r.data['status'], 'rejected', r.data)
def test_duplicate_lines_do_not_oversell(self):
self.tier.quota = 3
self.tier.save()
r = self.client.post('/api/v1/orders/', {'event_id': self.event.id, 'terms_accepted': True, 'lines': [{'tier_id': self.tier.id, 'quantity': 2}, {'tier_id': self.tier.id, 'quantity': 2}]}, format='json')
self.assertEqual(r.status_code, 400, r.data)
def test_paid_order_late_expire_does_not_change_payment(self):
order = self.order()
self.pay(order)
self.client.post('/api/v1/payments/midtrans/notification', _signed_notification(order, {'transaction_status': 'expire', 'status_code': '407'}), format='json')
order.refresh_from_db()
self.assertEqual(order.status, 'paid')
self.assertEqual(order.payment.status, 'verified')
def test_signed_wrong_amount_not_accepted(self):
import hashlib
from django.conf import settings
order = self.order()
with patch.object(gateway, 'create_transaction', return_value=SNAP_CREATE):
self.client.post(f'/api/v1/orders/{order.pk}/payment/', {}, format='json')
payload = _signed_notification(order, _settled_status())
payload['gross_amount'] = '1'
payload['signature_key'] = hashlib.sha512(f"{order.code}{payload['status_code']}1{settings.MIDTRANS_SERVER_KEY}".encode()).hexdigest()
self.client.post('/api/v1/payments/midtrans/notification', payload, format='json')
order.refresh_from_db()
self.assertNotEqual(order.status, 'paid')
def test_draft_event_not_publicly_retrievable(self):
self.event.status = 'draft'
self.event.save()
r = APIClient().get(f'/api/v1/events/{self.event.slug}/')
self.assertEqual(r.status_code, 404)
def test_event_staff_can_create_event(self):
self.client.force_authenticate(make_user('+6281555500003', role='event'))
r = self.client.post('/api/v1/events/', {'title': 'Role Event Audit', 'slug': 'role-event-audit', 'category': self.event.category_id, 'start_date': str(self.event.start_date), 'end_date': str(self.event.end_date), 'status': 'draft'}, format='json')
self.assertEqual(r.status_code, 201, r.data)
def test_order_requires_terms(self):
r = self.client.post('/api/v1/orders/', {'event_id': self.event.id, 'terms_accepted': False, 'lines': [{'tier_id': self.tier.id, 'quantity': 1}]}, format='json')
self.assertEqual(r.status_code, 400, r.data)
/Users/trendy/Documents/IMIapps/backend/.venv/lib/python3.9/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020
warnings.warn(
Creating test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')...
Found 47 test(s).
Operations to perform:
Synchronize unmigrated apps: common, corsheaders, django_filters, drf_spectacular, messages, rest_framework, staticfiles
Apply all migrations: accounts, admin, auth, content, contenttypes, events, finance, notifications, partners, sessions, ticketing
Synchronizing apps without migrations:
Creating tables...
Running deferred SQL...
Running migrations:
Applying contenttypes.0001_initial... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0001_initial... OK
Applying auth.0002_alter_permission_name_max_length... OK
Applying auth.0003_alter_user_email_max_length... OK
Applying auth.0004_alter_user_username_opts... OK
Applying auth.0005_alter_user_last_login_null... OK
Applying auth.0006_require_contenttypes_0002... OK
Applying auth.0007_alter_validators_add_error_messages... OK
Applying auth.0008_alter_user_username_max_length... OK
Applying auth.0009_alter_user_last_name_max_length... OK
Applying auth.0010_alter_group_name_max_length... OK
Applying auth.0011_update_proxy_permissions... OK
Applying auth.0012_alter_user_first_name_max_length... OK
Applying accounts.0001_initial... OK
Applying admin.0001_initial... OK
Applying admin.0002_logentry_remove_auto_add... OK
Applying admin.0003_logentry_add_action_flag_choices... OK
Applying events.0001_initial... OK
Applying content.0001_initial... OK
Applying content.0002_advertisement... OK
Applying finance.0001_initial... OK
Applying notifications.0001_initial... OK
Applying partners.0001_initial... OK
Applying partners.0002_alter_redemption_member_voucher... OK
Applying sessions.0001_initial... OK
Applying ticketing.0001_initial... OK
Applying ticketing.0002_payment_payment_type_payment_qr_string_and_more... OK
Applying ticketing.0003_order_app_fee_amount_order_imi_fee_amount... OK
System check identified no issues (0 silenced).
test_login_salah_password (apps.accounts.tests.AuthFlowTests) ... ok
test_register_verify_otp_login_me (apps.accounts.tests.AuthFlowTests) ... ok
test_register_wajib_match_password (apps.accounts.tests.AuthFlowTests) ... ok
test_register_wajib_terms (apps.accounts.tests.AuthFlowTests) ... ok
test_kartu_pengcab_terexpose (apps.accounts.tests.ProfileTests) ... ok
test_patch_profile (apps.accounts.tests.ProfileTests) ... ok
test_dashboard_finance_aggregates (apps.accounts.tests.StaffTests) ... ok
test_dashboard_stats_hanya_staff (apps.accounts.tests.StaffTests) ... ok
test_revenue_total_dihitung_per_order_paid (apps.accounts.tests.StaffTests) ... ok
test_crud_iklan (apps.content.tests.AdTests) ... ok
test_manage_perlu_staff (apps.content.tests.AdTests) ... ok
test_nonaktif_tidak_muncul_publik_setelah_update (apps.content.tests.AdTests) ... ok
test_public_hanya_iklan_aktif_urut_ordinal (apps.content.tests.AdTests) ... ok
test_articles_manage_perlu_staff (apps.content.tests.ContentTests) ... ok
test_artikel_publik_dan_baca_meningkat (apps.content.tests.ContentTests) ... ok
test_artikel_publish_otomatis_set_published_at (apps.content.tests.ContentTests) ... ok
test_banner_dan_kategori_publik (apps.content.tests.ContentTests) ... ok
test_create_event_payload_webadmin (apps.events.tests.EventAdminWriteTests) ... ok
test_patch_event_membersihkan_datetime_nan (apps.events.tests.EventAdminWriteTests) ... ok
test_admin_list_melihat_semua_status_dan_fungsi_filter (apps.events.tests.EventListVisibilityTests) ... ok
test_public_list_hanya_status_publik (apps.events.tests.EventListVisibilityTests) ... ok
test_member_tanpa_role_tidak_boleh_membuat_tier (apps.events.tests.TicketTierAdminWriteTests) ... ok
test_staff_bisa_create_patch_delete_tier (apps.events.tests.TicketTierAdminWriteTests) ... ok
test_get_default_config (apps.finance.tests.PricingConfigTests) ... ok
test_member_tidak_bisa_akses (apps.finance.tests.PricingConfigTests) ... ok
test_preview_ikut_diskon (apps.finance.tests.PricingConfigTests) ... ok
test_preview_rumus_harga_jual (apps.finance.tests.PricingConfigTests) ... ok
test_update_config (apps.finance.tests.PricingConfigTests) ... ok
test_inbox_milik_pengguna_sendiri (apps.notifications.tests.NotificationTests) ... ok
test_inbox_unread_dan_mark_read (apps.notifications.tests.NotificationTests) ... ok
test_preferensi_get_patch (apps.notifications.tests.NotificationTests) ... ok
test_klaim_dan_redeem_voucher (apps.partners.tests.WalletRedemptionTests) ... ok
test_klaim_tanpa_cukup_poin (apps.partners.tests.WalletRedemptionTests) ... ok
test_redeem_duplikat_lalu_kode_tak_dikenal (apps.partners.tests.WalletRedemptionTests) ... ok
test_redeem_wajib_staff (apps.partners.tests.WalletRedemptionTests) ... ok
test_wallet_balance_default (apps.partners.tests.WalletRedemptionTests) ... ok
test_scan_duplikat_dan_tak_dikenal (apps.ticketing.tests.CheckInTests) ... ok
test_scan_memakai_hash_code (apps.ticketing.tests.CheckInTests) ... ok
test_scan_tanpa_auth_ditolak (apps.ticketing.tests.CheckInTests) ... ok
test_full_checkout_flow (apps.ticketing.tests.CheckoutFlowTests) ... ok
test_pembatalan_order_melepas_kuota (apps.ticketing.tests.CheckoutFlowTests) ... ok
test_requires_license_ditolak_untuk_member_nonverified (apps.ticketing.tests.CheckoutFlowTests) ... ok
test_confirm_tetap_pending_menghasilkan_400 (apps.ticketing.tests.MidtransNotificationTests) ... ok
test_webhook_expire_melepas_kuota (apps.ticketing.tests.MidtransNotificationTests) ... ok
test_webhook_idempoten (apps.ticketing.tests.MidtransNotificationTests) ... ok
test_webhook_settlement_terbitkan_tiket (apps.ticketing.tests.MidtransNotificationTests) ... ok
test_webhook_signature_keliru_ditolak (apps.ticketing.tests.MidtransNotificationTests) ... ok
----------------------------------------------------------------------
Ran 47 tests in 17.581s
OK
Destroying test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')...
/Users/trendy/Documents/IMIapps/backend/.venv/lib/python3.9/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020
warnings.warn(
Creating test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')...
Found 46 test(s).
Operations to perform:
Synchronize unmigrated apps: common, corsheaders, django_filters, drf_spectacular, messages, rest_framework, staticfiles
Apply all migrations: accounts, admin, auth, content, contenttypes, events, finance, notifications, partners, sessions, ticketing
Synchronizing apps without migrations:
Creating tables...
Running deferred SQL...
Running migrations:
Applying contenttypes.0001_initial... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0001_initial... OK
Applying auth.0002_alter_permission_name_max_length... OK
Applying auth.0003_alter_user_email_max_length... OK
Applying auth.0004_alter_user_username_opts... OK
Applying auth.0005_alter_user_last_login_null... OK
Applying auth.0006_require_contenttypes_0002... OK
Applying auth.0007_alter_validators_add_error_messages... OK
Applying auth.0008_alter_user_username_max_length... OK
Applying auth.0009_alter_user_last_name_max_length... OK
Applying auth.0010_alter_group_name_max_length... OK
Applying auth.0011_update_proxy_permissions... OK
Applying auth.0012_alter_user_first_name_max_length... OK
Applying accounts.0001_initial... OK
Applying admin.0001_initial... OK
Applying admin.0002_logentry_remove_auto_add... OK
Applying admin.0003_logentry_add_action_flag_choices... OK
Applying events.0001_initial... OK
Applying content.0001_initial... OK
Applying content.0002_advertisement... OK
Applying finance.0001_initial... OK
Applying notifications.0001_initial... OK
Applying partners.0001_initial... OK
Applying partners.0002_alter_redemption_member_voucher... OK
Applying sessions.0001_initial... OK
Applying ticketing.0001_initial... OK
Applying ticketing.0002_payment_payment_type_payment_qr_string_and_more... OK
Applying ticketing.0003_order_app_fee_amount_order_imi_fee_amount... OK
System check identified no issues (0 silenced).
test_login_salah_password (apps.accounts.tests.AuthFlowTests) ... ok
test_register_verify_otp_login_me (apps.accounts.tests.AuthFlowTests) ... ok
test_register_wajib_match_password (apps.accounts.tests.AuthFlowTests) ... ok
test_register_wajib_terms (apps.accounts.tests.AuthFlowTests) ... ok
test_kartu_pengcab_terexpose (apps.accounts.tests.ProfileTests) ... ok
test_patch_profile (apps.accounts.tests.ProfileTests) ... ok
test_dashboard_stats_hanya_staff (apps.accounts.tests.StaffTests) ... ok
test_revenue_total_dihitung_per_order_paid (apps.accounts.tests.StaffTests) ... ok
test_crud_iklan (apps.content.tests.AdTests) ... ok
test_manage_perlu_staff (apps.content.tests.AdTests) ... ok
test_nonaktif_tidak_muncul_publik_setelah_update (apps.content.tests.AdTests) ... ok
test_public_hanya_iklan_aktif_urut_ordinal (apps.content.tests.AdTests) ... ok
test_articles_manage_perlu_staff (apps.content.tests.ContentTests) ... ok
test_artikel_publik_dan_baca_meningkat (apps.content.tests.ContentTests) ... ok
test_artikel_publish_otomatis_set_published_at (apps.content.tests.ContentTests) ... ok
test_banner_dan_kategori_publik (apps.content.tests.ContentTests) ... ok
test_create_event_payload_webadmin (apps.events.tests.EventAdminWriteTests) ... ok
test_patch_event_membersihkan_datetime_nan (apps.events.tests.EventAdminWriteTests) ... ok
test_admin_list_melihat_semua_status_dan_fungsi_filter (apps.events.tests.EventListVisibilityTests) ... ok
test_public_list_hanya_status_publik (apps.events.tests.EventListVisibilityTests) ... ok
test_member_tanpa_role_tidak_boleh_membuat_tier (apps.events.tests.TicketTierAdminWriteTests) ... ok
test_staff_bisa_create_patch_delete_tier (apps.events.tests.TicketTierAdminWriteTests) ... ok
test_get_default_config (apps.finance.tests.PricingConfigTests) ... ok
test_member_tidak_bisa_akses (apps.finance.tests.PricingConfigTests) ... ok
test_preview_ikut_diskon (apps.finance.tests.PricingConfigTests) ... ok
test_preview_rumus_harga_jual (apps.finance.tests.PricingConfigTests) ... ok
test_update_config (apps.finance.tests.PricingConfigTests) ... ok
test_inbox_milik_pengguna_sendiri (apps.notifications.tests.NotificationTests) ... ok
test_inbox_unread_dan_mark_read (apps.notifications.tests.NotificationTests) ... ok
test_preferensi_get_patch (apps.notifications.tests.NotificationTests) ... ok
test_klaim_dan_redeem_voucher (apps.partners.tests.WalletRedemptionTests) ... ok
test_klaim_tanpa_cukup_poin (apps.partners.tests.WalletRedemptionTests) ... ok
test_redeem_duplikat_lalu_kode_tak_dikenal (apps.partners.tests.WalletRedemptionTests) ... ok
test_redeem_wajib_staff (apps.partners.tests.WalletRedemptionTests) ... ok
test_wallet_balance_default (apps.partners.tests.WalletRedemptionTests) ... ok
test_scan_duplikat_dan_tak_dikenal (apps.ticketing.tests.CheckInTests) ... ok
test_scan_memakai_hash_code (apps.ticketing.tests.CheckInTests) ... ok
test_scan_tanpa_auth_ditolak (apps.ticketing.tests.CheckInTests) ... ok
test_full_checkout_flow (apps.ticketing.tests.CheckoutFlowTests) ... ok
test_pembatalan_order_melepas_kuota (apps.ticketing.tests.CheckoutFlowTests) ... ok
test_requires_license_ditolak_untuk_member_nonverified (apps.ticketing.tests.CheckoutFlowTests) ... ok
test_confirm_tetap_pending_menghasilkan_400 (apps.ticketing.tests.MidtransNotificationTests) ... ok
test_webhook_expire_melepas_kuota (apps.ticketing.tests.MidtransNotificationTests) ... ok
test_webhook_idempoten (apps.ticketing.tests.MidtransNotificationTests) ... ok
test_webhook_settlement_terbitkan_tiket (apps.ticketing.tests.MidtransNotificationTests) ... ok
test_webhook_signature_keliru_ditolak (apps.ticketing.tests.MidtransNotificationTests) ... ok
----------------------------------------------------------------------
Ran 46 tests in 17.077s
OK
Destroying test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')...
"""Local isolated audit server; payment gateway responses are explicitly simulated."""
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[3] / 'backend'))
os.environ['DJANGO_SETTINGS_MODULE'] = 'config.settings'
os.environ['DATABASE_URL'] = 'sqlite:////private/tmp/imi-audit-20261007.sqlite3'
import django
django.setup()
from apps.ticketing.midtrans import client
client.create_transaction = lambda order: {'token': 'AUDIT-SIMULATED', 'redirect_url': 'http://127.0.0.1:5300/#audit-payment', 'transaction_id': 'audit-' + order.code}
client.check_transaction_status = lambda order: {'transaction_status': 'settlement', 'fraud_status': 'accept', 'status_code': '200', 'payment_type': 'bank_transfer', 'transaction_id': 'audit-' + order.code}
from django.core.management import call_command
call_command('runserver', '127.0.0.1:8100', use_reloader=False)
Analyzing mobile...
No issues found! (ran in 1.2s)
00:00 +0: loading /Users/trendy/Documents/IMIapps/mobile/test/widget_test.dart 00:01 +0: loading /Users/trendy/Documents/IMIapps/mobile/test/widget_test.dart 00:01 +0: App smoke test 00:02 +0: App smoke test 00:02 +1: App smoke test 00:02 +1: All tests passed!
Compiling lib/main.dart for the Web...
Font asset "CupertinoIcons.ttf" was tree-shaken, reducing it from 257628 to 1172 bytes (99.5% reduction). Tree-shaking can be disabled by providing the --no-tree-shake-icons flag when building your app.
Font asset "MaterialIcons-Regular.otf" was tree-shaken, reducing it from 1645184 to 16784 bytes (99.0% reduction). Tree-shaking can be disabled by providing the --no-tree-shake-icons flag when building your app.
Compiling lib/main.dart for the Web... 17.0s
✓ Built build/web
/Users/trendy/Documents/IMIapps/backend/.venv/lib/python3.9/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020
warnings.warn(
Creating test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')...
Found 14 test(s).
Operations to perform:
Synchronize unmigrated apps: common, corsheaders, django_filters, drf_spectacular, messages, rest_framework, staticfiles
Apply all migrations: accounts, admin, auth, content, contenttypes, events, finance, notifications, partners, sessions, ticketing
Synchronizing apps without migrations:
Creating tables...
Running deferred SQL...
Running migrations:
Applying contenttypes.0001_initial... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0001_initial... OK
Applying auth.0002_alter_permission_name_max_length... OK
Applying auth.0003_alter_user_email_max_length... OK
Applying auth.0004_alter_user_username_opts... OK
Applying auth.0005_alter_user_last_login_null... OK
Applying auth.0006_require_contenttypes_0002... OK
Applying auth.0007_alter_validators_add_error_messages... OK
Applying auth.0008_alter_user_username_max_length... OK
Applying auth.0009_alter_user_last_name_max_length... OK
Applying auth.0010_alter_group_name_max_length... OK
Applying auth.0011_update_proxy_permissions... OK
Applying auth.0012_alter_user_first_name_max_length... OK
Applying accounts.0001_initial... OK
Applying admin.0001_initial... OK
Applying admin.0002_logentry_remove_auto_add... OK
Applying admin.0003_logentry_add_action_flag_choices... OK
Applying events.0001_initial... OK
Applying content.0001_initial... OK
Applying content.0002_advertisement... OK
Applying finance.0001_initial... OK
Applying notifications.0001_initial... OK
Applying partners.0001_initial... OK
Applying partners.0002_alter_redemption_member_voucher... OK
Applying sessions.0001_initial... OK
Applying ticketing.0001_initial... OK
Applying ticketing.0002_payment_payment_type_payment_qr_string_and_more... OK
Applying ticketing.0003_order_app_fee_amount_order_imi_fee_amount... OK
System check identified no issues (0 silenced).
test_content_staff_cannot_configure_finance (audit_probes.FinancePRDProbes) ... FAIL
test_invalid_allocation_rejected (audit_probes.FinancePRDProbes) ... FAIL
test_negative_price_rejected (audit_probes.FinancePRDProbes) ... FAIL
test_negative_share_rejected (audit_probes.FinancePRDProbes) ... FAIL
test_price_update_has_audit (audit_probes.FinancePRDProbes) ... FAIL
test_reporting_staff_cannot_configure_finance (audit_probes.FinancePRDProbes) ... FAIL
test_required_finance_routes_exist (audit_probes.FinancePRDProbes) ... test_draft_event_not_publicly_retrievable (audit_probes.TicketIntegrityProbes) ... FAIL
test_duplicate_lines_do_not_oversell (audit_probes.TicketIntegrityProbes) ... FAIL
test_event_staff_can_create_event (audit_probes.TicketIntegrityProbes) ... FAIL
test_order_requires_terms (audit_probes.TicketIntegrityProbes) ... FAIL
test_paid_order_late_expire_does_not_change_payment (audit_probes.TicketIntegrityProbes) ... FAIL
test_refunded_ticket_cannot_check_in (audit_probes.TicketIntegrityProbes) ... FAIL
test_signed_wrong_amount_not_accepted (audit_probes.TicketIntegrityProbes) ... FAIL
======================================================================
FAIL: test_content_staff_cannot_configure_finance (audit_probes.FinancePRDProbes)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/trendy/Documents/IMIapps/docs/audit/2026-10-07/audit_probes.py", line 29, in test_content_staff_cannot_configure_finance
self.assertEqual(r.status_code, 403, r.data)
AssertionError: 200 != 403 : {'id': 1, 'app_fee_pct': Decimal('5.00'), 'imi_fee_pct': Decimal('3.00'), 'tax_rate_pct': Decimal('10.00'), 'service_fee': 5000, 'updated_at': '2026-10-07T16:35:26+0700'}
======================================================================
FAIL: test_invalid_allocation_rejected (audit_probes.FinancePRDProbes)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/trendy/Documents/IMIapps/docs/audit/2026-10-07/audit_probes.py", line 24, in test_invalid_allocation_rejected
self.assertEqual(r.status_code, 400, r.data)
AssertionError: 200 != 400 : {'id': 1, 'app_fee_pct': Decimal('60.00'), 'imi_fee_pct': Decimal('45.00'), 'tax_rate_pct': Decimal('10.00'), 'service_fee': 5000, 'updated_at': '2026-10-07T16:35:26+0700'}
======================================================================
FAIL: test_negative_price_rejected (audit_probes.FinancePRDProbes)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/trendy/Documents/IMIapps/docs/audit/2026-10-07/audit_probes.py", line 38, in test_negative_price_rejected
self.assertEqual(r.status_code, 400, r.data)
AssertionError: 200 != 400 : {'price': -100000, 'discount_amount': 0, 'app_fee_pct': 5.0, 'imi_fee_pct': 3.0, 'tax_rate_pct': 10.0, 'service_fee': 5000, 'base': -100000, 'taxable': 0, 'app_fee': 0, 'imi_fee': 0, 'tax_pct': 10.0, 'tax_amount': 0, 'total': 5000}
======================================================================
FAIL: test_negative_share_rejected (audit_probes.FinancePRDProbes)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/trendy/Documents/IMIapps/docs/audit/2026-10-07/audit_probes.py", line 20, in test_negative_share_rejected
self.assertEqual(r.status_code, 400, r.data)
AssertionError: 200 != 400 : {'id': 1, 'app_fee_pct': Decimal('-5.00'), 'imi_fee_pct': Decimal('3.00'), 'tax_rate_pct': Decimal('10.00'), 'service_fee': 5000, 'updated_at': '2026-10-07T16:35:27+0700'}
======================================================================
FAIL: test_price_update_has_audit (audit_probes.FinancePRDProbes)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/trendy/Documents/IMIapps/docs/audit/2026-10-07/audit_probes.py", line 44, in test_price_update_has_audit
self.assertGreater(AuditLog.objects.count(), before)
AssertionError: 0 not greater than 0
======================================================================
FAIL: test_reporting_staff_cannot_configure_finance (audit_probes.FinancePRDProbes)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/trendy/Documents/IMIapps/docs/audit/2026-10-07/audit_probes.py", line 34, in test_reporting_staff_cannot_configure_finance
self.assertEqual(r.status_code, 403, r.data)
AssertionError: 200 != 403 : {'id': 1, 'app_fee_pct': Decimal('5.00'), 'imi_fee_pct': Decimal('3.00'), 'tax_rate_pct': Decimal('10.00'), 'service_fee': 5000, 'updated_at': '2026-10-07T16:35:27+0700'}
======================================================================
FAIL: test_required_finance_routes_exist (audit_probes.FinancePRDProbes) (path='revenue-sharing/models/')
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/trendy/Documents/IMIapps/docs/audit/2026-10-07/audit_probes.py", line 49, in test_required_finance_routes_exist
self.assertNotEqual(self.client.get('/api/v1/' + path).status_code, 404)
AssertionError: 404 == 404
======================================================================
FAIL: test_required_finance_routes_exist (audit_probes.FinancePRDProbes) (path='transactions/')
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/trendy/Documents/IMIapps/docs/audit/2026-10-07/audit_probes.py", line 49, in test_required_finance_routes_exist
self.assertNotEqual(self.client.get('/api/v1/' + path).status_code, 404)
AssertionError: 404 == 404
======================================================================
FAIL: test_required_finance_routes_exist (audit_probes.FinancePRDProbes) (path='settlements/')
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/trendy/Documents/IMIapps/docs/audit/2026-10-07/audit_probes.py", line 49, in test_required_finance_routes_exist
self.assertNotEqual(self.client.get('/api/v1/' + path).status_code, 404)
AssertionError: 404 == 404
======================================================================
FAIL: test_required_finance_routes_exist (audit_probes.FinancePRDProbes) (path='reconciliations/')
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/trendy/Documents/IMIapps/docs/audit/2026-10-07/audit_probes.py", line 49, in test_required_finance_routes_exist
self.assertNotEqual(self.client.get('/api/v1/' + path).status_code, 404)
AssertionError: 404 == 404
======================================================================
FAIL: test_required_finance_routes_exist (audit_probes.FinancePRDProbes) (path='reports/revenue/')
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/trendy/Documents/IMIapps/docs/audit/2026-10-07/audit_probes.py", line 49, in test_required_finance_routes_exist
self.assertNotEqual(self.client.get('/api/v1/' + path).status_code, 404)
AssertionError: 404 == 404
======================================================================
FAIL: test_required_finance_routes_exist (audit_probes.FinancePRDProbes) (path='reports/settlement/')
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/trendy/Documents/IMIapps/docs/audit/2026-10-07/audit_probes.py", line 49, in test_required_finance_routes_exist
self.assertNotEqual(self.client.get('/api/v1/' + path).status_code, 404)
AssertionError: 404 == 404
======================================================================
FAIL: test_required_finance_routes_exist (audit_probes.FinancePRDProbes) (path='reports/reconciliation/')
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/trendy/Documents/IMIapps/docs/audit/2026-10-07/audit_probes.py", line 49, in test_required_finance_routes_exist
self.assertNotEqual(self.client.get('/api/v1/' + path).status_code, 404)
AssertionError: 404 == 404
======================================================================
FAIL: test_draft_event_not_publicly_retrievable (audit_probes.TicketIntegrityProbes)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/trendy/Documents/IMIapps/docs/audit/2026-10-07/audit_probes.py", line 108, in test_draft_event_not_publicly_retrievable
self.assertEqual(r.status_code, 404)
AssertionError: 200 != 404
======================================================================
FAIL: test_duplicate_lines_do_not_oversell (audit_probes.TicketIntegrityProbes)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/trendy/Documents/IMIapps/docs/audit/2026-10-07/audit_probes.py", line 81, in test_duplicate_lines_do_not_oversell
self.assertEqual(r.status_code, 400, r.data)
AssertionError: 201 != 400 : {'id': 1, 'code': 'IMI-TRX-20261007-0001', 'event': 1, 'event_title': 'Kejurnas Test #1', 'status': 'pending', 'status_display': 'Menunggu Pembayaran', 'subtotal': 400000, 'subtotal_display': 'Rp 400.000', 'discount_amount': 60000, 'discount_display': '-Rp 60.000', 'app_fee_amount': 17000, 'app_fee_display': 'Rp 17.000', 'imi_fee_amount': 10200, 'imi_fee_display': 'Rp 10.200', 'service_fee': 5000, 'tax_amount': 36700, 'tax_display': 'Rp 36.700', 'total': 408900, 'total_display': 'Rp 408.900', 'currency': 'IDR', 'member_discount_pct': 15, 'voucher_discount_amount': 0, 'buyer_name': 'Member Test', 'buyer_email': '', 'buyer_phone': '+6281555566666', 'payment_method': '', 'expires_at': '2026-10-07T16:50:28+0700', 'paid_at': None, 'created_at': '2026-10-07T16:35:28+0700', 'lines': [{'id': 1, 'tier': 1, 'tier_code': 'festival', 'tier_name': 'Festival', 'session': None, 'session_name': None, 'quantity': 2, 'unit_price': 100000, 'line_total': 200000, 'line_total_display': 'Rp 200.000', 'receiver_name': 'Member Test'}, {'id': 2, 'tier': 1, 'tier_code': 'festival', 'tier_name': 'Festival', 'session': None, 'session_name': None, 'quantity': 2, 'unit_price': 100000, 'line_total': 200000, 'line_total_display': 'Rp 200.000', 'receiver_name': 'Member Test'}]}
======================================================================
FAIL: test_event_staff_can_create_event (audit_probes.TicketIntegrityProbes)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/trendy/Documents/IMIapps/docs/audit/2026-10-07/audit_probes.py", line 113, in test_event_staff_can_create_event
self.assertEqual(r.status_code, 201, r.data)
AssertionError: 403 != 201 : {'detail': ErrorDetail(string='Anda tidak memiliki izin untuk melakukan tindakan ini.', code='permission_denied')}
======================================================================
FAIL: test_order_requires_terms (audit_probes.TicketIntegrityProbes)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/trendy/Documents/IMIapps/docs/audit/2026-10-07/audit_probes.py", line 117, in test_order_requires_terms
self.assertEqual(r.status_code, 400, r.data)
AssertionError: 201 != 400 : {'id': 1, 'code': 'IMI-TRX-20261007-0001', 'event': 1, 'event_title': 'Kejurnas Test #1', 'status': 'pending', 'status_display': 'Menunggu Pembayaran', 'subtotal': 100000, 'subtotal_display': 'Rp 100.000', 'discount_amount': 15000, 'discount_display': '-Rp 15.000', 'app_fee_amount': 4200, 'app_fee_display': 'Rp 4.200', 'imi_fee_amount': 2600, 'imi_fee_display': 'Rp 2.600', 'service_fee': 5000, 'tax_amount': 9200, 'tax_display': 'Rp 9.200', 'total': 106000, 'total_display': 'Rp 106.000', 'currency': 'IDR', 'member_discount_pct': 15, 'voucher_discount_amount': 0, 'buyer_name': 'Member Test', 'buyer_email': '', 'buyer_phone': '+6281555566666', 'payment_method': '', 'expires_at': '2026-10-07T16:50:29+0700', 'paid_at': None, 'created_at': '2026-10-07T16:35:29+0700', 'lines': [{'id': 1, 'tier': 1, 'tier_code': 'festival', 'tier_name': 'Festival', 'session': None, 'session_name': None, 'quantity': 1, 'unit_price': 100000, 'line_total': 100000, 'line_total_display': 'Rp 100.000', 'receiver_name': 'Member Test'}]}
======================================================================
FAIL: test_paid_order_late_expire_does_not_change_payment (audit_probes.TicketIntegrityProbes)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/trendy/Documents/IMIapps/docs/audit/2026-10-07/audit_probes.py", line 89, in test_paid_order_late_expire_does_not_change_payment
self.assertEqual(order.payment.status, 'verified')
AssertionError: 'expired' != 'verified'
- expired
+ verified
======================================================================
FAIL: test_refunded_ticket_cannot_check_in (audit_probes.TicketIntegrityProbes)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/trendy/Documents/IMIapps/docs/audit/2026-10-07/audit_probes.py", line 75, in test_refunded_ticket_cannot_check_in
self.assertEqual(r.data['status'], 'rejected', r.data)
AssertionError: 'success' != 'rejected'
- success
+ rejected
: {'id': 1, 'ticket': 1, 'ticket_code': 'IMI-TKT-0001-01', 'holder_name': 'Member Test', 'order': 1, 'scan_code': 'TQ-DBED6391D46AC6B55F3C2347', 'gate': '', 'status': 'success', 'status_display': 'Check-in Berhasil', 'reason': '', 'operator': 1, 'created_at': '2026-10-07T16:35:30+0700'}
======================================================================
FAIL: test_signed_wrong_amount_not_accepted (audit_probes.TicketIntegrityProbes)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/trendy/Documents/IMIapps/docs/audit/2026-10-07/audit_probes.py", line 102, in test_signed_wrong_amount_not_accepted
self.assertNotEqual(order.status, 'paid')
AssertionError: 'paid' == 'paid'
----------------------------------------------------------------------
Ran 14 tests in 4.880s
FAILED (failures=20)
Destroying test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')...
{
"web": [
{
"page": "members",
"text": "Semua status\nPending\nAktif\nDitangguhkan\nKedaluwarsa\nSemua tipe\nAnggota KTA\nNon-KTA\nNAMA\tNO. HP\tTIPE\tSTATUS\tPENGCAB\tPOIN\tVERIFIKASI\t\nsugeng\nIMI-JB-2026-0004\n\t+628112112168\tAnggota KTA IMI\tAktif\tPengcab Kab. Bandung Barat\t0\t—\t\nDetail\n\nTrendy Dwi Q G\nIMI-JB-2026-0003\n\t+62817392918\tAnggota KTA IMI\tAktif\tPengcab Kab. Bandung Barat\t0\t—\t\nDetail\n\nUji Coba\nIMI-JB-2026-0002\n\t+6281288887771\tAnggota KTA IMI\tAktif\tPengcab Kota Bandung\t0\t—\t\nDetail\n\nRizky Pratama\nIMI-JB-2026-0001\n\t+6281200000002\tAnggota KTA IMI\tAktif\tPengcab Kota Bandung\t1.550\tTerverifikasi\t\nDetail\n4 item\n← Sebelum\nHal 1 / 1\nBerikut →"
},
{
"page": "events",
"text": "Semua status\nDraft\nPublished\nPendaftaran Dibuka\nEarly Bird\nHampir Habis\nSold Out\nSedang Berlangsung\nSelesai\nDibatalkan\nSemua kategori\nDrag Bike\nModifikasi\nMotocross\nRoad Race\nSlalom & Drift\nSpeed Offroad\nTouring & Rally\n+ Buat Event\nEVENT\tKATEGORI\tSTATUS\tTANGGAL\tVENUE\tHARGA MIN\t\nTest Admin Web Event\ntest-admin-web-event\n\tRoad Race\tDraft\t12 Des 2026 → 13 Des 2026\t-\t-\t\nDetail\nTiket\nEdit\n\nKejurnas Road Race #1 Sentul\nkejurnas-road-race-1-sentul\n\tRoad Race\tPendaftaran Dibuka\t13 Okt 2026 → 15 Okt 2026\tSentul International Circuit\tRp100.000\t\nDetail\nTiket\nEdit\n\nKEJURNAS BANDUNG BARAT\nkbb\n\tMotocross\tPendaftaran Dibuka\t10 Okt 2026 → 11 Okt 2026\tCikole\t-\t\nDetail\nTiket\nEdit\n3 item\n← Sebelum\nHal 1 / 1\nBerikut →"
},
{
"page": "content",
"text": "Semua status\nDraft\nPublished\nArsip\nSemua kategori\nArtikel\nBerita Resmi\nPengumuman\nPress Release\nVideo\n+ Buat Artikel\nJUDUL\tKATEGORI\tSTATUS\tPENULIS\tDITERBITKAN\tDILIHAT\t\nArtikel uji dari Web Admin\nResmi IMI\n\t-\tPublished\tSuper Admin IMI Jabar\t06 Okt 2026, 16.49\t0\t\nEdit\nHapus\n\nIMI Jabar Umumkan Kalender Kejurnas 2025\nFeaturedResmi IMI\n\tBerita Resmi\tPublished\tSuper Admin IMI Jabar\t06 Okt 2026, 16.11\t0\t\nEdit\nHapus\n2 item\n← Sebelum\nHal 1 / 1\nBerikut →"
},
{
"page": "ads",
"text": "Semua status\nAktif\nNonaktif\n+ Tambah Iklan\nIKLAN\tWARNA\tTOMBOL\tURUTAN\tSTATUS\t\n\nBPJS Ketenagakerjaan Atlet & Pembalap\nPROTEKSI ATLET BALAP\n\t#10B981\tPelajari Program\t1\tAktif\t\nEdit\nHapus\n\n\nMotul 300V Factory Line Road Racing\nOFFICIAL RACING OIL\n\t#E11D48\tBeli di Official Store\t2\tAktif\t\nEdit\nHapus\n\n\nJuaraga Official Merchandise IMI\nOFFICIAL MERCHANDISE\n\t#F59E0B\tLihat Katalog Juaraga\t3\tAktif\t\nEdit\nHapus\n3 item\n← Sebelum\nHal 1 / 1\nBerikut →"
},
{
"page": "partners",
"text": "PartnerBenefit / Promo\n+ Tambah Partner\nPARTNER\tKATEGORI\tSTATUS\tVERIFIKASI\tWEBSITE\t\nMotul Speed Garage\nmotul-speed-garage\n\tworkshop\tactive\tVerified\t-\t\nDetail\nEdit\nHapus\n\nPertamina Turbo\npertamina-turbo\n\tfuel\tactive\tVerified\t-\t\nDetail\nEdit\nHapus\n2 item\n← Sebelum\nHal 1 / 1\nBerikut →"
},
{
"page": "checkins",
"text": "Simulasi Scan Check-in (Gate)\nScan & Validasi\nRiwayat Check-in\nSemua status\nSukses\nDitolak\nDuplikat\nWAKTU\tTIKET\tNAMA\tSTATUS\tGATE\tALASAN\tOPERATOR\n06 Okt 2026, 18.01\tIMI-TKT-0002-03\tRizky Pratama\tCheck-in Berhasil\tGATE-1\t-\t#1\n06 Okt 2026, 16.16\t-\t-\tDitolak\tGATE-1\tQR tidak dikenal / tidak valid.\t#1\n06 Okt 2026, 16.16\tIMI-TKT-0001-02\tUji Coba\tCheck-in Berhasil\tGATE-7\t-\t#1\n06 Okt 2026, 16.16\tIMI-TKT-0001-01\tUji Coba\tDuplikat\tGATE-8\tTiket sudah di-scan sebelumnya.\t#1\n06 Okt 2026, 16.16\tIMI-TKT-0001-01\tUji Coba\tDuplikat\tGATE-7\tTiket sudah di-scan sebelumnya.\t#1\n06 Okt 2026, 16.13\t-\t-\tDitolak\tGATE-1\tQR tidak dikenal / tidak valid.\t#1\n06 Okt 2026, 16.13\t-\t-\tDitolak\tGATE-2\tQR tidak dikenal / tidak valid.\t#1\n06 Okt 2026, 16.13\tIMI-TKT-0001-01\tUji Coba\tCheck-in Berhasil\tGATE-1\t-\t#1\n8 item\n← Sebelum\nHal 1 / 1\nBerikut →"
},
{
"page": "roles",
"text": "Semua pengguna\nInternal (staff & admin)\nAnggota\nPengguna & Role\nPerubahan role/status hanya dapat dilakukan oleh Super Admin.\nNAMA\tNO. HP\tROLE\tLABEL\tSTATUS\t\nSuper Admin IMI Jabar\nadmin@imijabar.id\n\t+6281200000001\tSuper Admin\tSuper Adminstaffsuperuser\tAktif\t\nTanpa role\nSuper Admin\nAdmin\nEvent / Panitia\nContent Admin\nPartnership Admin\nReporting\nSimpan Role\n1 item\n← Sebelum\nHal 1 / 1\nBerikut →"
}
],
"mobile": [
{
"step": "checkout-before-order",
"state": "Browser tab: 2, Title: \"IMI Jawa Barat\", URL: \"http://127.0.0.1:5300/\".\n0 AXWebArea IMI Jawa Barat, URL: 127.0.0.1:5300/\n\t1 container\n\t\t2 container\n\t\t\t3 container\n\t\t\t\t4 button Back, ID: flt-semantic-node-121\n\t\t\t\t5 heading Pembayaran & Checkout, Value: 2, ID: flt-semantic-node-122\n\t\t\t\t\t6 text Pembayaran & Checkout\n\t\t\t7 container flt-semantic-node-97\n\t\t\t\t8 container\n\t\t\t\t\t9 text Ringkasan Tiket\n\t\t\t\t\t10 text Kejurnas Road Race #1 Sentul\n\t\t\t\t\t11 text Festival (1x)\n\t\t\t\t\t12 text Rp 100.000\n\t\t\t\t\t13 text Data Pemegang Tiket\n\t\t\t\t\t14 text field (settable) Nama Lengkap\n\t\t\t\t\t15 text field (settable) Nomor WhatsApp\n\t\t\t\t\t16 text Pilih Metode Pembayaran\n\t\t\t\t\t17 container ID: flt-semantic-node-106, Description: Midtrans (QRIS / VA / E-Wallet / Kartu) Pilih channel favorit Anda di halaman pembayaran aman Midtrans\n\t\t\t\t\t\t18 radio button Value: 1, ID: flt-semantic-node-107\n\t\t\t\t\t19 text Rincian Pembayaran\n\t\t\t\t\t20 text Subtotal Tiket\n\t\t\t\t\t21 text Rp 100.000\n\t\t\t\t\t22 text Diskon Anggota KTA (15%)\n\t\t\t\t\t23 text -Rp 15.000\n\t\t\t\t\t24 text Biaya Layanan Paddock\n\t\t\t\t\t25 text Rp 5.000\n\t\t\t\t\t26 text Pajak PB1 (10%)\n\t\t\t\t\t27 text Rp 8.500\n\t\t\t\t\t28 text Total Bayar\n\t\t\t\t\t29 text Rp 98.500\n\t\t\t\t\t30 button Bayar Sekarang, ID: flt-semantic-node-119\n\nThe focused UI element is 4 button Back, ID: flt-semantic-node-121"
},
{
"step": "backend-order-price",
"state": "Browser tab: 2, Title: \"IMI Jawa Barat\", URL: \"http://127.0.0.1:5300/\".\n0 AXWebArea IMI Jawa Barat, URL: 127.0.0.1:5300/\n\t1 container\n\t\t2 container\n\t\t\t3 heading Instruksi Pembayaran, Value: 2, ID: flt-semantic-node-324\n\t\t\t\t4 text Instruksi Pembayaran\n\t\t\t5 container\n\t\t\t\t6 text Selesaikan Dalam 15 Menit\n\t\t\t\t7 text Tiket Anda ditahan sementara hingga pembayaran diverifikasi.\n\t\t\t\t8 text Snap Midtrans\n\t\t\t\t9 text No. Order: IMI-TRX-20261007-0001\n\t\t\t\t10 text Pembayaran aman melalui Midtrans Snap. Anda akan diarahkan ke halaman resmi pembayaran untuk memilih cara bayar:\n\t\t\t\t11 text QRIS (GoPay, OVO, ShopeePay, Dana)\n\t\t\t\t12 text Transfer Bank & Virtual Account (BCA, Mandiri, BNI, BRI)\n\t\t\t\t13 text Kartu Kredit / Debit (Visa, MasterCard, JCB)\n\t\t\t\t14 text E-Wallet lainnya via LinkAja / GoPay / ShopeePay\n\t\t\t\t15 text Status pembayaran diverifikasi otomatis oleh Midtrans. Tekan \"Saya Sudah Bayar\" setelah selesai.\n\t\t\t\t16 text Total yang Harus Dibayar:\n\t\t\t\t17 text Rp 106.000\n\t\t\t\t18 button Buka Midtrans & Bayar Sekarang, ID: flt-semantic-node-321\n\t\t\t\t19 button Saya Sudah Bayar — Cek Status, ID: flt-semantic-node-322\n\nThe focused UI element is 3 heading Instruksi Pembayaran, Value: 2, ID: flt-semantic-node-324"
},
{
"step": "admin-first-scan",
"state": "Browser tab: 3, Title: \"IMI Jawa Barat — Admin\", URL: \"http://127.0.0.1:5301/#/checkins\".\n0 AXWebArea IMI Jawa Barat — Admin, URL: 127.0.0.1:5301/#/checkins\n\t1 container app\n\t\t2 container\n\t\t\t3 text IMI IMI Jabar Admin\n\t\t\t4 link Description: ▦ Dashboard, Value: 127.0.0.1:5301/#/dashboard\n\t\t\t5 link Description: ◉ Member Management, Value: 127.0.0.1:5301/#/members\n\t\t\t6 link Description: ❖ Event Management, Value: 127.0.0.1:5301/#/events\n\t\t\t7 link Description: ▤ Content Management, Value: 127.0.0.1:5301/#/content\n\t\t\t8 link Description: ▰ Slider Iklan, Value: 127.0.0.1:5301/#/ads\n\t\t\t9 link Description: ◈ Partner & Benefit, Value: 127.0.0.1:5301/#/partners\n\t\t\t10 link Description: ✓ Check-in & Tiket, Value: 127.0.0.1:5301/#/checkins\n\t\t\t11 link Description: ¥ Harga & Bagi Hasil, Value: 127.0.0.1:5301/#/finance\n\t\t\t12 link Description: ⚿ User & Role, Value: 127.0.0.1:5301/#/roles\n\t\t13 container\n\t\t\t14 heading Check-in & Tiket, Value: 1\n\t\t\t\t15 text Check-in & Tiket\n\t\t\t16 text SA\n\t\t\t17 text Super Admin IMI Jabar\n\t\t\t18 text Super Admin\n\t\t\t19 button Keluar\n\t\t\t20 container page\n\t\t\t\t21 text Simulasi Scan Check-in (Gate)\n\t\t\t\t22 text field (settable) Value: TQ-BCA66B78B438E1EFCBBD212A, ID: scan-code\n\t\t\t\t23 text field (settable) Value: GATE-1, ID: scan-gate\n\t\t\t\t24 button Scan & Validasi\n\t\t\t\t33 container\n\t\t\t\t\t34 text Check-in Berhasil Rizky Pratama Tiket: IMI-TKT-0001-01 Gate: GATE-1 Waktu: 07 Okt 2026, 16.33\n\t\t\t\t35 text Riwayat Check-in\n\t\t\t\t26 pop up button (collapsed, settable) Value: Semua status, Secondary Actions: Expand\n\t\t\t\t\t27 menu\n\t\t\t\t\t\t28 (selected) Semua status\n\t\t\t\t\t\t29 Sukses\n\t\t\t\t\t\t30 Ditolak\n\t\t\t\t\t\t31 Duplikat\n\t\t\t\t32 text field (settable)\n\t\t\t\t36 table\n\t\t\t\t\t37 row\n\t\t\t\t\t\t38 cell\n\t\t\t\t\t\t\t39 text WAKTU\n\t\t\t\t\t\t40 cell\n\t\t\t\t\t\t\t41 text TIKET\n\t\t\t\t\t\t42 cell\n\t\t\t\t\t\t\t43 text NAMA\n\t\t\t\t\t\t44 cell\n\t\t\t\t\t\t\t45 text STATUS\n\t\t\t\t\t\t46 cell\n\t\t\t\t\t\t\t47 text GATE\n\t\t\t\t\t\t48 cell\n\t\t\t\t\t\t\t49 text ALASAN\n\t\t\t\t\t\t50 cell\n\t\t\t\t\t\t\t51 text OPERATOR\n\t\t\t\t\t52 row\n\t\t\t\t\t\t53 cell\n\t\t\t\t\t\t\t54 text 07 Okt 2026, 16.33\n\t\t\t\t\t\t55 cell\n\t\t\t\t\t\t\t56 text IMI-TKT-0001-01\n\t\t\t\t\t\t57 cell\n\t\t\t\t\t\t\t58 text Rizky Pratama\n\t\t\t\t\t\t59 cell\n\t\t\t\t\t\t\t60 text Check-in Berhasil\n\t\t\t\t\t\t61 cell\n\t\t\t\t\t\t\t62 text GATE-1\n\t\t\t\t\t\t63 cell\n\t\t\t\t\t\t\t64 text -\n\t\t\t\t\t\t65 cell\n\t\t\t\t\t\t\t66 text #1\n\t\t\t\t67 text 1 item\n\t\t\t\t68 button (disabled) ← Sebelum\n\t\t\t\t69 text Hal 1 / 1\n\t\t\t\t70 button (disabled) Berikut →\n\nThe focused UI element is 22 text field (settable) Value: TQ-BCA66B78B438E1EFCBBD212A, ID: scan-code"
},
{
"step": "admin-duplicate-scan",
"state": "Browser tab: 3, Title: \"IMI Jawa Barat — Admin\", URL: \"http://127.0.0.1:5301/#/checkins\".\n0 AXWebArea IMI Jawa Barat — Admin, URL: 127.0.0.1:5301/#/checkins\n\t1 container app\n\t\t2 container\n\t\t\t3 text IMI IMI Jabar Admin\n\t\t\t4 link Description: ▦ Dashboard, Value: 127.0.0.1:5301/#/dashboard\n\t\t\t5 link Description: ◉ Member Management, Value: 127.0.0.1:5301/#/members\n\t\t\t6 link Description: ❖ Event Management, Value: 127.0.0.1:5301/#/events\n\t\t\t7 link Description: ▤ Content Management, Value: 127.0.0.1:5301/#/content\n\t\t\t8 link Description: ▰ Slider Iklan, Value: 127.0.0.1:5301/#/ads\n\t\t\t9 link Description: ◈ Partner & Benefit, Value: 127.0.0.1:5301/#/partners\n\t\t\t10 link Description: ✓ Check-in & Tiket, Value: 127.0.0.1:5301/#/checkins\n\t\t\t11 link Description: ¥ Harga & Bagi Hasil, Value: 127.0.0.1:5301/#/finance\n\t\t\t12 link Description: ⚿ User & Role, Value: 127.0.0.1:5301/#/roles\n\t\t13 container\n\t\t\t14 heading Check-in & Tiket, Value: 1\n\t\t\t\t15 text Check-in & Tiket\n\t\t\t16 text SA\n\t\t\t17 text Super Admin IMI Jabar\n\t\t\t18 text Super Admin\n\t\t\t19 button Keluar\n\t\t\t20 container page\n\t\t\t\t21 text Simulasi Scan Check-in (Gate)\n\t\t\t\t22 text field (settable) Value: TQ-BCA66B78B438E1EFCBBD212A, ID: scan-code\n\t\t\t\t23 text field (settable) Value: GATE-1, ID: scan-gate\n\t\t\t\t24 button Scan & Validasi\n\t\t\t\t33 container\n\t\t\t\t\t34 text Duplikat Rizky Pratama Tiket: IMI-TKT-0001-01 Gate: GATE-1 Alasan: Tiket sudah di-scan sebelumnya. Waktu: 07 Okt 2026, 16.33\n\t\t\t\t35 text Riwayat Check-in\n\t\t\t\t26 pop up button (collapsed, settable) Value: Semua status, Secondary Actions: Expand\n\t\t\t\t\t27 menu\n\t\t\t\t\t\t28 (selected) Semua status\n\t\t\t\t\t\t29 Sukses\n\t\t\t\t\t\t30 Ditolak\n\t\t\t\t\t\t31 Duplikat\n\t\t\t\t32 text field (settable)\n\t\t\t\t36 table\n\t\t\t\t\t37 row\n\t\t\t\t\t\t38 cell\n\t\t\t\t\t\t\t39 text WAKTU\n\t\t\t\t\t\t40 cell\n\t\t\t\t\t\t\t41 text TIKET\n\t\t\t\t\t\t42 cell\n\t\t\t\t\t\t\t43 text NAMA\n\t\t\t\t\t\t44 cell\n\t\t\t\t\t\t\t45 text STATUS\n\t\t\t\t\t\t46 cell\n\t\t\t\t\t\t\t47 text GATE\n\t\t\t\t\t\t48 cell\n\t\t\t\t\t\t\t49 text ALASAN\n\t\t\t\t\t\t50 cell\n\t\t\t\t\t\t\t51 text OPERATOR\n\t\t\t\t\t52 row\n\t\t\t\t\t\t53 cell\n\t\t\t\t\t\t\t54 text 07 Okt 2026, 16.33\n\t\t\t\t\t\t55 cell\n\t\t\t\t\t\t\t56 text IMI-TKT-0001-01\n\t\t\t\t\t\t57 cell\n\t\t\t\t\t\t\t58 text Rizky Pratama\n\t\t\t\t\t\t59 cell\n\t\t\t\t\t\t\t60 text Duplikat\n\t\t\t\t\t\t61 cell\n\t\t\t\t\t\t\t62 text GATE-1\n\t\t\t\t\t\t63 cell\n\t\t\t\t\t\t\t64 text Tiket sudah di-scan sebelumnya.\n\t\t\t\t\t\t65 cell\n\t\t\t\t\t\t\t66 text #1\n\t\t\t\t\t67 row\n\t\t\t\t\t\t68 cell\n\t\t\t\t\t\t\t69 text 07 Okt 2026, 16.33\n\t\t\t\t\t\t70 cell\n\t\t\t\t\t\t\t71 text IMI-TKT-0001-01\n\t\t\t\t\t\t72 cell\n\t\t\t\t\t\t\t73 text Rizky Pratama\n\t\t\t\t\t\t74 cell\n\t\t\t\t\t\t\t75 text Check-in Berhasil\n\t\t\t\t\t\t76 cell\n\t\t\t\t\t\t\t77 text GATE-1\n\t\t\t\t\t\t78 cell\n\t\t\t\t\t\t\t79 text -\n\t\t\t\t\t\t80 cell\n\t\t\t\t\t\t\t81 text #1\n\t\t\t\t82 text 2 item\n\t\t\t\t83 button (disabled) ← Sebelum\n\t\t\t\t84 text Hal 1 / 1\n\t\t\t\t85 button (disabled) Berikut →\n\nThe focused UI element is 22 text field (settable) Value: TQ-BCA66B78B438E1EFCBBD212A, ID: scan-code"
},
{
"step": "claim-voucher",
"state": "Browser tab: 2, Title: \"IMI Jawa Barat\", URL: \"http://127.0.0.1:5300/\".\n0 AXWebArea IMI Jawa Barat, URL: 127.0.0.1:5300/\n\t1 container\n\t\t2 container\n\t\t\t3 container\n\t\t\t\t4 container Voucher Berhasil Diklaim\n\t\t\t\t\t5 text Voucher Berhasil Diklaim\n\t\t\t\t6 text Tunjukkan kode voucher ini kepada kasir/petugas Motul Speed Garage: VCR-0001-0001-AE92 Bonus cashback +50 Poin Balap telah ditambahkan ke akun Anda.\n\t\t\t\t7 button Tutup, ID: flt-semantic-node-432\n\t\t\t8 text Dismiss\n\nThe focused UI element is 4 container Voucher Berhasil Diklaim"
},
{
"step": "mobile-profile-seen-admin",
"state": "Browser tab: 3, Title: \"IMI Jawa Barat — Admin\", URL: \"http://127.0.0.1:5301/#/members\".\n0 AXWebArea IMI Jawa Barat — Admin, URL: 127.0.0.1:5301/#/members\n\t1 container app\n\t\t2 container\n\t\t\t3 text IMI IMI Jabar Admin\n\t\t\t4 link Description: ▦ Dashboard, Value: 127.0.0.1:5301/#/dashboard\n\t\t\t5 link Description: ◉ Member Management, Value: 127.0.0.1:5301/#/members\n\t\t\t6 link Description: ❖ Event Management, Value: 127.0.0.1:5301/#/events\n\t\t\t7 link Description: ▤ Content Management, Value: 127.0.0.1:5301/#/content\n\t\t\t8 link Description: ▰ Slider Iklan, Value: 127.0.0.1:5301/#/ads\n\t\t\t9 link Description: ◈ Partner & Benefit, Value: 127.0.0.1:5301/#/partners\n\t\t\t10 link Description: ✓ Check-in & Tiket, Value: 127.0.0.1:5301/#/checkins\n\t\t\t11 link Description: ¥ Harga & Bagi Hasil, Value: 127.0.0.1:5301/#/finance\n\t\t\t12 link Description: ⚿ User & Role, Value: 127.0.0.1:5301/#/roles\n\t\t13 container\n\t\t\t14 heading Member Management, Value: 1\n\t\t\t\t21 text Member Management\n\t\t\t16 text SA\n\t\t\t17 text Super Admin IMI Jabar\n\t\t\t18 text Super Admin\n\t\t\t19 button Keluar\n\t\t\t20 container page\n\t\t\t\t22 search text field (settable)\n\t\t\t\t23 pop up button (collapsed, settable) Value: Semua status, Secondary Actions: Expand\n\t\t\t\t\t24 menu\n\t\t\t\t\t\t25 (selected) Semua status\n\t\t\t\t\t\t26 Pending\n\t\t\t\t\t\t27 Aktif\n\t\t\t\t\t\t28 Ditangguhkan\n\t\t\t\t\t\t29 Kedaluwarsa\n\t\t\t\t30 pop up button (collapsed, settable) Value: Semua tipe, Secondary Actions: Expand\n\t\t\t\t\t31 menu\n\t\t\t\t\t\t32 (selected) Semua tipe\n\t\t\t\t\t\t33 Anggota KTA\n\t\t\t\t\t\t34 Non-KTA\n\t\t\t\t35 table\n\t\t\t\t\t36 row\n\t\t\t\t\t\t37 cell\n\t\t\t\t\t\t\t38 text NAMA\n\t\t\t\t\t\t39 cell\n\t\t\t\t\t\t\t40 text NO. HP\n\t\t\t\t\t\t41 cell\n\t\t\t\t\t\t\t42 text TIPE\n\t\t\t\t\t\t43 cell\n\t\t\t\t\t\t\t44 text STATUS\n\t\t\t\t\t\t45 cell\n\t\t\t\t\t\t\t46 text PENGCAB\n\t\t\t\t\t\t47 cell\n\t\t\t\t\t\t\t48 text POIN\n\t\t\t\t\t\t49 cell\n\t\t\t\t\t\t\t50 text VERIFIKASI\n\t\t\t\t\t51 row\n\t\t\t\t\t\t52 cell\n\t\t\t\t\t\t\t53 text Member Audit E2E\n\t\t\t\t\t\t\t54 text IMI-JB-2026-0001\n\t\t\t\t\t\t55 cell\n\t\t\t\t\t\t\t56 text +6281200000002\n\t\t\t\t\t\t57 cell\n\t\t\t\t\t\t\t58 text Anggota KTA IMI\n\t\t\t\t\t\t59 cell\n\t\t\t\t\t\t\t60 text Aktif\n\t\t\t\t\t\t61 cell\n\t\t\t\t\t\t\t62 text Pengcab Kota Bandung\n\t\t\t\t\t\t63 cell\n\t\t\t\t\t\t\t64 text 1.500\n\t\t\t\t\t\t65 cell\n\t\t\t\t\t\t\t66 text Terverifikasi\n\t\t\t\t\t\t67 cell\n\t\t\t\t\t\t\t68 button Detail\n\t\t\t\t69 text 1 item\n\t\t\t\t70 button (disabled) ← Sebelum\n\t\t\t\t71 text Hal 1 / 1\n\t\t\t\t72 button (disabled) Berikut →\n\nThe focused UI element is 5 link Description: ◉ Member Management, URL: 127.0.0.1:5301/#/members"
},
{
"step": "home-stale-after-profile-and-voucher",
"state": "Browser tab: 2, Title: \"IMI Jawa Barat\", URL: \"http://127.0.0.1:5300/\".\n0 AXWebArea IMI Jawa Barat, URL: 127.0.0.1:5300/\n\t1 container\n\t\t2 container\n\t\t\t3 container\n\t\t\t\t4 image Description: IMI JABAR Paddock Digital, ID: flt-semantic-node-505\n\t\t\t\t5 button flt-semantic-node-506\n\t\t\t6 container flt-semantic-node-477\n\t\t\t\t7 container\n\t\t\t\t\t8 text KTA DIGITAL IMI JABAR\n\t\t\t\t\t9 text PRO RACING LICENSE\n\t\t\t\t\t10 text VERIFIED\n\t\t\t\t\t11 text Rizky Pratama\n\t\t\t\t\t12 text IMI-JB-2026-0001\n\t\t\t\t\t13 text 1450 POIN\n\t\t\t\t\t14 text Berlaku s/d: 2026-12-31\n\t\t\t\t\t15 text Buka Kartu & QR\n\t\t\t\t\t16 text Balap Motor\n\t\t\t\t\t17 text Balap Mobil\n\t\t\t\t\t18 text Wisata / Turing\n\t\t\t\t\t19 text Klub Jabar\n\t\t\t\t\t20 text Event Balap Resmi Terdekat\n\t\t\t\t\t21 text Lihat Semua\n\t\t\t\t\t22 text ROAD RACE\n\t\t\t\t\t23 text Sentul International Circuit\n\t\t\t\t\t24 text Kejurnas Road Race #1 Sentul\n\t\t\t\t\t25 text 2026-10-14 - 2026-10-16\n\t\t\t\t\t26 text Sentul, Bogor\n\t\t\t\t\t27 text Mulai Dari\n\t\t\t\t\t28 text Rp 100.000\n\t\t\t\t\t29 button Beli Tiket, ID: flt-semantic-node-499\n\t\t\t\t\t30 text Voucher & Benefit Mitra\n\t\t\t\t\t31 text Voucher Terlaris Motul Speed Garage Paket Dedicated Dyno Tuning + Info Rp 899.000\n\t\t\t\t\t32 text Kabar Resmi IMI Jawa Barat\n\t\t\t\t\t33 text RESMI Divisi Humas IMI Jabar Baca Selengkapnya IMI Jabar Umumkan Kalender Kejurnas 2025 27 Pengcab siap memeriahkan 45+ event resmi sepanjang musim 2025.\n\t\t\t34 container\n\t\t\t\t35 button Beranda Tab 1 of 5, ID: flt-semantic-node-346\n\t\t\t\t36 button Event Tab 2 of 5, ID: flt-semantic-node-347\n\t\t\t\t37 button E-Tiket Tab 3 of 5, ID: flt-semantic-node-348\n\t\t\t\t38 button Benefit Tab 4 of 5, ID: flt-semantic-node-349\n\t\t\t\t39 button Profil Tab 5 of 5, ID: flt-semantic-node-350\n\nThe focused UI element is 35 button Beranda Tab 1 of 5, ID: flt-semantic-node-346"
},
{
"step": "article-detail",
"state": "Browser tab: 2, Title: \"IMI Jawa Barat\", URL: \"http://127.0.0.1:5300/\".\n0 AXWebArea IMI Jawa Barat, URL: 127.0.0.1:5300/\n\t1 container\n\t\t2 container\n\t\t\t3 container\n\t\t\t\t4 button Back, ID: flt-semantic-node-521\n\t\t\t\t5 heading Kabar & Berita Resmi, Value: 2, ID: flt-semantic-node-522\n\t\t\t\t\t6 text Kabar & Berita Resmi\n\t\t\t7 container\n\t\t\t\t8 text RILIS RESMI IMI JABAR\n\t\t\t\t9 text 2026-10-07\n\t\t\t\t10 text IMI Jabar Umumkan Kalender Kejurnas 2025\n\t\t\t\t11 text Divisi Humas IMI Jabar\n\t\t\t\t12 image flt-semantic-node-514\n\t\t\t\t13 text WARTA MOTORSPORT JAWA BARAT\n\t\t\t\t14 text 27 Pengcab siap memeriahkan 45+ event resmi sepanjang musim 2025.\n\t\t\t\t15 text IMI Pengurus Provinsi Jawa Barat resmi merilis kalender balap 2025 dengan 45+ event di seluruh 27 Pengcab...\n\t\t\t\t16 text Bagikan pengumuman resmi ini ke komunitas atau klub balap Anda.\n\t\t\t\t17 button Salin Link, ID: flt-semantic-node-519\n\nThe focused UI element is 4 button Back, ID: flt-semantic-node-521"
},
{
"step": "multi-tier-selection",
"state": "Browser tab: 2, Title: \"IMI Jawa Barat\", URL: \"http://127.0.0.1:5300/\".\n0 AXWebArea IMI Jawa Barat, URL: 127.0.0.1:5300/\n\t1 container\n\t\t2 container\n\t\t\t3 container\n\t\t\t\t4 button Back, ID: flt-semantic-node-548\n\t\t\t\t5 heading Kejurnas Road Race #1 Sentul, Value: 2, ID: flt-semantic-node-549\n\t\t\t\t\t6 text Kejurnas Road Race #1 Sentul\n\t\t\t7 text Kejurnas Road Race #1 Sentul\n\t\t\t8 text Sentul International Circuit • 2026-10-14\n\t\t\t9 container flt-semantic-node-565\n\t\t\t\t10 container\n\t\t\t\t\t11 container ID: flt-semantic-node-553, Description: VIP Paddock MEMBER ONLY Rp 750.000 / orang Akses Paddock VIP Proteksi & Asuransi gratis Foodcourt Premium Sisa Kuota: 200 0\n\t\t\t\t\t\t12 container\n\t\t\t\t\t\t\t14 button flt-semantic-node-555\n\t\t\t\t\t15 container Description: Grandstand Rp 250.000 / orang Tribun utama 1x Almanak Sisa Kuota: 2000 0, ID: flt-semantic-node-556\n\t\t\t\t\t\t16 container\n\t\t\t\t\t\t\t17 button flt-semantic-node-558\n\t\t\t\t\t18 container Description: Festival Rp 100.000 / orang Akses area umum + fan zone Live timing Sisa Kuota: 1999 1, ID: flt-semantic-node-559\n\t\t\t\t\t\t19 container\n\t\t\t\t\t\t\t26 button flt-semantic-node-560\n\t\t\t\t\t\t\t20 button flt-semantic-node-561\n\t\t\t\t\t21 container Description: Pit Crew Rp 350.000 / personil Akses pit dan paddock Memerlukan KTA Balap Sisa Kuota: 2000 0, ID: flt-semantic-node-562\n\t\t\t\t\t\t22 container\n\t\t\t\t\t\t\t23 button flt-semantic-node-564\n\t\t\t24 container Description: 1 Tiket Terpilih Rp 100.000, ID: flt-semantic-node-566\n\t\t\t\t25 button Lanjut Pembayaran, ID: flt-semantic-node-567\n\nThe focused UI element is 20 button flt-semantic-node-561"
},
{
"step": "multi-tier-checkout-lost-festival",
"state": "Browser tab: 2, Title: \"IMI Jawa Barat\", URL: \"http://127.0.0.1:5300/\".\n0 AXWebArea IMI Jawa Barat, URL: 127.0.0.1:5300/\n\t1 container\n\t\t2 container\n\t\t\t3 container\n\t\t\t\t4 button Back, ID: flt-semantic-node-594\n\t\t\t\t5 heading Pembayaran & Checkout, Value: 2, ID: flt-semantic-node-595\n\t\t\t\t\t6 text Pembayaran & Checkout\n\t\t\t7 container flt-semantic-node-570\n\t\t\t\t8 container\n\t\t\t\t\t9 text Ringkasan Tiket\n\t\t\t\t\t10 text Kejurnas Road Race #1 Sentul\n\t\t\t\t\t11 text Festival (1x)\n\t\t\t\t\t12 text Rp 100.000\n\t\t\t\t\t13 text Data Pemegang Tiket\n\t\t\t\t\t14 text field (settable) Nama Lengkap\n\t\t\t\t\t15 text field (settable) Nomor WhatsApp\n\t\t\t\t\t16 text Pilih Metode Pembayaran\n\t\t\t\t\t17 container ID: flt-semantic-node-579, Description: Midtrans (QRIS / VA / E-Wallet / Kartu) Pilih channel favorit Anda di halaman pembayaran aman Midtrans\n\t\t\t\t\t\t18 radio button Value: 1, ID: flt-semantic-node-580\n\t\t\t\t\t19 text Rincian Pembayaran\n\t\t\t\t\t20 text Subtotal Tiket\n\t\t\t\t\t21 text Rp 100.000\n\t\t\t\t\t22 text Diskon Anggota KTA (15%)\n\t\t\t\t\t23 text -Rp 15.000\n\t\t\t\t\t24 text Biaya Layanan Paddock\n\t\t\t\t\t25 text Rp 5.000\n\t\t\t\t\t26 text Pajak PB1 (10%)\n\t\t\t\t\t27 text Rp 8.500\n\t\t\t\t\t28 text Total Bayar\n\t\t\t\t\t29 text Rp 98.500\n\t\t\t\t\t30 button Bayar Sekarang, ID: flt-semantic-node-592\n\nThe focused UI element is 4 button Back, ID: flt-semantic-node-594"
}
]
}
\ No newline at end of file
...@@ -37,6 +37,17 @@ class ApiService { ...@@ -37,6 +37,17 @@ class ApiService {
return headers; return headers;
} }
static dynamic _decodeJsonSafely(http.Response res) {
if (res.statusCode >= 500) {
throw 'Server backend mengalami gangguan (HTTP ${res.statusCode} ${res.reasonPhrase ?? "Internal Error"}). Mohon periksa status server.';
}
final body = utf8.decode(res.bodyBytes).trim();
if (body.startsWith('<')) {
throw 'Server mengembalikan halaman HTML (HTTP ${res.statusCode}), bukan format API JSON.';
}
return jsonDecode(body);
}
// Auth Endpoints (no trailing slash) // Auth Endpoints (no trailing slash)
static Future<ApiResponse<Map<String, dynamic>>> login({ static Future<ApiResponse<Map<String, dynamic>>> login({
required String identifier, required String identifier,
...@@ -53,7 +64,7 @@ class ApiService { ...@@ -53,7 +64,7 @@ class ApiService {
}), }),
); );
final data = jsonDecode(utf8.decode(res.bodyBytes)); final data = _decodeJsonSafely(res);
if (res.statusCode == 200) { if (res.statusCode == 200) {
if (data['tokens'] != null) { if (data['tokens'] != null) {
await StorageService.saveTokens( await StorageService.saveTokens(
...@@ -496,6 +507,21 @@ class ApiService { ...@@ -496,6 +507,21 @@ class ApiService {
} }
} }
static Future<ApiResponse<List<dynamic>>> getAds() async {
try {
final url = Uri.parse('$baseUrl/ads/');
final res = await http.get(url, headers: _headers(withAuth: false));
if (res.statusCode == 200) {
final data = jsonDecode(utf8.decode(res.bodyBytes));
final list = data is List ? data : (data is Map && data['results'] != null ? data['results'] : []);
return ApiResponse(success: true, data: list, statusCode: res.statusCode);
}
return ApiResponse(success: false, statusCode: res.statusCode);
} catch (e) {
return ApiResponse(success: false, statusCode: 0);
}
}
// Notifications (trailing slash for collection, specific paths for actions) // Notifications (trailing slash for collection, specific paths for actions)
static Future<ApiResponse<List<dynamic>>> getNotifications() async { static Future<ApiResponse<List<dynamic>>> getNotifications() async {
try { try {
......
...@@ -62,3 +62,47 @@ class BannerModel { ...@@ -62,3 +62,47 @@ class BannerModel {
} }
} }
class AdModel {
final int id;
final String title;
final String category;
final String subtitle;
final String details;
final String actionText;
final String url;
final String imageUrl;
final String badgeColor;
final int ordinal;
final bool isActive;
const AdModel({
required this.id,
required this.title,
this.category = '',
this.subtitle = '',
this.details = '',
this.actionText = '',
this.url = '',
this.imageUrl = '',
this.badgeColor = '#10B981',
this.ordinal = 0,
this.isActive = true,
});
factory AdModel.fromJson(Map<String, dynamic> json) {
return AdModel(
id: json['id'] ?? 0,
title: json['title'] ?? '',
category: json['category'] ?? '',
subtitle: json['subtitle'] ?? '',
details: json['details'] ?? '',
actionText: json['action_text'] ?? '',
url: json['url'] ?? '',
imageUrl: json['image_url'] ?? '',
badgeColor: json['badge_color'] ?? '#10B981',
ordinal: json['ordinal'] ?? 0,
isActive: json['is_active'] ?? true,
);
}
}
...@@ -20,6 +20,7 @@ class AppState extends ChangeNotifier { ...@@ -20,6 +20,7 @@ class AppState extends ChangeNotifier {
List<BenefitModel> _benefits = []; List<BenefitModel> _benefits = [];
List<ArticleModel> _articles = []; List<ArticleModel> _articles = [];
List<BannerModel> _banners = []; List<BannerModel> _banners = [];
List<AdModel> _ads = [];
List<NotificationModel> _notifications = []; List<NotificationModel> _notifications = [];
UserModel? get currentUser => _currentUser; UserModel? get currentUser => _currentUser;
...@@ -34,6 +35,7 @@ class AppState extends ChangeNotifier { ...@@ -34,6 +35,7 @@ class AppState extends ChangeNotifier {
List<BenefitModel> get benefits => _benefits; List<BenefitModel> get benefits => _benefits;
List<ArticleModel> get articles => _articles; List<ArticleModel> get articles => _articles;
List<BannerModel> get banners => _banners; List<BannerModel> get banners => _banners;
List<AdModel> get ads => _ads;
List<NotificationModel> get notifications => _notifications; List<NotificationModel> get notifications => _notifications;
MembershipCardModel? get primaryCard => _currentUser?.primaryCard; MembershipCardModel? get primaryCard => _currentUser?.primaryCard;
...@@ -141,6 +143,11 @@ class AppState extends ChangeNotifier { ...@@ -141,6 +143,11 @@ class AppState extends ChangeNotifier {
_banners = banRes.data!.map((b) => BannerModel.fromJson(b)).toList(); _banners = banRes.data!.map((b) => BannerModel.fromJson(b)).toList();
} }
final adRes = await ApiService.getAds();
if (adRes.success && adRes.data != null) {
_ads = adRes.data!.map((a) => AdModel.fromJson(a)).toList();
}
notifyListeners(); notifyListeners();
} }
......
...@@ -28,6 +28,7 @@ class _HomeScreenState extends State<HomeScreen> { ...@@ -28,6 +28,7 @@ class _HomeScreenState extends State<HomeScreen> {
List<EventModel> _events = []; List<EventModel> _events = [];
List<BenefitModel> _benefits = []; List<BenefitModel> _benefits = [];
List<ArticleModel> _articles = []; List<ArticleModel> _articles = [];
List<AdModel> _ads = [];
bool _isLoading = true; bool _isLoading = true;
// Bottom Ads Carousel // Bottom Ads Carousel
...@@ -53,8 +54,8 @@ class _HomeScreenState extends State<HomeScreen> { ...@@ -53,8 +54,8 @@ class _HomeScreenState extends State<HomeScreen> {
void _startAdAutoScroll() { void _startAdAutoScroll() {
_adTimer?.cancel(); _adTimer?.cancel();
_adTimer = Timer.periodic(const Duration(seconds: 4), (_) { _adTimer = Timer.periodic(const Duration(seconds: 4), (_) {
if (!_adPageController.hasClients) return; if (!_adPageController.hasClients || _ads.length < 2) return;
final nextIndex = (_currentAdIndex + 1) % 3; // 3 ads: asuransi, oli, merch final nextIndex = (_currentAdIndex + 1) % _ads.length;
_adPageController.animateToPage( _adPageController.animateToPage(
nextIndex, nextIndex,
duration: const Duration(milliseconds: 600), duration: const Duration(milliseconds: 600),
...@@ -90,6 +91,12 @@ class _HomeScreenState extends State<HomeScreen> { ...@@ -90,6 +91,12 @@ class _HomeScreenState extends State<HomeScreen> {
_articles = artRes.data!.map((a) => ArticleModel.fromJson(a)).toList(); _articles = artRes.data!.map((a) => ArticleModel.fromJson(a)).toList();
} }
// Load Footer Ads
final adRes = await ApiService.getAds();
if (adRes.success && adRes.data != null) {
_ads = adRes.data!.map((a) => AdModel.fromJson(a)).toList();
}
if (mounted) { if (mounted) {
setState(() => _isLoading = false); setState(() => _isLoading = false);
} }
...@@ -821,43 +828,97 @@ class _HomeScreenState extends State<HomeScreen> { ...@@ -821,43 +828,97 @@ class _HomeScreenState extends State<HomeScreen> {
); );
} }
Color _badgeColor(String hex) {
final h = hex.replaceAll('#', '');
if (h.length == 6) {
return Color(int.parse('FF$h', radix: 16));
}
return const Color(0xFF10B981);
}
Widget _buildAdImageWidget(String imageSrc, Color badgeColor) {
if (imageSrc.isEmpty) {
return Center(child: Icon(Icons.shopping_bag, size: 36, color: badgeColor));
}
String? localFallback;
if (imageSrc.contains('ad_bpjs_banner')) {
localFallback = 'assets/images/ad_bpjs_banner.jpg';
} else if (imageSrc.contains('ad_motul_300v')) {
localFallback = 'assets/images/ad_motul_300v.png';
} else if (imageSrc.contains('ad_merch_jacket')) {
localFallback = 'assets/images/ad_merch_jacket.webp';
}
if (imageSrc.startsWith('http://') || imageSrc.startsWith('https://')) {
return Image.network(
imageSrc,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) {
if (localFallback != null) {
return Image.asset(localFallback, fit: BoxFit.contain);
}
return Center(child: Icon(Icons.shopping_bag, size: 36, color: badgeColor));
},
);
}
return Image.asset(
imageSrc,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Center(
child: Icon(Icons.shopping_bag, size: 36, color: badgeColor),
),
);
}
Widget _buildBottomAdsCarouselSection(BuildContext context) { Widget _buildBottomAdsCarouselSection(BuildContext context) {
// 3 Real Ads: Asuransi (BPJS Ketenagakerjaan JKK Motorsport), Oli (Motul 300V Factory Line), Merch (Juaraga Official IMI) List<Map<String, dynamic>> ads;
final ads = [ if (_ads.isNotEmpty) {
ads = _ads.map((a) => <String, dynamic>{
'category': a.category,
'badgeColor': _badgeColor(a.badgeColor),
'title': a.title,
'subtitle': a.subtitle,
'actionText': a.actionText,
'url': a.url,
'details': a.details,
'image': a.imageUrl,
}).toList();
} else {
ads = [
{ {
'category': 'PROTEKSI ATLET BALAP', 'category': 'PROTEKSI ATLET BALAP',
'badgeColor': const Color(0xFF10B981), // Emerald 'badgeColor': const Color(0xFF10B981),
'title': 'BPJS Ketenagakerjaan Atlet & Pembalap', 'title': 'BPJS Ketenagakerjaan Atlet & Pembalap',
'subtitle': 'Jaminan Kecelakaan Kerja (JKK) tanpa batas biaya & JKM santunan resmi atlet IMI.', 'subtitle': 'Jaminan Kecelakaan Kerja (JKK) tanpa batas biaya & JKM santunan resmi atlet IMI.',
'image': 'assets/images/ad_bpjs_banner.jpg', 'image': 'assets/images/ad_bpjs_banner.jpg',
'fit': BoxFit.cover,
'actionText': 'Pelajari Program', 'actionText': 'Pelajari Program',
'url': 'https://www.bpjsketenagakerjaan.go.id', 'url': 'https://www.bpjsketenagakerjaan.go.id',
'details': 'Program perlindungan resmi bagi pembalap dan atlet motorsport IMI sesuai UU Keolahragaan No. 11/2022. Mencakup perawatan medis tanpa batas biaya sampai sembuh untuk insiden di sirkuit maupun latihan resmi.', 'details': 'Program perlindungan resmi bagi pembalap dan atlet motorsport IMI sesuai UU Keolahragaan No. 11/2022. Mencakup perawatan medis tanpa batas biaya sampai sembuh untuk insiden di sirkuit maupun latihan resmi.',
}, },
{ {
'category': 'OFFICIAL RACING OIL', 'category': 'OFFICIAL RACING OIL',
'badgeColor': const Color(0xFFE11D48), // Motul Crimson 'badgeColor': const Color(0xFFE11D48),
'title': 'Motul 300V Factory Line Road Racing', 'title': 'Motul 300V Factory Line Road Racing',
'subtitle': 'Pelumas Ester Core® 100% sintetis untuk performa mesin maksimum & perlindungan putaran tinggi.', 'subtitle': 'Pelumas Ester Core® 100% sintetis untuk performa mesin maksimum & perlindungan putaran tinggi.',
'image': 'assets/images/ad_motul_300v.png', 'image': 'assets/images/ad_motul_300v.png',
'fit': BoxFit.contain,
'actionText': 'Beli di Official Store', 'actionText': 'Beli di Official Store',
'url': 'https://www.motul.com/id/id', 'url': 'https://www.motul.com/id/id',
'details': 'Oli resmi balap Kejurnas IMI Jabar didistribusikan oleh PT Motul Indonesia Energy (MIE). Teknologi ESTER Core® memberikan daya tahan gesekan ekstrem pada transmisi dan kopling basah (JASO MA2).', 'details': 'Oli resmi balap Kejurnas IMI Jabar didistribusikan oleh PT Motul Indonesia Energy (MIE). Teknologi ESTER Core® memberikan daya tahan gesekan ekstrem pada transmisi dan kopling basah (JASO MA2).',
}, },
{ {
'category': 'OFFICIAL MERCHANDISE', 'category': 'OFFICIAL MERCHANDISE',
'badgeColor': AppColors.circuitGold, // Gold 'badgeColor': AppColors.circuitGold,
'title': 'Juaraga Official Merchandise IMI', 'title': 'Juaraga Official Merchandise IMI',
'subtitle': 'Koleksi jaket windbreaker, paddock jersey, dan apparel resmi berlisensi Ikatan Motor Indonesia.', 'subtitle': 'Koleksi jaket windbreaker, paddock jersey, dan apparel resmi berlisensi Ikatan Motor Indonesia.',
'image': 'assets/images/ad_merch_jacket.webp', 'image': 'assets/images/ad_merch_jacket.webp',
'fit': BoxFit.contain,
'actionText': 'Lihat Katalog Juaraga', 'actionText': 'Lihat Katalog Juaraga',
'url': 'https://juaraga.id', 'url': 'https://juaraga.id',
'details': 'Merchandise resmi IMI diproduksi eksklusif oleh Juaraga dengan material premium standar motorsport internasional. Dapatkan apparel resmi dan dukung ekosistem balap nasional.', 'details': 'Merchandise resmi IMI diproduksi eksklusif oleh Juaraga dengan material premium standar motorsport internasional. Dapatkan apparel resmi dan dukung ekosistem balap nasional.',
}, },
]; ];
}
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
...@@ -1028,12 +1089,9 @@ class _HomeScreenState extends State<HomeScreen> { ...@@ -1028,12 +1089,9 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Image.asset( child: _buildAdImageWidget(
ad['image'] as String, ad['image'] as String? ?? '',
fit: ad['fit'] as BoxFit, badgeColor,
errorBuilder: (_, __, ___) => Center(
child: Icon(Icons.shopping_bag, size: 36, color: badgeColor),
),
), ),
), ),
), ),
...@@ -1118,8 +1176,10 @@ class _HomeScreenState extends State<HomeScreen> { ...@@ -1118,8 +1176,10 @@ class _HomeScreenState extends State<HomeScreen> {
const SizedBox(height: 20), const SizedBox(height: 20),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: () async { onPressed: () async {
final url = (ad['url'] as String).trim();
if (url.isEmpty) return;
Navigator.pop(ctx); Navigator.pop(ctx);
final uri = Uri.parse(ad['url'] as String); final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) { if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication); await launchUrl(uri, mode: LaunchMode.externalApplication);
} }
......
<!doctype html>
<html lang="id">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mockup Slider Iklan — Footer Beranda IMI</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: radial-gradient(1200px 600px at 15% -10%, #16223a 0%, #0b1220 55%);
color: #e8edf5;
min-height: 100vh;
padding: 40px 20px 60px;
}
.wrap { max-width: 1120px; margin: 0 auto; }
header { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 8px; }
h1 { font-size: 22px; margin: 0 0 6px; letter-spacing: -0.01em; }
.sub { color: #94a3b8; font-size: 13px; margin: 0; }
.src {
font-size: 12px; color: #7dd3fc; background: #0c1a2e;
border: 1px solid #1e3a5f; padding: 6px 10px; border-radius: 8px;
display: inline-flex; gap: 8px; align-items: center;
}
.src b { color: #fbbf24; font-weight: 600; }
.layout { display: grid; grid-template-columns: minmax(300px, 380px) 1fr; gap: 36px; align-items: start; margin-top: 28px; }
/* Phone frame */
.phone {
background: #0e1522; border: 10px solid #232b3b; border-radius: 44px;
box-shadow: 0 30px 80px rgba(0,0,0,.5), inset 0 0 0 2px #1a2130;
padding: 14px 14px 18px; position: relative;
}
.phone::before {
content: ""; position: absolute; top: -10px; left: 50%; transform: translateX(-50%);
width: 110px; height: 24px; background: #232b3b; border-radius: 0 0 14px 14px;
}
.statusbar { display: flex; justify-content: space-between; padding: 2px 8px 10px; font-size: 11px; color: #cbd5e1; font-weight: 600; }
.screen { background: #f4f6f9; border-radius: 26px; overflow: hidden; min-height: 620px; position: relative; }
.appbar { background: linear-gradient(180deg, #0f172a, #111827); padding: 14px 16px 10px; color: #fff; }
.appbar .hi { font-size: 12px; color: #94a3b8; }
.appbar .greet { font-size: 16px; font-weight: 700; }
.card {
background: #fff; border-radius: 16px; margin: 12px; overflow: hidden;
box-shadow: 0 8px 24px rgba(15, 23, 42, .12);
}
.section-title { padding: 18px 16px 6px; }
.section-title span { font-size: 13px; font-weight: 700; color: #0f172a; }
.section-title em { font-size: 11px; color: #94a3b8; font-style: normal; font-weight: 500; margin-left: 6px; }
.slider { position: relative; }
.pane { display: none; animation: fade .45s ease; }
.pane.on { display: flex; }
@keyframes fade { from { opacity: 0; transform: translateX(10px);} to { opacity: 1; transform: none;} }
.pane-inner { display: flex; align-items: center; gap: 14px; padding: 16px; min-height: 170px; }
.pane-left { flex: 1; min-width: 0; }
.chip {
display: inline-flex; align-items: center; gap: 6px; font-size: 9.5px; font-weight: 700;
letter-spacing: .10em; color: #fff; padding: 4px 8px; border-radius: 999px;
box-shadow: inset 0 0 0 1px rgba(255,255,255,.25);
}
.chip i { width: 6px; height: 6px; border-radius: 50%; background: rgba(255,255,255,.85); }
.pane h3 { margin: 9px 0 4px; font-size: 14.5px; font-weight: 800; color: #0f172a; line-height: 1.25; }
.pane p { margin: 0 0 10px; font-size: 11.5px; color: #5b6472; line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.cta { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700; }
.cta svg { width: 14px; height: 14px; }
.thumb {
width: 128px; height: 128px; border-radius: 14px; background: radial-gradient(circle at 30% 20%, #fff, #eef1f6);
display: flex; align-items: center; justify-content: center; padding: 8px; flex-shrink: 0;
border: 1px solid #e8ecf2; overflow: hidden;
}
.thumb img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 6px; }
.dots { display: flex; gap: 6px; justify-content: center; padding: 0 0 12px; position: absolute; bottom: -2px; left: 0; right: 0; }
.dots button {
border: 0; height: 5px; border-radius: 999px; background: #cbd5e1; cursor: pointer; padding: 0; transition: all .25s; width: 6px;
}
.dots button.on { width: 20px; }
/* Info / data detail panel */
.panel { background: #101a2c; border: 1px solid #1e2c44; border-radius: 16px; padding: 20px 22px; }
.panel h2 { font-size: 14px; margin: 0 0 4px; color: #f1f5f9; }
.panel .hint { font-size: 12px; color: #7c8aa0; margin: 0 0 16px; }
.rec {
display: grid; grid-template-columns: 44px 1fr auto; gap: 12px; align-items: center;
background: #0c1626; border: 1px solid #1c2c46; border-radius: 12px; padding: 10px 12px; margin-bottom: 10px;
}
.rec img { width: 44px; height: 34px; object-fit: cover; border-radius: 6px; background: #15233c; }
.rec .t { font-size: 12.5px; font-weight: 600; color: #e6edf7; }
.rec .meta { font-size: 11px; color: #7c8aa0; margin-top: 2px; }
.rec .ord {
font-size: 11px; color: #9db1c9; background: #16233b; border: 1px solid #243651;
padding: 3px 8px; border-radius: 999px; font-weight: 700;
}
.err {
display: none; margin-bottom: 14px; font-size: 12.5px; color: #fecaca; background: rgba(127,29,29,.25);
border: 1px solid rgba(248,113,113,.4); padding: 10px 12px; border-radius: 10px;
}
.err.show { display: block; }
a.btn { display: inline-block; margin-top: 4px; font-size: 12.5px; color: #7dd3fc; text-decoration: none; }
a.btn:hover { text-decoration: underline; }
@media (max-width: 860px) { .layout { grid-template-columns: 1fr; } .phone { max-width: 400px; margin: 0 auto; } }
</style>
</head>
<body>
<div class="wrap">
<header>
<div>
<h1>Mockup Slider Iklan — Footer Beranda</h1>
<p class="sub">Data asli dari <code>/api/v1/ads/</code> · dikelola di Web Admin → Slider Iklan</p>
</div>
<div class="src">Sumber data: <b id="srcState">…</b></div>
</header>
<div class="layout">
<div class="phone">
<div class="statusbar"><span>09:41</span><span>5G ▛</span></div>
<div class="screen">
<div class="appbar"><div class="hi">Selamat pagi</div><div class="greet">Pebalap</div></div>
<div class="card">
<div class="section-title">Beranda <em>live preview</em></div>
<div class="slider" id="slider" aria-label="Slider iklan footer"></div>
</div>
</div>
</div>
<div class="panel">
<h2>Data slider (langsung dari API)</h2>
<p class="hint">Urutan &amp; status mengikuti <code>ordinal</code> dan <code>is_active</code> pada tabel <code>Advertisement</code>.</p>
<div class="err" id="err"></div>
<div id="recs"></div>
<a class="btn" href="http://127.0.0.1:5173" target="_blank">→ SPA /</a>
<a class="btn" href="http://127.0.0.1:8001/api/docs/" target="_blank">→ API Docs</a>
</div>
</div>
</div>
<script>
const API = "http://127.0.0.1:8001/api/v1/ads/";
const CTA_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>';
const fallback = [
{ title: "BPJS Ketenagakerjaan Atlet & Pembalap", category: "PROTEKSI ATLET BALAP", badge_color: "#10B981",
subtitle: "Jaminan Kecelakaan Kerja (JKK) tanpa batas biaya & JKM santunan resmi atlet IMI.",
action_text: "Pelajari Program", url: "https://www.bpjsketenagakerjaan.go.id",
image_url: "http://127.0.0.1:8001/media/ads/ad_bpjs_banner.jpg",
details: "Program perlindungan resmi bagi pembalap dan atlet motorsport IMI sesuai UU Keolahragaan No. 11/2022. Mencakup perawatan medis tanpa batas biaya sampai sembuh untuk insiden di sirkuit maupun latihan resmi.", ordinal: 1 },
{ title: "Motul 300V Factory Line Road Racing", category: "OFFICIAL RACING OIL", badge_color: "#E11D48",
subtitle: "Pelumas Ester Core\u00ae 100% sintetis untuk performa mesin maksimum & perlindungan putaran tinggi.",
action_text: "Beli di Official Store", url: "https://www.motul.com/id/id",
image_url: "http://127.0.0.1:8001/media/ads/ad_motul_300v.png",
details: "Oli resmi balap Kejurnas IMI Jabar didistribusikan oleh PT Motul Indonesia Energy (MIE). Teknologi ESTER Core\u00ae memberikan daya tahan gesekan ekstrem pada transmisi dan kopling basah (JASO MA2).", ordinal: 2 },
{ title: "Juaraga Official Merchandise IMI", category: "OFFICIAL MERCHANDISE", badge_color: "#F59E0B",
subtitle: "Koleksi jaket windbreaker, paddock jersey, dan apparel resmi berlisensi Ikatan Motor Indonesia.",
action_text: "Lihat Katalog Juaraga", url: "https://juaraga.id",
image_url: "http://127.0.0.1:8001/media/ads/ad_merch_jacket.webp",
details: "Merchandise resmi IMI diproduksi eksklusif oleh Juaraga dengan material premium standar motorsport internasional. Dapatkan apparel resmi dan dukung ekosistem balap nasional.", ordinal: 3 },
];
let ads = fallback, idx = 0, timer = null;
const slider = () => document.getElementById("slider");
const errEl = () => document.getElementById("err");
const stateEl = () => document.getElementById("srcState");
function render() {
const el = slider();
el.innerHTML = "";
ads.forEach((a, i) => {
const chipStyle = a.badge_color ? `background:${a.badge_color}` : "background:#10B981";
const pane = document.createElement("div");
pane.className = "pane" + (i === 0 ? " on" : "");
pane.style.outline = "1px solid transparent"; /* keep animation clean */
pane.innerHTML = `
<div class="pane-inner">
<div class="pane-left">
<span class="chip" style="${chipStyle}"><i></i>${esc(a.category || "PROMO")}</span>
<h3>${esc(a.title)}</h3>
<p>${esc(a.subtitle || "")}</p>
<span class="cta" style="color:${a.badge_color || "#10B981"}">${esc(a.action_text || "Pelajari")} ${CTA_ICON}</span>
</div>
<div class="thumb">${a.image_url
? `<img src="${esc(a.image_url)}" alt="${esc(a.title)}" loading="lazy" onerror="this.parentNode.innerHTML=''">`
: `<span style="font-size:10px;color:#94a3b8">no image</span>`}</div>
</div>`;
pane.addEventListener("click", () => show(a));
el.appendChild(pane);
});
const dots = document.createElement("div");
dots.className = "dots";
dots.innerHTML = ads.map((_, i) => `<button data-i="${i}" aria-label="Slide ${i + 1}" class="${i === 0 ? "on" : ""}"></button>`).join("");
dots.addEventListener("click", (e) => {
const b = e.target.closest("button"); if (!b) return;
goto(+b.dataset.i); restart();
});
el.appendChild(dots);
renderRecs();
restart();
}
function renderRecs() {
document.getElementById("recs").innerHTML = ads.map((a) => `
<div class="rec">
${a.image_url ? `<img src="${esc(a.image_url)}" alt="" onerror="this.style.display='none'">` : `<div style="width:44px;height:34px;border-radius:6px;background:#1a2740"></div>`}
<div><div class="t">${esc(a.title)}</div><div class="meta">${esc(a.category || "—")} · ${esc(a.action_text || "Tanpa tombol")}${a.is_active === false ? " · <b style='color:#f87171'>NONAKTIF</b>" : ""}</div></div>
<span class="ord">#${a.ordinal}</span>
</div>`).join("");
}
function show(a) {
const details = a.details ? `\n\n${a.details}` : "";
alert(`[Detail Sheet — mockup]\n${a.title} (${a.category})\n${a.subtitle}${details}`);
}
function goto(i) {
idx = (i + ads.length) % ads.length;
[...slider().querySelectorAll(".pane")].forEach((p, k) => p.classList.toggle("on", k === idx));
[...slider().querySelectorAll(".dots button")].forEach((b, k) => b.classList.toggle("on", k === idx));
}
function restart() {
clearInterval(timer);
if (ads.length > 1) timer = setInterval(() => goto(idx + 1), 4000);
}
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c])); }
fetch(API).then((r) => r.json()).then((data) => {
ads = (Array.isArray(data) ? data : (data.results || []))
.filter((a) => a.is_active !== false)
.sort((a, b) => (a.ordinal || 0) - (b.ordinal || 0));
stateEl().textContent = `${ads.length} ads · live API`;
render();
}).catch(() => {
stateEl().textContent = "fallback offline";
errEl().classList.add("show");
errEl().textContent = "API tidak terbaca — menampilkan salinan data terakhir. Jalankan server backend di 127.0.0.1:8001 lalu muat ulang.";
render();
});
</script>
</body>
</html>
\ No newline at end of file
...@@ -4,8 +4,10 @@ import * as dashboard from "./pages/dashboard.js"; ...@@ -4,8 +4,10 @@ import * as dashboard from "./pages/dashboard.js";
import * as members from "./pages/members.js"; import * as members from "./pages/members.js";
import * as events from "./pages/events.js"; import * as events from "./pages/events.js";
import * as content from "./pages/content.js"; import * as content from "./pages/content.js";
import * as ads from "./pages/ads.js";
import * as partners from "./pages/partners.js"; import * as partners from "./pages/partners.js";
import * as checkins from "./pages/checkins.js"; import * as checkins from "./pages/checkins.js";
import * as finance from "./pages/finance.js";
import * as roles from "./pages/roles.js"; import * as roles from "./pages/roles.js";
const ROLE_LABEL = Object.fromEntries(ROLES.map((r) => [r.value, r.label])); const ROLE_LABEL = Object.fromEntries(ROLES.map((r) => [r.value, r.label]));
...@@ -15,8 +17,10 @@ const PAGES = [ ...@@ -15,8 +17,10 @@ const PAGES = [
{ key: "members", title: "Member Management", icon: "◉", page: members }, { key: "members", title: "Member Management", icon: "◉", page: members },
{ key: "events", title: "Event Management", icon: "❖", page: events }, { key: "events", title: "Event Management", icon: "❖", page: events },
{ key: "content", title: "Content Management", icon: "▤", page: content }, { key: "content", title: "Content Management", icon: "▤", page: content },
{ key: "ads", title: "Slider Iklan", icon: "▰", page: ads },
{ key: "partners", title: "Partner & Benefit", icon: "◈", page: partners }, { key: "partners", title: "Partner & Benefit", icon: "◈", page: partners },
{ key: "checkins", title: "Check-in & Tiket", icon: "✓", page: checkins }, { key: "checkins", title: "Check-in & Tiket", icon: "✓", page: checkins },
{ key: "finance", title: "Harga & Bagi Hasil", icon: "¥", page: finance },
{ key: "roles", title: "User & Role", icon: "⚿", page: roles }, { key: "roles", title: "User & Role", icon: "⚿", page: roles },
]; ];
......
import { api } from "../api.js";
import { h, badge, emptyRow, field, makePager, toast, openModal, select, confirmDialog, errorCard, withBusy, clearFieldErrors, addFieldError, applyApiErrors } from "../ui.js";
const PAGE_SIZE = 20;
export function render(mount) {
mount.innerHTML = "";
const state = { page: 1, search: "", active: "" };
const searchInput = h("input", {
type: "search", placeholder: "Cari judul / kategori…", class: "grow",
oninput: (e) => debounce(() => { state.search = e.target.value.trim(); state.page = 1; load(); }),
});
const activeSel = select([
{ value: "", label: "Semua status" },
{ value: "true", label: "Aktif" },
{ value: "false", label: "Nonaktif" },
], state.active, {
onchange: (e) => { state.active = e.target.value; state.page = 1; load(); },
});
const newBtn = h("button", { class: "btn btn-primary", onclick: () => openAdForm(null, () => load()) }, "+ Tambah Iklan");
mount.append(h("div", { class: "toolbar" }, searchInput, activeSel, h("div", { class: "spacer" }), newBtn));
const tableWrap = h("div");
mount.append(tableWrap);
async function load() {
tableWrap.innerHTML = "";
tableWrap.append(h("div", { class: "card card-pad" }, "Memuat…"));
try {
const data = await api.get("/ads/manage/", {
page: state.page, search: state.search, is_active: state.active,
});
renderTable(tableWrap, data, state, load);
} catch (err) {
tableWrap.innerHTML = "";
tableWrap.append(errorCard(err.message, load));
}
}
load();
}
function colorPreview(hex) {
const box = h("span", {
style: `display:inline-block;width:12px;height:12px;border-radius:3px;background:${hex || "#ccc"};vertical-align:-1px;margin-right:6px;border:1px solid var(--line,#e3e8ee)`,
});
return box;
}
function renderTable(wrap, data, state, reload) {
wrap.innerHTML = "";
const card = h("div", { class: "card" });
const rows = data.results.map((ad) =>
h("tr", {},
h("td", {},
h("div", { style: "display:flex;align-items:center;gap:10px" },
ad.image_url
? h("img", { src: ad.image_url, style: "width:44px;height:32px;object-fit:cover;border-radius:6px;background:#f1f4f8" })
: h("span", { style: "width:44px;height:32px;border-radius:6px;background:#f1f4f8;display:inline-flex;align-items:center;justify-content:center;font-size:12px;color:#94a3b8" }, "—"),
h("div", {},
h("strong", {}, ad.title),
h("div", { class: "muted", style: "font-size:12px" }, ad.category || "-"),
),
),
),
h("td", {}, colorPreview(ad.badge_color), ad.badge_color),
h("td", {}, ad.action_text || "-"),
h("td", { class: "num" }, ad.ordinal),
h("td", {}, badge(ad.is_active ? "enabled" : "disabled", ad.is_active ? "Aktif" : "Nonaktif")),
h("td", {}, h("div", { class: "row-actions" },
h("button", { class: "btn btn-sm", onclick: () => openAdForm(ad, reload) }, "Edit"),
h("button", { class: "btn btn-sm btn-danger", onclick: () => remove(ad, reload) }, "Hapus"),
)),
),
);
card.append(
h("div", { class: "table-wrap" },
h("table", { class: "tbl" },
h("thead", {}, h("tr", {}, ["Iklan", "Warna", "Tombol", "Urutan", "Status", ""].map((t) => h("th", {}, t)))),
h("tbody", {}, rows.length ? rows : emptyRow("Tidak ada iklan.")),
),
),
makePager({ page: state.page, totalPages: Math.max(1, Math.ceil(data.count / PAGE_SIZE)), count: data.count, onChange: (p) => { state.page = p; reload(); } }),
);
wrap.append(card);
}
async function remove(ad, reload) {
if (!(await confirmDialog(`Hapus iklan "${ad.title}"?`))) return;
try {
await api.del(`/ads/manage/${ad.id}/`);
toast("Iklan dihapus.", "ok");
reload();
} catch (err) {
toast(err.message, "err");
}
}
function openAdForm(ad, reload) {
const V = (name) => (ad ? (ad[name] ?? "") : "");
const preview = h("img", {
style: "max-height:90px;border-radius:8px;background:#f1f4f8;display:none",
onerror: () => { preview.style.display = "none"; },
});
const imageUrlInput = h("input", {
type: "url", name: "image_url", value: V("image_url"),
placeholder: "https://… (boleh kosong)",
oninput: () => {
const v = imageUrlInput.value.trim();
if (v) { preview.src = v; preview.style.display = ""; }
else preview.style.display = "none";
},
});
const form = h("form", { novalidate: "" },
field("Judul Iklan *", h("input", { type: "text", required: true, name: "title", value: V("title") })),
h("div", { class: "form-grid" },
field("Kategori / Badge", h("input", { type: "text", name: "category", value: V("category"), placeholder: "mis. PROTEKSI ATLET BALAP" })),
field("Teks Tombol", h("input", { type: "text", name: "action_text", value: V("action_text") })),
),
field("Subjudul", h("input", { type: "text", name: "subtitle", value: V("subtitle") })),
field("Detail (sheet saat diklik)", h("textarea", { name: "details", rows: 3 }, V("details"))),
field("URL Tujuan", h("input", { type: "url", name: "url", value: V("url"), placeholder: "https://…" })),
field("Gambar Iklan", imageUrlInput),
preview,
h("div", { class: "form-grid" },
field("Warna Aksen", h("input", { type: "color", name: "badge_color", value: V("badge_color") || "#10B981", style: "height:38px;width:64px;padding:2px" })),
field("Urutan", h("input", { type: "number", min: "0", name: "ordinal", value: V("ordinal") })),
),
h("div", { style: "margin-top:10px" },
h("label", { class: "check" }, h("input", { type: "checkbox", name: "is_active", checked: ad ? !!ad.is_active : true }), "Aktif (ditampilkan di slider)"),
),
);
if (imageUrlInput.value) {
preview.src = imageUrlInput.value;
preview.style.display = "";
}
const saveBtn = h("button", { class: "btn btn-primary", type: "button", onclick: submit }, ad ? "Simpan Perubahan" : "Tambah Iklan");
const modal = openModal({
title: ad ? "Edit Iklan" : "Tambah Iklan",
wide: true,
body: form,
foot: h("div", { class: "modal-foot" },
h("button", { class: "btn btn-ghost", type: "button", onclick: () => modal.close() }, "Batal"),
saveBtn,
),
});
async function submit() {
clearFieldErrors(form);
if (!form.reportValidity()) {
const bad = form.querySelector("[aria-invalid], :invalid");
if (bad) bad.focus();
return;
}
const vals = collect(form);
if (!vals.title) { addFieldError(form, "title", "Judul iklan wajib diisi."); form.elements.title.focus(); return; }
await withBusy(saveBtn, async () => {
try {
if (ad) {
await api.patch(`/ads/manage/${ad.id}/`, vals);
toast("Perubahan disimpan.", "ok");
} else {
await api.post("/ads/manage/", vals);
toast("Iklan ditambahkan.", "ok");
}
modal.close();
reload();
} catch (err) {
applyApiErrors(form, err);
}
});
}
}
function collect(form) {
const out = {};
for (const el of form.querySelectorAll("input, select, textarea")) {
if (!el.name) continue;
if (el.type === "checkbox") { out[el.name] = el.checked; continue; }
const v = el.value.trim();
if (el.type === "number") out[el.name] = v === "" ? 0 : Number(v);
else out[el.name] = v;
}
return out;
}
let _debounce;
function debounce(fn, ms = 400) {
clearTimeout(_debounce);
_debounce = setTimeout(fn, ms);
}
\ No newline at end of file
...@@ -70,7 +70,14 @@ function summary(stats, definitions) { ...@@ -70,7 +70,14 @@ function summary(stats, definitions) {
), ),
), ),
); );
const destinations = [["#/members", "Kelola anggota"], ["#/events", "Kelola event"], ["#/content", "Kelola konten"]]; const destinations = [
["#/members", "Kelola anggota"],
["#/events", "Kelola event"],
["#/content", "Kelola konten"],
["#/finance", "Atur bagi hasil & harga"],
["#/finance", "Atur settlement & payout"],
["#/finance", "Detail laporan keuangan"],
];
const sections = h("div", { class: "dashboard-sections" }, const sections = h("div", { class: "dashboard-sections" },
definitions.map(({ title, items }, index) => h("section", { class: "dashboard-panel" }, definitions.map(({ title, items }, index) => h("section", { class: "dashboard-panel" },
h("h3", {}, title), h("h3", {}, title),
......
...@@ -34,6 +34,38 @@ const SECTIONS = [ ...@@ -34,6 +34,38 @@ const SECTIONS = [
["Banner aktif", "banners_active"], ["Banner aktif", "banners_active"],
], ],
}, },
{
title: "Revenue Sharing",
items: [
["Order lunas", "rs_orders"],
["Nilai tiket (setelah diskon)", "rs_taxable", money],
["Bagian Tim Aplikasi", "rs_app_fee", money],
["Bagian IMI", "rs_imi_fee", money],
["Biaya layanan", "rs_service_fee", money],
["Pajak PB1", "rs_tax", money],
["Total diterima", "rs_total", money],
],
},
{
title: "Settlement",
items: [
["Pembayaran terverifikasi", "st_settled"],
["Nominal settlement", "st_settled_amount", money],
["Menunggu pembayaran", "st_pending"],
["Bagian IMI (siap payout)", "rs_imi_fee", money],
["Bagian Tim Aplikasi (siap payout)", "rs_app_fee", money],
],
},
{
title: "Rekonsiliasi",
items: [
["Order lunas", "rc_orders"],
["Pembayaran terverifikasi", "rc_payments"],
["Order lunas tanpa verifikasi", "rc_orders_unmatched"],
["Pembayaran tanpa order lunas", "rc_payments_unmatched"],
["Selisih nominal", "rc_diff", money],
],
},
]; ];
export function render(mount) { export function render(mount) {
......
import { api } from "../api.js"; import { api } from "../api.js";
import { import {
h, badge, chip, dgrid, emptyRow, field, fmtDate, fmtDT, makePager, money, h, badge, chip, dgrid, emptyRow, field, fmtDate, fmtDT, makePager, money,
toast, openModal, select, errorCard, withBusy, clearFieldErrors, addFieldError, applyApiErrors, toast, openModal, select, errorCard, withBusy, confirmDialog,
clearFieldErrors, addFieldError, applyApiErrors,
} from "../ui.js"; } from "../ui.js";
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
...@@ -11,6 +12,9 @@ const EVENT_STATUSES = [ ...@@ -11,6 +12,9 @@ const EVENT_STATUSES = [
["ongoing", "Sedang Berlangsung"], ["completed", "Selesai"], ["cancelled", "Dibatalkan"], ["ongoing", "Sedang Berlangsung"], ["completed", "Selesai"], ["cancelled", "Dibatalkan"],
]; ];
const STATUS_LABEL = Object.fromEntries(EVENT_STATUSES); const STATUS_LABEL = Object.fromEntries(EVENT_STATUSES);
const TIER_STATUSES = [
["enabled", "Aktif"], ["disabled", "Nonaktif"],
];
function statusLabel(s) { function statusLabel(s) {
return STATUS_LABEL[s] || s; return STATUS_LABEL[s] || s;
...@@ -84,6 +88,7 @@ function renderTable(wrap, data, state, reload) { ...@@ -84,6 +88,7 @@ function renderTable(wrap, data, state, reload) {
h("td", { class: "num" }, ev.min_ticket_price ? money(ev.min_ticket_price) : "-"), h("td", { class: "num" }, ev.min_ticket_price ? money(ev.min_ticket_price) : "-"),
h("td", {}, h("div", { class: "row-actions" }, h("td", {}, h("div", { class: "row-actions" },
h("button", { class: "btn btn-sm", type: "button", onclick: () => openDetail(ev.slug) }, "Detail"), h("button", { class: "btn btn-sm", type: "button", onclick: () => openDetail(ev.slug) }, "Detail"),
h("button", { class: "btn btn-sm", type: "button", onclick: () => manageTiers(ev) }, "Tiket"),
h("button", { class: "btn btn-sm", type: "button", onclick: () => openEventForm(ev, reload) }, "Edit"), h("button", { class: "btn btn-sm", type: "button", onclick: () => openEventForm(ev, reload) }, "Edit"),
)), )),
), ),
...@@ -105,6 +110,259 @@ function simpleEmpty(title, sub) { ...@@ -105,6 +110,259 @@ function simpleEmpty(title, sub) {
return h("div", {}, h("div", {}, title), sub ? h("div", { class: "muted", style: "font-size:12px" }, sub) : null); return h("div", {}, h("div", {}, title), sub ? h("div", { class: "muted", style: "font-size:12px" }, sub) : null);
} }
function slugify(s) {
return String(s || "").toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30) || "tier";
}
function uniqueTierCode(base, taken) {
let code = base;
let i = 2;
while (taken.has(code)) { code = `${base}-${i}`; i += 1; }
taken.add(code);
return code;
}
async function fetchTiers(eventId) {
const out = [];
let page = 1;
let next = true;
while (next) {
const data = await api.get("/ticket-tiers/", { event: eventId, page });
out.push(...data.results);
next = Boolean(data.next);
page += 1;
}
return out;
}
function tierRowEl(t, onremove) {
const row = h("div", { class: "form-grid", "data-tier-row": "", dataset: t && t.id ? { tierId: String(t.id) } : {}, style: "margin-bottom:10px;padding:10px;border:1px solid var(--line,#e3e8ee);border-radius:8px" },
field("Nama Kelas *", h("input", { type: "text", name: "tier_name", value: t ? t.name : "", placeholder: "mis. Festival / VIP" })),
field("Kode (kosongkan = auto)", h("input", { type: "text", name: "tier_code", value: t ? t.code || "" : "", placeholder: "mis. festival" })),
field("Harga Tiket (Rp) *", h("input", { type: "number", min: "0", name: "tier_price", value: t && t.price != null ? t.price : "" })),
field("Kuota (kosong = tanpa batas)", h("input", { type: "number", min: "1", name: "tier_quota", value: t && t.quota != null ? t.quota : "" })),
field("Status", select(TIER_STATUSES, t ? t.status : "enabled", { name: "tier_status" })),
field("Max / Order", h("input", { type: "number", min: "1", name: "tier_max_per_order", value: t && t.max_per_order ? t.max_per_order : 4 })),
h("div", { style: "display:flex;align-items:center;gap:16px;flex-wrap:wrap" },
h("label", { class: "check" }, h("input", { type: "checkbox", name: "tier_member_only", checked: !!(t && t.member_only) }), "Khusus Member KTA"),
h("label", { class: "check" }, h("input", { type: "checkbox", name: "tier_requires_license", checked: !!(t && t.requires_license) }), "Butuh Lisensi"),
h("button", { class: "btn btn-sm btn-danger-outline", type: "button", onclick: onremove }, "Hapus Kelas"),
),
);
return row;
}
function collectTierRows(box) {
const rows = [];
for (const el of box.querySelectorAll("[data-tier-row]")) {
const q = (n) => { const f = el.querySelector(`[name="${n}"]`); return f ? f.value.trim() : ""; };
const name = q("tier_name");
const price = Number(q("tier_price"));
if (!name || q("tier_price") === "" || isNaN(price) || price < 0) return null;
rows.push({
id: el.dataset.tierId ? Number(el.dataset.tierId) : null,
name,
code: q("tier_code"),
price: Math.round(price),
quota: q("tier_quota") === "" ? null : Number(q("tier_quota")),
status: q("tier_status") || "enabled",
member_only: !!(el.querySelector(`[name="tier_member_only"]`) || {}).checked,
requires_license: !!(el.querySelector(`[name="tier_requires_license"]`) || {}).checked,
max_per_order: Number(q("tier_max_per_order")) || 4,
});
}
return rows;
}
async function syncTiers(eventId, original, rows) {
const taken = new Set((original || []).map((t) => t.code));
for (const r of rows) {
if (r.id) {
const orig = (original || []).find((t) => t.id === r.id);
if (orig && orig.code === r.code) continue;
}
if (taken.has(r.code)) r.code = uniqueTierCode(r.code || slugify(r.name), taken);
else if (r.code) taken.add(r.code);
}
const originalIds = new Set((original || []).map((t) => t.id));
const rowIds = new Set(rows.map((r) => r.id).filter(Boolean));
for (const id of originalIds) {
if (rowIds.has(id)) continue;
try { await api.del(`/ticket-tiers/${id}/`); }
catch (e) { toast(`Hapus tiket #${id}: ${e.message}`, "err"); }
}
for (const r of rows) {
const payload = {
event: eventId, name: r.name, price: r.price, status: r.status,
quota: r.quota, member_only: r.member_only, requires_license: r.requires_license,
max_per_order: r.max_per_order,
};
if (r.code) payload.code = r.code;
try {
if (r.id) await api.patch(`/ticket-tiers/${r.id}/`, payload);
else await api.post("/ticket-tiers/", payload);
} catch (e) {
toast(`Tiket "${r.name}": ${e.message}`, "err");
}
}
}
function buildTierEditor(initialTiers) {
const list = h("div", {});
const addBtn = h("button", { class: "btn btn-sm btn-primary", type: "button", onclick: () => list.append(tierRowEl(null, remove)) }, "+ Tambah Kelas Tiket");
function remove() { this.parentNode.parentNode.remove(); }
list.append(...(initialTiers || []).map((t) => tierRowEl(t, remove)));
return h("div", {},
h("div", { class: "section-title" }, "Kelas Tiket & Harga (harga jual = harga tiket + fee aplikasi + fee IMI + pajak)"),
h("p", { class: "muted", style: "font-size:12px;margin:0 0 8px" },
"Tier yang dihapus hanya boleh yang belum terjual. Kelas dapat diatur ulang dari menu Tiket setelah event dibuat."),
list, addBtn);
}
async function manageTiers(ev) {
let tiers;
try { tiers = await fetchTiers(ev.id); } catch (err) { toast(err.message, "err"); return; }
const body = h("div", {});
const modal = openModal({
title: `Kelas Tiket — ${ev.title}`,
wide: true,
body,
foot: h("div", { class: "modal-foot" },
h("button", { class: "btn btn-ghost", type: "button", onclick: () => modal.close() }, "Tutup")),
});
async function render() {
try { tiers = await fetchTiers(ev.id); } catch (e) { toast(e.message, "err"); }
body.innerHTML = "";
body.append(
tiers.length
? h("table", { class: "tbl" },
h("thead", {}, h("tr", {}, ["Kelas", "Harga", "Kuota", "Terjual", "Status", "Keterangan", "Aksi"].map((t) => h("th", {}, t)))),
h("tbody", {}, tiers.map((t) => h("tr", {},
h("td", {}, h("strong", {}, t.name), h("div", { class: "muted", style: "font-size:12px" }, t.code)),
h("td", { class: "num" }, money(t.price)),
h("td", { class: "num" }, t.quota != null ? t.quota : "∞"),
h("td", { class: "num" }, t.sold_count ?? 0),
h("td", {}, badge(t.status)),
h("td", {}, [
t.member_only ? h("span", { class: "chip" }, "Member KTA") : null,
t.requires_license ? h("span", { class: "chip" }, "Butuh Lisensi") : null,
(t.benefits || []).map((b) => h("span", { class: "chip" }, b.text)),
]),
h("td", {}, h("div", { class: "row-actions" },
h("button", { class: "btn btn-sm", type: "button", onclick: () => openTierForm(ev, t, tiers, render) }, "Edit"),
h("button", { class: "btn btn-sm btn-danger-outline", type: "button", onclick: () => removeTier(t) }, "Hapus"),
)),
))),
)
: h("p", { class: "muted" }, "Belum ada kelas tiket. Tambahkan di bawah."),
h("div", { style: "margin-top:12px" },
h("button", { class: "btn btn-primary btn-sm", type: "button", onclick: () => openTierForm(ev, null, tiers, render) }, "+ Tambah Kelas Tiket")),
);
}
async function removeTier(t) {
if (t.sold_count > 0) {
toast(`"${t.name}" sudah terjual ${t.sold_count}; gunakan Edit untuk menonaktifkan.`, "err");
return;
}
const ok = await confirmDialog(`Hapus kelas tiket "${t.name}"?`);
if (!ok) return;
try {
await api.del(`/ticket-tiers/${t.id}/`);
toast("Kelas tiket dihapus.", "ok");
render();
} catch (err) { toast(err.message, "err"); }
}
await render();
}
function openTierForm(ev, tier, existingTiers, onSaved) {
const V = (n) => (tier && tier[n] != null ? tier[n] : "");
const priceInput = h("input", { type: "number", min: "0", required: true, name: "price", value: V("price") });
const hint = h("div", { class: "muted", style: "font-size:12px;margin:-4px 0 2px" }, "Isi harga untuk melihat harga jual.");
const form = h("form", { novalidate: "" },
h("div", { class: "form-grid" },
field("Nama Kelas *", h("input", { type: "text", required: true, name: "name", value: V("name") })),
field("Kode (kosongkan = auto)", h("input", { type: "text", name: "code", value: V("code") })),
field("Harga Tiket (Rp) *", priceInput),
field("Kuota (kosong = tanpa batas)", h("input", { type: "number", min: "1", name: "quota", value: V("quota") })),
field("Status", select(TIER_STATUSES, tier ? tier.status : "enabled", { name: "status" })),
field("Max / Order", h("input", { type: "number", min: "1", name: "max_per_order", value: V("max_per_order") || 4 })),
field("Mulai Dijual", h("input", { type: "datetime-local", name: "sale_start", value: V("sale_start") ? toLocal(V("sale_start")) : "" })),
field("Selesai Dijual", h("input", { type: "datetime-local", name: "sale_end", value: V("sale_end") ? toLocal(V("sale_end")) : "" })),
),
h("div", { style: "display:flex;gap:16px;flex-wrap:wrap;margin:6px 0 10px" },
h("label", { class: "check" }, h("input", { type: "checkbox", name: "member_only", checked: !!V("member_only") }), "Khusus Member KTA"),
h("label", { class: "check" }, h("input", { type: "checkbox", name: "requires_license", checked: !!V("requires_license") }), "Butuh Lisensi"),
),
field("Deskripsi", h("textarea", { name: "description", rows: 2 }, V("description"))),
field("Benefit (satu per baris)", h("textarea", { name: "benefits", rows: 3 }, (tier && tier.benefits || []).map((b) => b.text).join("\n"))),
hint,
);
let timer;
priceInput.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(preview, 250); });
preview();
function preview() {
const p = Math.max(0, Number(priceInput.value) || 0);
if (p <= 0) { hint.textContent = "Isi harga untuk melihat harga jual."; return; }
api.post("/finance/pricing-config/preview/", { price: p, discount_amount: 0 })
.then((d) => {
hint.textContent =
`Harga jual = ${money(d.total)} (fee aplikasi ${d.app_fee_pct}% = ${money(d.app_fee)} + fee IMI ${d.imi_fee_pct}% = ${money(d.imi_fee)} + pajak ${d.tax_rate_pct}% = ${money(d.tax_amount)} + layanan ${money(d.service_fee)}).`;
})
.catch(() => {});
}
const saveBtn = h("button", { class: "btn btn-primary", type: "button", onclick: submit }, tier ? "Simpan Perubahan" : "Tambah Kelas");
const modal = openModal({
title: tier ? "Edit Kelas Tiket" : "Tambah Kelas Tiket",
body: form,
foot: h("div", { class: "modal-foot" },
h("button", { class: "btn btn-ghost", type: "button", onclick: () => modal.close() }, "Batal"),
saveBtn),
});
async function submit() {
clearFieldErrors(form);
if (!form.elements.name.value.trim()) { addFieldError(form, "name", "Nama kelas wajib diisi."); form.elements.name.focus(); return; }
if (priceInput.value === "" || isNaN(Number(priceInput.value)) || Number(priceInput.value) < 0) {
addFieldError(form, "price", "Harga wajib diisi (≥ 0)."); priceInput.focus(); return;
}
const taken = new Set((existingTiers || []).map((t) => t.code));
if (tier) taken.delete(tier.code);
const payload = {
event: ev.id,
name: form.elements.name.value.trim(),
code: (form.elements.code.value.trim() || uniqueTierCode(slugify(form.elements.name.value.trim()), taken)),
price: Math.round(Number(priceInput.value)),
quota: form.elements.quota.value.trim() === "" ? null : Number(form.elements.quota.value),
status: form.elements.status.value,
member_only: form.elements.member_only.checked,
requires_license: form.elements.requires_license.checked,
max_per_order: Number(form.elements.max_per_order.value) || 4,
description: form.elements.description.value.trim(),
benefits: form.elements.benefits.value.split(/\n+/).map((s) => s.trim()).filter(Boolean).map((text) => ({ text })),
};
if (form.elements.sale_start.value) payload.sale_start = `${form.elements.sale_start.value}:00+07:00`;
if (form.elements.sale_end.value) payload.sale_end = `${form.elements.sale_end.value}:00+07:00`;
await withBusy(saveBtn, async () => {
try {
if (tier) await api.patch(`/ticket-tiers/${tier.id}/`, payload);
else await api.post("/ticket-tiers/", payload);
toast("Kelas tiket disimpan.", "ok");
modal.close();
if (onSaved) onSaved();
} catch (err) { applyApiErrors(form, err); }
});
}
}
async function openDetail(slug) { async function openDetail(slug) {
let ev; let ev;
try { try {
...@@ -133,7 +391,10 @@ async function openDetail(slug) { ...@@ -133,7 +391,10 @@ async function openDetail(slug) {
["Max Tiket/User", ev.max_ticket_per_user], ["Max Tiket/User", ev.max_ticket_per_user],
]), ]),
ev.description ? h("p", {}, ev.description) : null, ev.description ? h("p", {}, ev.description) : null,
h("div", { class: "section-title" }, "Tiket"), tierTable(ev), h("div", { style: "display:flex;align-items:center;gap:10px;margin-top:6px" },
h("div", { class: "section-title", style: "margin:0" }, "Tiket"),
h("button", { class: "btn btn-sm", type: "button", onclick: () => manageTiers(ev) }, "Atur Kelas Tiket")),
tierTable(ev),
ev.sessions && ev.sessions.length ? [h("div", { class: "section-title" }, "Sesi"), sessionTable(ev)] : null, ev.sessions && ev.sessions.length ? [h("div", { class: "section-title" }, "Sesi"), sessionTable(ev)] : null,
), ),
}); });
...@@ -172,12 +433,13 @@ function sessionTable(ev) { ...@@ -172,12 +433,13 @@ function sessionTable(ev) {
} }
async function openEventForm(ev, reload) { async function openEventForm(ev, reload) {
let categories, circuits, detail; let categories, circuits, detail, tiers;
try { try {
[categories, circuits, detail] = await Promise.all([ [categories, circuits, detail, tiers] = await Promise.all([
api.get("/event-categories/"), api.get("/event-categories/"),
api.get("/circuits/"), api.get("/circuits/"),
ev ? api.get(`/events/${ev.slug}/`) : Promise.resolve(null), ev ? api.get(`/events/${ev.slug}/`) : Promise.resolve(null),
ev ? fetchTiers(ev.id) : Promise.resolve([]),
]); ]);
} catch (err) { } catch (err) {
toast(err.message, "err"); toast(err.message, "err");
...@@ -240,10 +502,11 @@ async function openEventForm(ev, reload) { ...@@ -240,10 +502,11 @@ async function openEventForm(ev, reload) {
), ),
); );
const tierBox = buildTierEditor(ev ? tiers : []);
const saveBtn = h("button", { class: "btn btn-primary", type: "button", onclick: submit }, ev ? "Simpan Perubahan" : "Buat Event"); const saveBtn = h("button", { class: "btn btn-primary", type: "button", onclick: submit }, ev ? "Simpan Perubahan" : "Buat Event");
const modal = openModal({ const modal = openModal({
title: ev ? "Edit Event" : "Buat Event Baru", title: ev ? "Edit Event" : "Buat Event Baru",
body: form, body: h("div", {}, form, tierBox),
foot: h("div", { class: "modal-foot" }, foot: h("div", { class: "modal-foot" },
h("button", { class: "btn btn-ghost", type: "button", onclick: () => modal.close() }, "Batal"), h("button", { class: "btn btn-ghost", type: "button", onclick: () => modal.close() }, "Batal"),
saveBtn, saveBtn,
...@@ -267,14 +530,21 @@ async function openEventForm(ev, reload) { ...@@ -267,14 +530,21 @@ async function openEventForm(ev, reload) {
await withBusy(saveBtn, async () => { await withBusy(saveBtn, async () => {
const vals = collect(form); const vals = collect(form);
if (!vals.category) { addFieldError(form, "category", "Pilih kategori event."); return; } if (!vals.category) { addFieldError(form, "category", "Pilih kategori event."); return; }
const rows = collectTierRows(tierBox);
if (rows === null) {
toast("Lengkapi nama & harga tiap kelas tiket (harga ≥ 0).", "err");
return;
}
try { try {
let saved;
if (ev) { if (ev) {
await api.patch(`/events/${ev.slug}/`, vals); await api.patch(`/events/${ev.slug}/`, vals);
toast("Perubahan disimpan.", "ok"); saved = ev;
} else { } else {
await api.post("/events/", vals); saved = await api.post("/events/", vals);
toast("Event dibuat.", "ok");
} }
await syncTiers(saved.id, ev ? tiers : [], rows);
toast(ev ? "Perubahan disimpan." : "Event dibuat.", "ok");
modal.close(); modal.close();
reload(); reload();
} catch (err) { } catch (err) {
...@@ -290,10 +560,15 @@ function collect(form) { ...@@ -290,10 +560,15 @@ function collect(form) {
if (!el.name) continue; if (!el.name) continue;
if (el.type === "checkbox") { out[el.name] = el.checked; continue; } if (el.type === "checkbox") { out[el.name] = el.checked; continue; }
const v = el.value.trim(); const v = el.value.trim();
if (v === "") { out[el.name] = null; continue; } if (el.type === "number") {
if (el.type === "number") out[el.name] = Number(v); if (v !== "" && !isNaN(Number(v))) out[el.name] = Number(v);
else if (el.type === "datetime-local") out[el.name] = `${v}:00+07:00`; continue;
else out[el.name] = v; }
if (el.type === "datetime-local") { out[el.name] = v === "" ? null : `${v}:00+07:00`; continue; }
if (el.type === "time") { out[el.name] = v === "" ? null : v; continue; }
if (el.type === "date") { if (v !== "") out[el.name] = v; continue; }
if (el.name === "circuit") { out[el.name] = v === "" ? null : v; continue; }
out[el.name] = v;
} }
return out; return out;
} }
......
import { api } from "../api.js";
import {
h, field, toast, money, errorCard, withBusy, clearFieldErrors, addFieldError, applyApiErrors,
} from "../ui.js";
const pct = (v) => `${String(Number(v)).replace(".", ",")}%`;
export function render(mount) {
mount.innerHTML = "";
mount.append(h("div", { class: "card card-pad" }, "Memuat…"));
load(mount);
}
async function load(mount) {
let cfg;
try {
cfg = await api.get("/finance/pricing-config/");
} catch (err) {
mount.innerHTML = "";
mount.append(errorCard(err.message, () => load(mount)));
return;
}
mount.innerHTML = "";
mount.append(cardForm(cfg, () => load(mount)));
mount.append(cardCalculator(cfg));
}
function cardForm(cfg, reload) {
const form = h("form", { novalidate: "" },
h("div", { class: "section-title" }, "Aturan Harga & Bagi Hasil"),
h("p", { class: "muted", style: "font-size:12px;margin:0 0 12px" },
"Rumus harga jual = harga tiket + fee % tim aplikasi + fee % tim IMI + pajak (PB1). Fee dihitung dari nilai tiket setelah diskon."),
h("div", { class: "form-grid" },
field("Fee / Bagian Tim Aplikasi (%)", h("input", { type: "number", step: "0.01", min: "0", name: "app_fee_pct", value: cfg.app_fee_pct })),
field("Fee / Bagian IMI (%)", h("input", { type: "number", step: "0.01", min: "0", name: "imi_fee_pct", value: cfg.imi_fee_pct })),
field("Pajak Hiburan PB1 (%)", h("input", { type: "number", step: "0.01", min: "0", name: "tax_rate_pct", value: cfg.tax_rate_pct })),
field("Biaya Layanan (Rp)", h("input", { type: "number", min: "0", name: "service_fee", value: cfg.service_fee })),
),
h("div", { style: "margin-top:12px" },
h("button", { class: "btn btn-primary", type: "button", onclick: () => save() }, "Simpan Aturan"),
),
);
async function save() {
clearFieldErrors(form);
const vals = {};
for (const el of form.elements) {
if (!el.name) continue;
const v = el.value.trim();
if (v === "" || isNaN(Number(v))) {
addFieldError(form, el.name, "Isi dengan angka.");
el.focus();
return;
}
vals[el.name] = Number(v);
}
await withBusy(form.querySelector('[type="button"]'), async () => {
try {
await api.put("/finance/pricing-config/", vals);
toast("Aturan harga disimpan.", "ok");
reload();
} catch (err) {
applyApiErrors(form, err);
}
});
}
return h("div", { class: "card card-pad" }, form);
}
function cardCalculator(cfg) {
const price = h("input", { type: "number", min: "0", value: 100000 });
const discount = h("input", { type: "number", min: "0", value: 0 });
const box = h("tbody", {});
const summary = h("div", { class: "muted", style: "font-size:12px;margin-top:8px" }, "Masukkan harga tiket untuk melihat rincian harga jual.");
function run() {
const p = Math.max(0, Number(price.value) || 0);
if (p <= 0) return;
api.post("/finance/pricing-config/preview/", {
price: p,
discount_amount: Math.max(0, Number(discount.value) || 0),
}).then((d) => {
box.innerHTML = "";
box.append(
row("Harga Tiket", money(d.price)),
d.discount_amount ? row("Diskon", `-${money(d.discount_amount)}`) : null,
row("Nilai Tiket (setelah diskon)", money(d.taxable)),
row(`Fee / Bagian Tim Aplikasi (${pct(d.app_fee_pct)})`, money(d.app_fee)),
row(`Fee / Bagian IMI (${pct(d.imi_fee_pct)})`, money(d.imi_fee)),
row("Biaya Layanan", money(d.service_fee)),
row(`Pajak PB1 (${pct(d.tax_rate_pct)})`, money(d.tax_amount)),
h("tr", {}, h("td", { class: "k", style: "font-weight:700" }, "TOTAL HARGA JUAL"), h("td", { class: "v", style: "font-weight:700" }, money(d.total))),
);
summary.textContent =
`Bagi hasil dari nilai tiket: Tim Aplikasi ${money(d.app_fee)} (${pct(d.app_fee_pct)}), IMI ${money(d.imi_fee)} (${pct(d.imi_fee_pct)}).`;
}).catch((err) => { summary.textContent = err.message; });
}
let timer;
const debounceRun = () => { clearTimeout(timer); timer = setTimeout(run, 250); };
price.addEventListener("input", debounceRun);
discount.addEventListener("input", debounceRun);
run();
return h("div", { class: "card card-pad" },
h("div", { class: "section-title" }, "Kalkulator Harga Jual"),
h("p", { class: "muted", style: "font-size:12px;margin:0 0 12px" },
`Aturan aktif: aplikasi ${pct(cfg.app_fee_pct)}, IMI ${pct(cfg.imi_fee_pct)}, pajak ${pct(cfg.tax_rate_pct)}, biaya layanan ${money(cfg.service_fee)}.`),
h("div", { class: "form-grid" },
field("Harga Tiket (Rp)", price),
field("Diskon (Rp)", discount),
),
h("table", { class: "tbl" }, box),
summary,
);
}
function row(k, v) {
return h("tr", {}, h("td", { class: "k" }, k), h("td", { class: "v", style: "text-align:right" }, v));
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment