- Fix loading animation scope - skeleton hanya di datatable saat page change
- Fix search di mode Detail yang tidak bisa diketik
- Fix pagination performance dengan pre-computed couponCount (bukan N+1 query)
Sebelum mengerjakan setiap task:
- Buat plan - files yang akan dimodifikasi, approach, potensi impact
- Tunggu approval dari user
- Setelah approved, baru eksekusi
Current behavior:
- Pindah page → skeleton muncul di SEMUA area (analytics + datatable)
Expected behavior:
- Pindah page → loading overlay/skeleton HANYA di datatable
- Analytics tetap terlihat (tidak perlu loading karena data statis)
Solution:
Pisahkan loading state:
isInitialLoading→ full skeleton (analytics + table) - hanya pertama kaliisPageLoading→ overlay di table saja
function StepParticipants({ eventId, mode }: Props) {
const [hasInitiallyLoaded, setHasInitiallyLoaded] = useState(false);
const { data, isLoading, isFetching } = useParticipantsPaginatedWithCouponCount(...);
// Track initial load completion
useEffect(() => {
if (data && !hasInitiallyLoaded) {
setHasInitiallyLoaded(true);
}
}, [data, hasInitiallyLoaded]);
const isInitialLoading = !hasInitiallyLoaded && isLoading;
const isPageChanging = hasInitiallyLoaded && isFetching;
// Initial load - full skeleton
if (isInitialLoading) {
return <FullPageSkeleton />; // analytics + table skeleton
}
return (
<div>
{/* Analytics - TIDAK ada loading overlay */}
<AnalyticsSummary
totalParticipants={data?.total || 0}
totalCoupons={totalCoupons}
/>
{/* Table - dengan loading overlay saat page change */}
<div className="relative">
{isPageChanging && (
<div className="absolute inset-0 bg-white/70 flex items-center justify-center z-10">
<Spinner />
</div>
)}
<ParticipantTable data={data} ... />
</div>
</div>
);
}Current behavior:
- Toggle ke Detail view → search field tidak bisa diketik
- Kemungkinan: input tidak ter-handle atau ada issue dengan state
Root cause kemungkinan:
- Search state tidak di-share antara Group dan Detail view
- Input onChange tidak ter-bind dengan benar di Detail mode
- Re-render yang reset input value
Solution:
Check dan fix search input handling:
// PaginatedTable.tsx atau StepParticipants.tsx
// Pastikan search state di-lift ke parent dan di-share ke kedua view
const [searchQuery, setSearchQuery] = useState('');
// Pastikan onChange handler benar
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search..."
// Pastikan TIDAK ada kondisi yang disable input di Detail mode
/>Debug checklist:
- Cek apakah input punya
disabledprop di Detail mode - Cek apakah input punya
readOnlyprop - Cek apakah
onChangehandler ter-bind - Cek apakah ada CSS
pointer-events: nonedi Detail mode - Cek console untuk error
Kemungkinan fix:
// Jika search field berbeda untuk Group dan Detail, unify:
<PaginatedTable
// ... props
searchQuery={searchQuery} // controlled dari parent
onSearchChange={setSearchQuery} // handler dari parent
viewMode={viewMode} // group atau detail
/>
// Di dalam PaginatedTable - search input SAMA untuk kedua mode:
<input
type="text"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={viewMode === 'group' ? 'Search participant...' : 'Search coupon...'}
/>Root Cause: N+1 Query Problem
Saat pindah page (50 records per page):
1 query: fetch 50 participants
50 queries: fetch coupon count untuk SETIAP participant
─────────────────────────────────────────────────
Total: 51 queries ke IndexedDB = LAMBAT
Solusi yang SALAH:
// JANGAN - fetch semua coupon lalu count di memory
const allCoupons = await db.coupons.where('eventId').equals(eventId).toArray();
// Masalah: miliaran kupon = memory crash, pagination jadi sia-siaSolusi yang BENAR: Pre-computed Coupon Count
Simpan couponCount langsung di Participant table, bukan query setiap kali.
interface Participant {
id: string;
eventId: string;
name?: string;
couponCount: number; // ← PRE-COMPUTED saat import
// ...
}Kapan couponCount di-update:
| Event | Action |
|---|---|
| Import Excel | Hitung jumlah kupon per participant, simpan ke couponCount |
| Delete coupon | Decrement couponCount participant terkait |
| Add coupon (jika ada) | Increment couponCount participant terkait |
Kapan Event analytics di-update:
| Action | Event.totalParticipants | Event.totalCoupons |
|---|---|---|
| Import Excel | Set jumlah participant | Set jumlah kupon |
| Delete coupon | - | Decrement 1 |
| Delete participant | Decrement 1 | Decrement sebanyak participant.couponCount |
| Add coupon (jika ada) | - | Increment 1 |
Query jadi sangat simple:
// CEPAT - 1 query saja, couponCount sudah embedded
const participants = await db.participants
.where('eventId').equals(eventId)
.offset(offset)
.limit(limit)
.toArray();
// Tidak perlu query coupon sama sekali untuk display table
// participants[0].couponCount sudah ada
// Analytics juga dari Event, bukan query count
const event = await db.events.get(eventId);
// event.totalParticipants, event.totalCoupons sudah adaPerbandingan:
| Approach | Queries/page | Memory | Scalability |
|---|---|---|---|
| N+1 query (current) | 51 | OK | ❌ Lambat |
| Fetch all coupons | 1 | ❌ Crash | ❌ Tidak scalable |
| Pre-computed (recommended) | 1 | OK | ✅ O(1) konstan |
Keuntungan untuk Phase 2:
- Pattern sama untuk SQL database
- Bisa pakai database trigger untuk auto-update count
- Query tetap simple:
SELECT * FROM participants WHERE eventId = ? LIMIT ? OFFSET ?
- Tambah
hasInitiallyLoadedstate - Bedakan
isInitialLoadingvsisPageChanging - Initial: full skeleton
- Page change: overlay di table saja
- Debug: cek console untuk error
- Pastikan search input controlled dari parent
- Pastikan onChange handler tidak di-block di Detail mode
- Test ketik di input
Step 1: Update Participant type
// types/index.ts
interface Participant {
id: string;
eventId: string;
name?: string;
email?: string;
phone?: string;
customFields: Record<string, string>;
couponCount: number; // TAMBAH INI
winCount: number;
status: ParticipantStatus;
}Step 2: Update Excel import logic
// services/excelService.ts atau tempat import logic
async function importParticipants(eventId: string, rows: ExcelRow[]) {
// Group coupons by participant
const couponsByParticipant = new Map<string, number>();
rows.forEach(row => {
const pid = row.participant_id;
couponsByParticipant.set(pid, (couponsByParticipant.get(pid) || 0) + 1);
});
// Create participants with couponCount
const participants: Participant[] = [];
const uniqueParticipants = new Map<string, ExcelRow>();
rows.forEach(row => {
if (!uniqueParticipants.has(row.participant_id)) {
uniqueParticipants.set(row.participant_id, row);
}
});
uniqueParticipants.forEach((row, pid) => {
participants.push({
id: pid,
eventId,
name: row.participant_name,
couponCount: couponsByParticipant.get(pid) || 0, // SET SAAT IMPORT
winCount: 0,
status: 'active',
customFields: { ... },
});
});
await participantRepository.bulkAdd(participants);
}Step 3: Update delete coupon logic
// Saat delete coupon, decrement participant couponCount + event totalCoupons
async function deleteCoupon(eventId: string, couponId: string) {
const coupon = await couponRepository.getById(eventId, couponId);
if (coupon) {
// Delete coupon
await couponRepository.delete(eventId, couponId);
// Decrement participant couponCount
const participant = await participantRepository.getById(eventId, coupon.participantId);
if (participant) {
await participantRepository.update(eventId, participant.id, {
couponCount: participant.couponCount - 1,
});
}
// Decrement event totalCoupons
const event = await eventRepository.getById(eventId);
if (event) {
await eventRepository.update(eventId, {
totalCoupons: event.totalCoupons - 1,
});
}
}
}Step 3b: Update delete participant logic
// Saat delete participant, decrement event totalParticipants + totalCoupons
async function deleteParticipant(eventId: string, participantId: string) {
const participant = await participantRepository.getById(eventId, participantId);
if (participant) {
// Delete all coupons of this participant
await couponRepository.deleteByParticipantId(eventId, participantId);
// Delete participant
await participantRepository.delete(eventId, participantId);
// Update event analytics
const event = await eventRepository.getById(eventId);
if (event) {
await eventRepository.update(eventId, {
totalParticipants: event.totalParticipants - 1,
totalCoupons: event.totalCoupons - participant.couponCount,
});
}
}
}Step 4: Simplify repository query
// repositories/dexie/participantRepository.ts
async getByEventIdPaginated(eventId: string, { offset, limit }: PaginationParams) {
const total = await db.participants
.where('eventId').equals(eventId)
.count();
const data = await db.participants
.where('eventId').equals(eventId)
.offset(offset)
.limit(limit)
.toArray();
// TIDAK PERLU query coupon counts lagi
// data[].couponCount sudah ada
return {
data,
total,
page: Math.floor(offset / limit) + 1,
pageSize: limit,
totalPages: Math.ceil(total / limit),
};
}Step 5: Remove unused coupon count methods
- Hapus
getCountsByParticipantIds()di couponRepository - Hapus
useCouponCountsByParticipants()hook - Hapus logic merge couponCount di component
Step 6: Update Event analytics saat delete
Event model sudah punya totalParticipants dan totalCoupons. Perlu di-update saat delete:
// Saat delete participant
async function deleteParticipant(eventId: string, participantId: string) {
const participant = await participantRepository.getById(eventId, participantId);
if (participant) {
// Delete all coupons milik participant
await couponRepository.deleteByParticipantId(eventId, participantId);
// Delete participant
await participantRepository.delete(eventId, participantId);
// Update Event analytics
const event = await eventRepository.getById(eventId);
if (event) {
await eventRepository.update(eventId, {
totalParticipants: event.totalParticipants - 1,
totalCoupons: event.totalCoupons - participant.couponCount,
});
}
}
}
// Saat delete coupon
async function deleteCoupon(eventId: string, couponId: string) {
const coupon = await couponRepository.getById(eventId, couponId);
if (coupon) {
// Delete coupon
await couponRepository.delete(eventId, couponId);
// Decrement participant couponCount
const participant = await participantRepository.getById(eventId, coupon.participantId);
if (participant) {
await participantRepository.update(eventId, participant.id, {
couponCount: participant.couponCount - 1,
});
}
// Decrement Event totalCoupons
const event = await eventRepository.getById(eventId);
if (event) {
await eventRepository.update(eventId, {
totalCoupons: event.totalCoupons - 1,
});
}
}
}Summary update logic:
| Action | Participant.couponCount | Event.totalParticipants | Event.totalCoupons |
|---|---|---|---|
| Import Excel | Set per participant | Set total | Set total |
| Delete participant | - (deleted) | Decrement 1 | Decrement by couponCount |
| Delete coupon | Decrement 1 | - | Decrement 1 |
| File | Change |
|---|---|
src/types/index.ts |
Add couponCount to Participant interface |
src/components/wizard/StepParticipants.tsx |
Fix loading scope (initial vs page change) |
src/components/wizard/import/PaginatedTable.tsx |
Fix Detail search input |
src/services/excelService.ts |
Compute couponCount saat import |
src/repositories/dexie/participantRepository.ts |
Simplify query (hapus coupon count lookup) |
src/repositories/dexie/couponRepository.ts |
Hapus getCountsByParticipantIds() |
src/repositories/dexie/eventRepository.ts |
Add method update analytics |
src/hooks/useCoupons.ts |
Hapus useCouponCountsByParticipants(), update delete logic |
src/hooks/useParticipants.ts |
Update delete logic (decrement counts + update event analytics) |
1. Update Types
└── Add couponCount to Participant interface
↓
2. Update Excel Import
└── Compute & store couponCount saat import
└── Set Event.totalParticipants & totalCoupons
↓
3. Simplify Repository
├── Hapus coupon count query di participantRepository
└── Hapus getCountsByParticipantIds di couponRepository
↓
4. Update Hooks & Delete Logic
├── Hapus useCouponCountsByParticipants
├── Update delete participant (decrement Event.totalParticipants & totalCoupons)
└── Update delete coupon (decrement Participant.couponCount & Event.totalCoupons)
↓
5. Fix Loading Scope
└── Separate initial loading vs page change loading
↓
6. Fix Detail Search
└── Debug & fix search input di Detail mode
↓
7. Update DB Version (jika schema berubah)
└── Increment DB_VERSION untuk migration
↓
8. Test & Build
Karena menambah field couponCount ke Participant:
- Increment
DB_VERSIONdi db.ts - Data lama perlu di-clear atau migrate
- Untuk existing data, bisa jalankan migration script untuk compute couponCount
Loading Scope:
- Initial load → full skeleton (analytics + table)
- Page change → overlay HANYA di table, analytics tetap terlihat
- Search → overlay HANYA di table
Detail Search:
- Toggle ke Detail mode
- Klik search input → bisa fokus
- Ketik → karakter muncul di input
- Search filter bekerja di Detail mode
Performance (Pre-computed couponCount):
- Import Excel → couponCount tersimpan di participant
- Page change cepat (< 200ms)
- Group view: couponCount langsung muncul (tidak ada delay)
- Console: hanya 1 query per page change (tidak ada N+1)
Analytics Update:
- Import Excel → Event.totalParticipants & totalCoupons ter-set
- Delete participant → totalParticipants decrement 1, totalCoupons decrement by couponCount
- Delete coupon → Participant.couponCount decrement 1, Event.totalCoupons decrement 1
- Analytics summary di UI ter-update setelah delete
Regression:
- Create event → import → save draft masih work
- Edit event → participant table muncul
- Delete participant masih work
- Delete coupon masih work
| Metric | Before | Target |
|---|---|---|
| Queries per page | 51 (N+1) | 1 |
| Page change time | 500-2000ms | < 200ms |
| Coupon count delay | Ada ("-" → angka) | Tidak ada |