Files
hipmi-mobile/components/Drawer/NavbarMenu_V2.tsx
bagasbanuna 5c931b069c Fixed navbar admin
User & Admin Layout
- app/(application)/(user)/home.tsx
- app/(application)/admin/_layout.tsx

Components
- components/Drawer/NavbarMenu.tsx
- components/index.ts

Docs
- docs/prompt-for-qwen-code.md

Backup Component
- components/Drawer/NavbarMenu.back.tsx

New Components
- components/Drawer/NavbarMenu_V2.tsx
- components/_ShareComponent/BasicWrapper.tsx

New Admin Screen
- screens/Admin/listPageAdmin_V2.tsx

### No Issue
2026-02-11 17:40:08 +08:00

571 lines
15 KiB
TypeScript

import { AccentColor, MainColor } from "@/constants/color-palet";
import { Ionicons } from "@expo/vector-icons";
import { router, usePathname } from "expo-router";
import { useEffect, useRef, useState } from "react";
import {
Animated,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
export interface NavbarItem_V2 {
label: string;
icon?: keyof typeof Ionicons.glyphMap;
color?: string;
link?: string;
links?: {
label: string;
link: string;
detailPattern?: string;
}[];
initiallyOpened?: boolean;
}
interface NavbarMenuProps {
items: NavbarItem_V2[];
onClose?: () => void;
}
export default function NavbarMenu_V2({ items, onClose }: NavbarMenuProps) {
const pathname = usePathname();
const [openKeys, setOpenKeys] = useState<string[]>([]);
// Normalisasi path: hapus trailing slash
const normalizePath = (path: string) => path.replace(/\/+$/, "");
const normalizedPathname = pathname ? normalizePath(pathname) : "";
// Auto-open parent menu jika submenu aktif
useEffect(() => {
if (!normalizedPathname || !items || items.length === 0) {
return;
}
try {
const newOpenKeys: string[] = [];
// Helper function yang sama dengan di MenuItem
const checkPathMatch = (linkPath: string, detailPattern?: string) => {
const normalizedLink = linkPath.replace(/\/+$/, "");
// Exact match
if (normalizedPathname === normalizedLink) return true;
// Detail pattern match
if (detailPattern) {
const patternRegex = new RegExp(
"^" + detailPattern.replace(/\*/g, "[^/]+") + "(/.*)?$",
);
if (patternRegex.test(normalizedPathname)) {
return true;
}
}
// Detail page match (fallback)
if (normalizedPathname.startsWith(normalizedLink + "/")) {
const remainder = normalizedPathname.substring(
normalizedLink.length + 1,
);
const segments = remainder.split("/").filter((s) => s.length > 0);
if (segments.length === 0) return false;
const commonWords = [
// Event
"type-create",
// Other
"detail",
"edit",
"create",
"new",
"add",
"delete",
"view",
"publish",
"review",
"reject",
"status",
"category",
"history",
"type-of-event",
"posting",
"report-posting",
"report-comment",
"group",
"dashboard",
"sticker",
"active",
"inactive",
"pending",
"transaction-detail",
"transaction",
"payment",
"disbursement",
"list-of-investor",
];
const hasIdSegment = segments.some((segment) => {
if (commonWords.includes(segment.toLowerCase())) {
return false;
}
const isPureNumber = /^\d+$/.test(segment);
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,
);
const hasNumber = /\d/.test(segment);
const isAlphanumericId =
/^[a-z0-9_-]+$/i.test(segment) &&
segment.length <= 50 &&
hasNumber;
return isPureNumber || isUUID || isAlphanumericId;
});
return hasIdSegment;
}
return false;
};
items.forEach((item) => {
if (item.links && item.links.length > 0) {
// Check jika ada submenu yang match dengan current path
const hasActiveSubmenu = item.links.some((subItem) => {
return checkPathMatch(subItem.link, subItem.detailPattern);
});
if (hasActiveSubmenu) {
newOpenKeys.push(item.label);
}
}
});
setOpenKeys(newOpenKeys);
} catch (error) {
console.error("Error in NavbarMenu useEffect:", error);
}
}, [normalizedPathname, items]);
// Toggle dropdown
const toggleOpen = (label: string) => {
setOpenKeys((prev) =>
prev.includes(label)
? prev.filter((key) => key !== label)
: [...prev, label],
);
};
return (
<View
style={{
marginBottom: 20,
}}
>
<ScrollView
contentContainerStyle={{
paddingVertical: 10,
}}
>
{items && items.length > 0
? items.map((item) => (
<MenuItem
key={item.label}
item={item}
onClose={onClose}
currentPath={normalizedPathname}
isOpen={openKeys.includes(item.label)}
toggleOpen={() => toggleOpen(item.label)}
/>
))
: null}
</ScrollView>
</View>
);
}
// Komponen Item Menu
function MenuItem({
item,
onClose,
currentPath,
isOpen,
toggleOpen,
}: {
item: NavbarItem_V2;
onClose?: () => void;
currentPath: string;
isOpen: boolean;
toggleOpen: () => void;
}) {
const animatedHeight = useRef(new Animated.Value(0)).current;
// Helper function untuk check apakah path aktif
const isPathActive = (
linkPath: string | undefined,
detailPattern?: string,
) => {
if (!linkPath) return false;
const normalizedLink = linkPath.replace(/\/+$/, "");
// 1. Match exact - prioritas tertinggi
if (currentPath === normalizedLink) return true;
// 2. Jika ada detailPattern, cek pattern dulu
if (detailPattern) {
// detailPattern contoh: "/admin/job/*/review"
// akan match dengan:
// - /admin/job/123/review ✅
// - /admin/job/123/review/transaction-detail ✅
// - /admin/job/123/review/anything/nested ✅
const patternRegex = new RegExp(
"^" + detailPattern.replace(/\*/g, "[^/]+") + "(/.*)?$",
);
const isMatch = patternRegex.test(currentPath);
// Debug log untuk pattern matching
if (
currentPath.includes("list-of-investor") ||
currentPath.includes("type-create")
) {
console.log(
"🔍 Pattern Match Check:",
JSON.stringify(
{
currentPath,
detailPattern,
regex: patternRegex.toString(),
isMatch,
},
null,
2,
),
);
}
if (isMatch) {
return true;
}
}
// 3. Match untuk detail pages (fallback)
if (currentPath.startsWith(normalizedLink + "/")) {
const remainder = currentPath.substring(normalizedLink.length + 1);
const segments = remainder.split("/").filter((s) => s.length > 0);
if (segments.length === 0) return false;
const commonWords = [
// Event
"type-create",
"detail",
"edit",
"create",
"new",
"add",
"delete",
"view",
"publish",
"review",
"reject",
"status",
"category",
"history",
"type-of-event",
"posting",
"report-posting",
"report-comment",
"group",
"dashboard",
"sticker",
"active",
"inactive",
"pending",
"transaction-detail",
"transaction",
"payment",
"disbursement",
];
const hasIdSegment = segments.some((segment) => {
if (commonWords.includes(segment.toLowerCase())) {
return false;
}
const isPureNumber = /^\d+$/.test(segment);
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,
);
const hasNumber = /\d/.test(segment);
const isAlphanumericId =
/^[a-z0-9_-]+$/i.test(segment) && segment.length <= 50 && hasNumber;
return isPureNumber || isUUID || isAlphanumericId;
});
return hasIdSegment;
}
return false;
};
// Check apakah menu item ini atau submenu-nya yang aktif
const isActive = isPathActive(item.link);
const hasActiveSubmenu =
item.links?.some((subItem) =>
isPathActive(subItem.link, subItem.detailPattern),
) || false;
// Animasi saat isOpen berubah
useEffect(() => {
Animated.timing(animatedHeight, {
toValue: isOpen ? (item.links ? item.links.length * 44 : 0) : 0,
duration: 200,
useNativeDriver: false,
}).start();
}, [isOpen, item.links, animatedHeight]);
// Jika ada submenu
if (item.links && item.links.length > 0) {
return (
<View>
{/* Parent Item */}
<TouchableOpacity
style={[
styles.parentItem,
hasActiveSubmenu && styles.parentItemActive,
]}
onPress={toggleOpen}
>
<Ionicons
name={item.icon}
size={16}
color={hasActiveSubmenu ? MainColor.yellow : MainColor.white}
style={styles.icon}
/>
<Text
style={[
styles.parentText,
hasActiveSubmenu && { color: MainColor.yellow },
]}
>
{item.label}
</Text>
<Ionicons
name={isOpen ? "chevron-up" : "chevron-down"}
size={16}
color={hasActiveSubmenu ? MainColor.yellow : MainColor.white}
/>
</TouchableOpacity>
{/* Submenu (Animated) */}
<Animated.View
style={[
styles.submenu,
{
height: animatedHeight,
opacity: animatedHeight.interpolate({
inputRange: [0, item.links.length * 44],
outputRange: [0, 1],
extrapolate: "clamp",
}),
},
]}
>
{item.links.map((subItem, index) => {
const isSubActive = isPathActive(
subItem.link,
subItem.detailPattern,
);
// CRITICAL FIX: Jika submenu ini aktif, cek apakah ada submenu lain yang LEBIH PANJANG dan juga aktif
// Jika ada yang lebih panjang dan aktif, maka yang pendek TIDAK AKTIF
const hasMoreSpecificMatch = item.links!.some((otherSubItem) => {
if (otherSubItem.link === subItem.link) return false; // Skip self
const otherIsActive = isPathActive(
otherSubItem.link,
otherSubItem.detailPattern,
);
const isOtherLonger =
otherSubItem.link.length > subItem.link.length;
// Debug log
if (isSubActive && otherIsActive) {
console.log(
"🔍 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),
// maka submenu yang pendek ini TIDAK boleh aktif
return otherIsActive && isOtherLonger;
});
// Final decision: aktif HANYA jika match DAN tidak ada yang lebih spesifik
const finalIsActive = isSubActive && !hasMoreSpecificMatch;
// Debug final decision
if (isSubActive) {
console.log(
"✅ Active check:",
JSON.stringify(
{
label: subItem.label,
link: subItem.link,
isSubActive,
hasMoreSpecificMatch,
finalIsActive,
},
null,
2,
),
);
}
return (
<TouchableOpacity
key={index}
style={[styles.subItem, finalIsActive && styles.subItemActive]}
onPress={() => {
onClose?.();
router.push(subItem.link as any);
}}
>
<Ionicons
name="radio-button-on-outline"
size={16}
color={finalIsActive ? MainColor.yellow : MainColor.white}
style={styles.icon}
/>
<Text
style={[
styles.subText,
finalIsActive && { color: MainColor.yellow },
]}
>
{subItem.label}
</Text>
</TouchableOpacity>
);
})}
</Animated.View>
</View>
);
}
// Menu tanpa submenu
return (
<TouchableOpacity
style={[styles.singleItem, isActive && styles.singleItemActive]}
onPress={() => {
onClose?.();
router.push(item.link as any);
}}
>
<Ionicons
name={item.icon}
size={16}
color={isActive ? MainColor.yellow : MainColor.white}
style={styles.icon}
/>
<Text
style={[
styles.singleText,
{ color: isActive ? MainColor.yellow : MainColor.white },
]}
>
{item.label}
</Text>
</TouchableOpacity>
);
}
// Styles
const styles = StyleSheet.create({
container: {
marginBottom: 5,
},
parentItem: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 12,
paddingHorizontal: 10,
borderRadius: 8,
marginBottom: 5,
justifyContent: "space-between",
},
parentItemActive: {
backgroundColor: AccentColor.blue,
},
parentText: {
flex: 1,
fontSize: 16,
fontWeight: "500",
marginLeft: 10,
color: MainColor.white,
},
singleItem: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 12,
paddingHorizontal: 10,
borderRadius: 8,
marginBottom: 5,
},
singleItemActive: {
backgroundColor: AccentColor.blue,
},
singleText: {
fontSize: 16,
fontWeight: "500",
marginLeft: 10,
color: MainColor.white,
},
icon: {
width: 24,
textAlign: "center",
paddingRight: 10,
},
submenu: {
overflow: "hidden",
marginLeft: 30,
marginTop: 5,
},
subItem: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 8,
paddingHorizontal: 10,
borderRadius: 6,
marginBottom: 4,
},
subItemActive: {
backgroundColor: AccentColor.blue,
},
subText: {
color: MainColor.white,
fontSize: 16,
fontWeight: "500",
},
});