Fixed navbar admin

User Layout
- app/(application)/(user)/home.tsx

Components
- components/Drawer/NavbarMenu_V2.tsx

Docs
- docs/admin-folder-structure.md

### Issue: saat masuk lebih dalam ke sub menu indikator aktif di navbar hilang
This commit is contained in:
2026-02-12 11:48:01 +08:00
parent 5c931b069c
commit e030b8f486
3 changed files with 376 additions and 219 deletions

View File

@@ -77,14 +77,14 @@ export default function Application() {
); );
} }
// if (data && data?.masterUserRoleId !== "1") { if (data && data?.masterUserRoleId !== "1") {
// console.log("User is not admin"); console.log("User is not admin");
// return ( return (
// <BasicWrapper> <BasicWrapper>
// <Redirect href={`/admin/dashboard`} /> <Redirect href={`/admin/dashboard`} />
// </BasicWrapper> </BasicWrapper>
// ); );
// } }
return ( return (
<> <>

View File

@@ -1,7 +1,7 @@
import { AccentColor, MainColor } from "@/constants/color-palet"; import { AccentColor, MainColor } from "@/constants/color-palet";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { router, usePathname } from "expo-router"; import { router, usePathname } from "expo-router";
import { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { import {
Animated, Animated,
ScrollView, ScrollView,
@@ -19,7 +19,7 @@ export interface NavbarItem_V2 {
links?: { links?: {
label: string; label: string;
link: string; link: string;
detailPattern?: string; detailPattern?: string; // NEW: Pattern untuk match detail pages
}[]; }[];
initiallyOpened?: boolean; initiallyOpened?: boolean;
} }
@@ -45,93 +45,89 @@ export default function NavbarMenu_V2({ items, onClose }: NavbarMenuProps) {
try { try {
const newOpenKeys: string[] = []; const newOpenKeys: string[] = [];
// Helper function yang sama dengan di MenuItem // Helper function yang sama dengan di MenuItem
const checkPathMatch = (linkPath: string, detailPattern?: string) => { const checkPathMatch = (linkPath: string, detailPattern?: string) => {
const normalizedLink = linkPath.replace(/\/+$/, ""); const normalizedLink = linkPath.replace(/\/+$/, "");
// Exact match // Exact match
if (normalizedPathname === normalizedLink) return true; if (normalizedPathname === normalizedLink) return true;
// Detail pattern match // Detail pattern match
if (detailPattern) { if (detailPattern) {
const patternRegex = new RegExp( const patternRegex = new RegExp(
"^" + detailPattern.replace(/\*/g, "[^/]+") + "(/.*)?$", "^" + detailPattern.replace(/\*/g, "[^/]+") + "(/.*)?$"
); );
if (patternRegex.test(normalizedPathname)) { if (patternRegex.test(normalizedPathname)) {
return true; return true;
} }
} }
// Detail page match (fallback) // Detail page match (fallback)
if (normalizedPathname.startsWith(normalizedLink + "/")) { if (normalizedPathname.startsWith(normalizedLink + "/")) {
const remainder = normalizedPathname.substring( const remainder = normalizedPathname.substring(normalizedLink.length + 1);
normalizedLink.length + 1, const segments = remainder.split("/").filter(s => s.length > 0);
);
const segments = remainder.split("/").filter((s) => s.length > 0);
if (segments.length === 0) return false; if (segments.length === 0) return false;
const commonWords = [ const commonWords = [
// Event // Actions
"type-create", 'detail', 'edit', 'create', 'new', 'add', 'delete', 'view',
// Other // Status types
"detail", 'publish', 'review', 'reject', 'status', 'active', 'inactive', 'pending',
"edit",
"create", // General pages
"new", 'category', 'history', 'dashboard', 'index',
"add",
"delete", // Event specific
"view", 'type-of-event', 'type-create', 'type-update',
"publish",
"review", // Forum specific
"reject", 'posting', 'report-posting', 'report-comment',
"status",
"category", // Collaboration
"history", 'group',
"type-of-event",
"posting", // App Information
"report-posting", 'business-field', 'information-bank', 'sticker',
"report-comment", 'bidang-update', 'sub-bidang-update',
"group",
"dashboard", // Transaction/Finance related
"sticker", 'transaction-detail', 'transaction', 'payment',
"active", 'disbursement-of-funds', 'detail-disbursement-of-funds',
"inactive", 'list-disbursement-of-funds',
"pending",
"transaction-detail", // List pages (CRITICAL!)
"transaction", 'list-of-investor', 'list-of-donatur', 'list-of-participants',
"payment", 'list-comment', 'list-report-comment', 'list-report-posting',
"disbursement",
"list-of-investor", // Input/Form pages
'reject-input',
// Category pages
'category-create', 'category-update'
]; ];
const hasIdSegment = segments.some((segment) => { const hasIdSegment = segments.some(segment => {
if (commonWords.includes(segment.toLowerCase())) { if (commonWords.includes(segment.toLowerCase())) {
return false; return false;
} }
const isPureNumber = /^\d+$/.test(segment); const isPureNumber = /^\d+$/.test(segment);
const isUUID = const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment);
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
segment,
);
const hasNumber = /\d/.test(segment); const hasNumber = /\d/.test(segment);
const isAlphanumericId = const isAlphanumericId = /^[a-z0-9_-]+$/i.test(segment) && segment.length <= 50 && hasNumber;
/^[a-z0-9_-]+$/i.test(segment) &&
segment.length <= 50 &&
hasNumber;
return isPureNumber || isUUID || isAlphanumericId; return isPureNumber || isUUID || isAlphanumericId;
}); });
return hasIdSegment; return hasIdSegment;
} }
return false; return false;
}; };
items.forEach((item) => { items.forEach((item) => {
if (item.links && item.links.length > 0) { if (item.links && item.links.length > 0) {
// Check jika ada submenu yang match dengan current path // Check jika ada submenu yang match dengan current path
@@ -154,9 +150,7 @@ export default function NavbarMenu_V2({ items, onClose }: NavbarMenuProps) {
// Toggle dropdown // Toggle dropdown
const toggleOpen = (label: string) => { const toggleOpen = (label: string) => {
setOpenKeys((prev) => setOpenKeys((prev) =>
prev.includes(label) prev.includes(label) ? prev.filter((key) => key !== label) : [...prev, label]
? prev.filter((key) => key !== label)
: [...prev, label],
); );
}; };
@@ -171,18 +165,18 @@ export default function NavbarMenu_V2({ items, onClose }: NavbarMenuProps) {
paddingVertical: 10, paddingVertical: 10,
}} }}
> >
{items && items.length > 0 {items && items.length > 0 ? (
? items.map((item) => ( items.map((item) => (
<MenuItem <MenuItem
key={item.label} key={item.label}
item={item} item={item}
onClose={onClose} onClose={onClose}
currentPath={normalizedPathname} currentPath={normalizedPathname}
isOpen={openKeys.includes(item.label)} isOpen={openKeys.includes(item.label)}
toggleOpen={() => toggleOpen(item.label)} toggleOpen={() => toggleOpen(item.label)}
/> />
)) ))
: null} ) : null}
</ScrollView> </ScrollView>
</View> </View>
); );
@@ -205,121 +199,109 @@ function MenuItem({
const animatedHeight = useRef(new Animated.Value(0)).current; const animatedHeight = useRef(new Animated.Value(0)).current;
// Helper function untuk check apakah path aktif // Helper function untuk check apakah path aktif
const isPathActive = ( const isPathActive = (linkPath: string | undefined, detailPattern?: string) => {
linkPath: string | undefined,
detailPattern?: string,
) => {
if (!linkPath) return false; if (!linkPath) return false;
const normalizedLink = linkPath.replace(/\/+$/, ""); const normalizedLink = linkPath.replace(/\/+$/, "");
// 1. Match exact - prioritas tertinggi // 1. Match exact - prioritas tertinggi
if (currentPath === normalizedLink) return true; if (currentPath === normalizedLink) return true;
// 2. Jika ada detailPattern, cek pattern dulu // 2. Jika ada detailPattern, cek pattern dulu
if (detailPattern) { if (detailPattern) {
// detailPattern contoh: "/admin/job/*/review" // detailPattern contoh: "/admin/job/*/review"
// akan match dengan: // akan match dengan:
// - /admin/job/123/review ✅ // - /admin/job/123/review ✅
// - /admin/job/123/review/transaction-detail ✅ // - /admin/job/123/review/transaction-detail ✅
// - /admin/job/123/review/anything/nested ✅ // - /admin/job/123/review/anything/nested ✅
const patternRegex = new RegExp( const patternRegex = new RegExp(
"^" + detailPattern.replace(/\*/g, "[^/]+") + "(/.*)?$", "^" + detailPattern.replace(/\*/g, "[^/]+") + "(/.*)?$"
); );
const isMatch = patternRegex.test(currentPath); const isMatch = patternRegex.test(currentPath);
// Debug log untuk pattern matching // Debug log untuk pattern matching
if ( if (currentPath.includes('transaction-detail') || currentPath.includes('disbursement')) {
currentPath.includes("list-of-investor") || console.log('🔍 Pattern Match Check:', {
currentPath.includes("type-create") currentPath,
) { detailPattern,
console.log( regex: patternRegex.toString(),
"🔍 Pattern Match Check:", isMatch
JSON.stringify( });
{
currentPath,
detailPattern,
regex: patternRegex.toString(),
isMatch,
},
null,
2,
),
);
} }
if (isMatch) { if (isMatch) {
return true; return true;
} }
} }
// 3. Match untuk detail pages (fallback) // 3. Match untuk detail pages (fallback)
if (currentPath.startsWith(normalizedLink + "/")) { if (currentPath.startsWith(normalizedLink + "/")) {
const remainder = currentPath.substring(normalizedLink.length + 1); const remainder = currentPath.substring(normalizedLink.length + 1);
const segments = remainder.split("/").filter((s) => s.length > 0); const segments = remainder.split("/").filter(s => s.length > 0);
if (segments.length === 0) return false; if (segments.length === 0) return false;
const commonWords = [ const commonWords = [
// Event // Actions
"type-create", 'detail', 'edit', 'create', 'new', 'add', 'delete', 'view',
"detail",
"edit", // Status types
"create", 'publish', 'review', 'reject', 'status', 'active', 'inactive', 'pending',
"new",
"add", // General pages
"delete", 'category', 'history', 'dashboard', 'index',
"view",
"publish", // Event specific
"review", 'type-of-event', 'type-create', 'type-update',
"reject",
"status", // Forum specific
"category", 'posting', 'report-posting', 'report-comment',
"history",
"type-of-event", // Collaboration
"posting", 'group',
"report-posting",
"report-comment", // App Information
"group", 'business-field', 'information-bank', 'sticker',
"dashboard", 'bidang-update', 'sub-bidang-update',
"sticker",
"active", // Transaction/Finance related
"inactive", 'transaction-detail', 'transaction', 'payment',
"pending", 'disbursement-of-funds', 'detail-disbursement-of-funds',
"transaction-detail", 'list-disbursement-of-funds',
"transaction",
"payment", // List pages (CRITICAL!)
"disbursement", 'list-of-investor', 'list-of-donatur', 'list-of-participants',
'list-comment', 'list-report-comment', 'list-report-posting',
// Input/Form pages
'reject-input',
// Category pages
'category-create', 'category-update'
]; ];
const hasIdSegment = segments.some((segment) => { const hasIdSegment = segments.some(segment => {
if (commonWords.includes(segment.toLowerCase())) { if (commonWords.includes(segment.toLowerCase())) {
return false; return false;
} }
const isPureNumber = /^\d+$/.test(segment); const isPureNumber = /^\d+$/.test(segment);
const isUUID = const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment);
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
segment,
);
const hasNumber = /\d/.test(segment); const hasNumber = /\d/.test(segment);
const isAlphanumericId = const isAlphanumericId = /^[a-z0-9_-]+$/i.test(segment) && segment.length <= 50 && hasNumber;
/^[a-z0-9_-]+$/i.test(segment) && segment.length <= 50 && hasNumber;
return isPureNumber || isUUID || isAlphanumericId; return isPureNumber || isUUID || isAlphanumericId;
}); });
return hasIdSegment; return hasIdSegment;
} }
return false; return false;
}; };
// Check apakah menu item ini atau submenu-nya yang aktif // Check apakah menu item ini atau submenu-nya yang aktif
const isActive = isPathActive(item.link); const isActive = isPathActive(item.link);
const hasActiveSubmenu = const hasActiveSubmenu =
item.links?.some((subItem) => item.links?.some((subItem) => isPathActive(subItem.link, subItem.detailPattern)) || false;
isPathActive(subItem.link, subItem.detailPattern),
) || false;
// Animasi saat isOpen berubah // Animasi saat isOpen berubah
useEffect(() => { useEffect(() => {
@@ -332,6 +314,13 @@ function MenuItem({
// Jika ada submenu // Jika ada submenu
if (item.links && item.links.length > 0) { if (item.links && item.links.length > 0) {
// PRE-CALCULATE semua active states untuk submenu
const submenuActiveStates = item.links.map(subItem => ({
subItem,
isActive: isPathActive(subItem.link, subItem.detailPattern),
pathLength: subItem.link.length
}));
return ( return (
<View> <View>
{/* Parent Item */} {/* Parent Item */}
@@ -377,71 +366,62 @@ function MenuItem({
}, },
]} ]}
> >
{item.links.map((subItem, index) => { {submenuActiveStates.map(({ subItem, isActive: isSubActive, pathLength }, index) => {
const isSubActive = isPathActive(
subItem.link, // CRITICAL FIX: Cek apakah ada submenu lain yang LEBIH PANJANG dan juga aktif
subItem.detailPattern, const hasMoreSpecificMatch = submenuActiveStates.some(other => {
); if (other.subItem.link === subItem.link) return false; // Skip self
// CRITICAL FIX: Jika submenu ini aktif, cek apakah ada submenu lain yang LEBIH PANJANG dan juga aktif const isOtherLonger = other.pathLength > pathLength;
// Jika ada yang lebih panjang dan aktif, maka yang pendek TIDAK AKTIF
const hasMoreSpecificMatch = item.links!.some((otherSubItem) => { // Debug log untuk Dashboard
if (otherSubItem.link === subItem.link) return false; // Skip self if (subItem.label === "Dashboard" && isSubActive) {
console.log(`🔎 Dashboard checking against ${other.subItem.label}:`, {
const otherIsActive = isPathActive( dashboardLink: subItem.link,
otherSubItem.link, dashboardLength: pathLength,
otherSubItem.detailPattern, otherLabel: other.subItem.label,
); otherLink: other.subItem.link,
const isOtherLonger = otherPattern: other.subItem.detailPattern,
otherSubItem.link.length > subItem.link.length; otherLength: other.pathLength,
otherIsActive: other.isActive,
// Debug log isOtherLonger,
if (isSubActive && otherIsActive) { willDisableDashboard: other.isActive && isOtherLonger,
console.log( currentURL: currentPath
"🔍 CONFLICT DETECTED:", });
JSON.stringify(
{
current: subItem.label,
currentPath: subItem.link,
currentLength: subItem.link.length,
other: otherSubItem.label,
otherPath: otherSubItem.link,
otherLength: otherSubItem.link.length,
isOtherLonger,
currentURL: currentPath,
},
null,
2,
),
);
} }
// Jika submenu lain JUGA aktif DAN lebih panjang (lebih spesifik), // Conflict log
// maka submenu yang pendek ini TIDAK boleh aktif if (isSubActive && other.isActive) {
return otherIsActive && isOtherLonger; console.log('🔍 CONFLICT DETECTED:', {
current: subItem.label,
currentPath: subItem.link,
currentLength: pathLength,
other: other.subItem.label,
otherPath: other.subItem.link,
otherLength: other.pathLength,
isOtherLonger,
shouldDisableCurrent: isOtherLonger,
currentURL: currentPath
});
}
return other.isActive && isOtherLonger;
}); });
// Final decision: aktif HANYA jika match DAN tidak ada yang lebih spesifik // Final decision
const finalIsActive = isSubActive && !hasMoreSpecificMatch; const finalIsActive = isSubActive && !hasMoreSpecificMatch;
// Debug final decision // Debug final
if (isSubActive) { if (isSubActive) {
console.log( console.log('✅ Active check:', {
"✅ Active check:", label: subItem.label,
JSON.stringify( link: subItem.link,
{ isSubActive,
label: subItem.label, hasMoreSpecificMatch,
link: subItem.link, finalIsActive
isSubActive, });
hasMoreSpecificMatch,
finalIsActive,
},
null,
2,
),
);
} }
return ( return (
<TouchableOpacity <TouchableOpacity
key={index} key={index}
@@ -567,4 +547,4 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
fontWeight: "500", fontWeight: "500",
}, },
}); });

View File

@@ -0,0 +1,177 @@
# Struktur Folder Admin Aplikasi HIPMI Mobile
Dokumen ini menjelaskan struktur folder dan file untuk bagian admin dari aplikasi HIPMI Mobile yang terletak di `app/(application)/admin`.
## File dan Folder Tingkat Atas
### Folder
- `app-information` - Manajemen informasi aplikasi
- `collaboration` - Manajemen modul kolaborasi
- `donation` - Manajemen modul donasi
- `event` - Manajemen modul acara
- `forum` - Manajemen modul forum
- `investment` - Manajemen modul investasi
- `job` - Manajemen modul lowongan kerja
- `notification` - Manajemen notifikasi
- `super-admin` - Fungsi super admin
- `user-access` - Manajemen akses pengguna
- `voting` - Manajemen modul voting
### File
- `_layout.tsx` - Komponen tata letak untuk bagian admin
- `dashboard.tsx` - Tampilan dasbor admin
- `maps.tsx` - Fungsionalitas peta untuk admin
## Struktur Folder Terperinci
### app-information/
```
app-information/
├── business-field/
│ ├── [id]/
│ │ ├── bidang-update.tsx
│ │ ├── index.tsx
│ │ └── sub-bidang-update.tsx
│ └── create.tsx
├── information-bank/
│ ├── [id]/
│ │ └── index.tsx
│ └── create.tsx
├── sticker/
│ ├── [id]/
│ │ └── index.tsx
│ └── create.tsx
└── index.tsx
```
### collaboration/
```
collaboration/
├── [id]/
│ ├── [status].tsx
│ ├── group.tsx
│ └── reject-input.tsx
├── group.tsx
├── index.tsx
├── publish.tsx
└── reject.tsx
```
### donation/
```
donation/
├── [id]/
│ ├── [status]/
│ │ ├── index.tsx
│ │ └── transaction-detail.tsx
│ ├── detail-disbursement-of-funds.tsx
│ ├── disbursement-of-funds.tsx
│ ├── list-disbursement-of-funds.tsx
│ ├── list-of-donatur.tsx
│ └── reject-input.tsx
├── [status]/
│ └── status.tsx
├── category-create.tsx
├── category-update.tsx
├── category.tsx
└── index.tsx
```
### event/
```
event/
├── [id]/
│ ├── [status]/
│ │ └── index.tsx
│ ├── list-of-participants.tsx
│ └── reject-input.tsx
├── [status]/
│ └── status.tsx
├── index.tsx
├── type-create.tsx
├── type-of-event.tsx
└── type-update.tsx
```
### forum/
```
forum/
├── [id]/
│ ├── index.tsx
│ ├── list-comment.tsx
│ ├── list-report-comment.tsx
│ └── list-report-posting.tsx
├── index.tsx
├── posting.tsx
├── report-comment.tsx
└── report-posting.tsx
```
### investment/
```
investment/
├── [id]/
│ ├── [status]/
│ │ ├── index.tsx
│ │ └── transaction-detail.tsx
│ ├── list-of-investor.tsx
│ └── reject-input.tsx
├── [status]/
│ └── status.tsx
└── index.tsx
```
### job/
```
job/
├── [id]/
│ ├── [status]/
│ │ ├── index.tsx
│ │ └── reject-input.tsx
├── [status]/
│ └── status.tsx
└── index.tsx
```
### notification/
```
notification/
└── index.tsx
```
### super-admin/
```
super-admin/
├── [id]/
│ └── index.tsx
└── index.tsx
```
### user-access/
```
user-access/
├── [id]/
│ └── index.tsx
└── index.tsx
```
### voting/
```
voting/
├── [id]/
│ ├── [status]/
│ │ ├── index.tsx
│ │ └── reject-input.tsx
├── [status]/
│ └── status.tsx
├── history.tsx
└── index.tsx
```
## Rute Dinamis
Bagian admin menggunakan rute dinamis yang ditunjukkan dengan kurung siku `[ ]`:
- `[id]` - Rute dinamis untuk ID item tertentu
- `[status]` - Rute dinamis untuk tampilan berdasarkan status
Ini memungkinkan routing yang fleksibel berdasarkan parameter tertentu seperti ID item atau status.