Compare commits

...

6 Commits

Author SHA1 Message Date
42fa80c228 Fix Admin
Admin – App Information
- app/(application)/admin/app-information/index.tsx
- app/(application)/admin/app-information/business-field/[id]/index.tsx

Admin Screens
- screens/Admin/App-Information/BusinessFieldSection.tsx
- screens/Admin/App-Information/InformationBankSection.tsx
- screens/Admin/User-Access/ScreenUserAccess.tsx

New Admin Screens
- screens/Admin/App-Information/ScreenAppInformation.tsx
- screens/Admin/App-Information/ScreenBusinessFieldDetail.tsx

Shared Components
- components/_ShareComponent/Admin/BoxTitlePage.tsx

API Service
- service/api-admin/api-master-admin.ts

Styles
- styles/global-styles.ts

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

### No Issue
2026-02-13 17:38:48 +08:00
fb697366fe Fixed admin user access
Admin Layout & Pages
- app/(application)/admin/_layout.tsx
- app/(application)/admin/user-access/index.tsx

Admin Components
- components/Drawer/NavbarMenu.tsx
- components/_ShareComponent/Admin/BoxTitlePage.tsx
- components/_ShareComponent/Admin/AdminBasicBox.tsx

Admin Screens
- screens/Admin/User-Access/

API – Admin User Access
- service/api-admin/api-admin-user-access.ts

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

### No issue
2026-02-12 17:35:28 +08:00
6d71c3a86f Navbar menu versi 3
Admin Layout
- app/(application)/admin/_layout.tsx

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

New Component
- components/Drawer/NavbarMenu_V3.tsx

### No Issue'
2026-02-12 14:55:05 +08:00
e030b8f486 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
2026-02-12 11:48:01 +08:00
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
b2be7be533 Saya telah melakukan serangkaian perubahan penting dalam pengembangan aplikasi HIPMI Mobile, khususnya dalam modul
Donasi. Berikut adalah ringkasan perubahan yang telah dilakukan:

     1. Menerapkan sistem pagination pada berbagai komponen layar donasi:
        - ScreenBeranda.tsx
        - ScreenMyDonation.tsx
        - ScreenRecapOfNews.tsx
        - ScreenListOfNews.tsx
        - ScreenListOfDonatur.tsx
        - ScreenFundDisbursement.tsx

     2. Memperbarui fungsi-fungsi API untuk mendukung parameter page:
        - apiDonationGetAll
        - apiDonationGetNewsById
        - apiDonationListOfDonaturById
        - apiDonationDisbursementOfFundsListById

     3. Mengganti komponen wrapper lama (ViewWrapper) dengan NewWrapper yang mendukung sistem pagination

     4. Membuat komponen layar terpisah untuk meningkatkan modularitas kode

     5. Memperbaiki berbagai error yang terjadi, termasuk masalah dengan import komponen dan struktur JSX

### No Issue
2026-02-10 17:30:30 +08:00
47 changed files with 4157 additions and 1358 deletions

View File

@@ -1,56 +1,9 @@
import {
FloatingButton,
LoaderCustom,
TextCustom,
ViewWrapper,
} from "@/components";
import Donation_BoxPublish from "@/screens/Donation/BoxPublish";
import { apiDonationGetAll } from "@/service/api-client/api-donation";
import { router, useFocusEffect } from "expo-router";
import _ from "lodash";
import { useCallback, useState } from "react";
import Donation_ScreenBeranda from "@/screens/Donation/ScreenBeranda";
export default function DonationBeranda() {
const [list, setList] = useState<any[] | null>(null);
const [loadList, setLoadList] = useState(false);
useFocusEffect(
useCallback(() => {
onLoadData();
}, [])
);
const onLoadData = async () => {
try {
setLoadList(true);
const response = await apiDonationGetAll({
category: "beranda"
});
setList(response.data);
} catch (error) {
console.log("[ERROR]", error);
} finally {
setLoadList(false);
}
};
return (
<ViewWrapper
hideFooter
floatingButton={
<FloatingButton onPress={() => router.push("/donation/create")} />
}
>
{loadList ? (
<LoaderCustom />
) : _.isEmpty(list) ? (
<TextCustom align="center" color="gray">Belum ada donasi</TextCustom>
) : (
list?.map((item: any, index: number) => (
<Donation_BoxPublish data={item} key={index} id={item.id} />
))
)}
</ViewWrapper>
<>
<Donation_ScreenBeranda />
</>
);
}

View File

@@ -1,148 +1,5 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
BadgeCustom,
BaseBox,
DummyLandscapeImage,
Grid,
LoaderCustom,
StackCustom,
TextCustom,
ViewWrapper,
} from "@/components";
import { useAuth } from "@/hooks/use-auth";
import { apiDonationGetAll } from "@/service/api-client/api-donation";
import { formatCurrencyDisplay } from "@/utils/formatCurrencyDisplay";
import { Href, router, useFocusEffect } from "expo-router";
import _ from "lodash";
import { useCallback, useState } from "react";
import { View } from "react-native";
import Toast from "react-native-toast-message";
import Donation_ScreenMyDonation from "@/screens/Donation/ScreenMyDonation";
export default function DonationMyDonation() {
const { user } = useAuth();
const [list, setList] = useState<any[] | null>(null);
const [loadList, setLoadList] = useState(false);
useFocusEffect(
useCallback(() => {
onLoadData();
}, [user?.id]),
);
const onLoadData = async () => {
if (!user?.id) {
Toast.show({
type: "error",
text1: "Load data gagal, user tidak ditemukan",
});
return;
}
try {
setLoadList(true);
const response = await apiDonationGetAll({
category: "my-donation",
authorId: user?.id,
});
setList(response.data);
} catch (error) {
console.log("[ERROR]", error);
} finally {
setLoadList(false);
}
};
const handlerColor = (status: string) => {
if (status === "menunggu") {
return "orange";
} else if (status === "proses") {
return "white";
} else if (status === "berhasil") {
return "green";
} else if (status === "gagal") {
return "red";
}
};
const handlePress = ({
invoiceId,
donationId,
status,
}: {
invoiceId: string;
donationId: string;
status: string;
}) => {
const url: Href = `../${donationId}/(transaction-flow)/${invoiceId}`;
if (status === "menunggu") {
router.push(`${url}/invoice`);
} else if (status === "proses") {
router.push(`${url}/process`);
} else if (status === "berhasil") {
router.push(`${url}/success`);
} else if (status === "gagal") {
router.push(`${url}/failed`);
}
};
return (
<ViewWrapper hideFooter>
{loadList ? (
<LoaderCustom />
) : _.isEmpty(list) ? (
<TextCustom align="center" color="gray">
Belum ada transaksi
</TextCustom>
) : (
list?.map((item, index) => (
<BaseBox
key={index}
paddingTop={7}
paddingBottom={7}
onPress={() => {
handlePress({
status: _.lowerCase(item.statusInvoice),
invoiceId: item.id,
donationId: item.donasiId,
});
}}
>
<Grid>
<Grid.Col span={5}>
<DummyLandscapeImage
height={100}
unClickPath
imageId={item.imageId}
/>
</Grid.Col>
<Grid.Col span={1}>
<View />
</Grid.Col>
<Grid.Col span={6}>
<StackCustom>
<TextCustom truncate={2} bold>
{item.title || "-"}
</TextCustom>
<TextCustom bold color="yellow">
Rp. {formatCurrencyDisplay(item.nominal)}
</TextCustom>
<BadgeCustom
variant="light"
color={handlerColor(_.lowerCase(item.statusInvoice))}
fullWidth
>
{item.statusInvoice}
</BadgeCustom>
</StackCustom>
</Grid.Col>
</Grid>
</BaseBox>
))
)}
</ViewWrapper>
);
return <Donation_ScreenMyDonation />;
}

View File

@@ -1,5 +1,6 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
BoxButtonOnFooter,
ButtonCenteredOnly,
ButtonCustom,
InformationBox,
@@ -31,7 +32,7 @@ export default function DonationEditNews() {
useFocusEffect(
useCallback(() => {
onLoadData();
}, [news])
}, [news]),
);
const onLoadData = async () => {
@@ -104,7 +105,21 @@ export default function DonationEditNews() {
};
return (
<ViewWrapper>
<ViewWrapper
footerComponent={
<BoxButtonOnFooter>
<ButtonCustom
disabled={!data?.title || !data?.deskripsi}
isLoading={isLoading}
onPress={() => {
handlerSubmitUpdate();
}}
>
Update
</ButtonCustom>
</BoxButtonOnFooter>
}
>
<StackCustom gap={"xs"}>
<InformationBox text="Upload gambar bersifat opsional untuk melengkapi kabar terkait donasi Anda." />
<LandscapeFrameUploaded
@@ -148,15 +163,6 @@ export default function DonationEditNews() {
/>
<Spacing />
<ButtonCustom
disabled={!data?.title || !data?.deskripsi}
isLoading={isLoading}
onPress={() => {
handlerSubmitUpdate();
}}
>
Update
</ButtonCustom>
</StackCustom>
<Spacing />
</ViewWrapper>

View File

@@ -1,8 +1,10 @@
import {
BoxButtonOnFooter,
ButtonCenteredOnly,
ButtonCustom,
InformationBox,
LandscapeFrameUploaded,
NewWrapper,
Spacing,
StackCustom,
TextAreaCustom,
@@ -53,7 +55,7 @@ export default function DonationAddNews() {
text1: "Gagal menambah berita",
});
return
return;
}
Toast.show({
@@ -70,7 +72,21 @@ export default function DonationAddNews() {
};
return (
<ViewWrapper>
<NewWrapper
footerComponent={
<BoxButtonOnFooter>
<ButtonCustom
disabled={!data.title || !data.deskripsi}
isLoading={isLoading}
onPress={() => {
handlerSubmit();
}}
>
Simpan
</ButtonCustom>
</BoxButtonOnFooter>
}
>
<StackCustom gap={"xs"}>
<InformationBox text="Upload gambar bersifat opsional untuk melengkapi kabar terkait donasi Anda." />
<LandscapeFrameUploaded image={image?.uri} />
@@ -116,17 +132,7 @@ export default function DonationAddNews() {
/>
<Spacing />
<ButtonCustom
disabled={!data.title || !data.deskripsi}
isLoading={isLoading}
onPress={() => {
handlerSubmit();
}}
>
Simpan
</ButtonCustom>
</StackCustom>
<Spacing />
</ViewWrapper>
</NewWrapper>
);
}

View File

@@ -1,110 +1,8 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
BackButton,
BaseBox,
DrawerCustom,
Grid,
LoaderCustom,
MenuDrawerDynamicGrid,
TextCustom,
ViewWrapper,
} from "@/components";
import { IconPlus } from "@/components/_Icon";
import { apiDonationGetNewsById } from "@/service/api-client/api-donation";
import { formatChatTime } from "@/utils/formatChatTime";
import {
router,
Stack,
useFocusEffect,
useLocalSearchParams,
} from "expo-router";
import _ from "lodash";
import { useCallback, useState } from "react";
import { useLocalSearchParams } from "expo-router";
import Donation_ScreenListOfNews from "@/screens/Donation/ScreenListOfNews";
export default function DonationRecapOfNews() {
const { id } = useLocalSearchParams();
const [openDrawer, setOpenDrawer] = useState(false);
const [list, setList] = useState<any[] | null>(null);
const [loadList, setLoadList] = useState<boolean>(false);
useFocusEffect(
useCallback(() => {
onLoadList();
}, [id])
);
const onLoadList = async () => {
try {
setLoadList(true);
const response = await apiDonationGetNewsById({
id: id as string,
category: "get-all",
});
setList(response.data);
} catch (error) {
console.log("[ERROR]", error);
setList([]);
} finally {
setLoadList(false);
}
};
return (
<>
<Stack.Screen
options={{
title: "Daftar Kabar",
headerLeft: () => <BackButton />,
}}
/>
<ViewWrapper>
{loadList ? (
<LoaderCustom />
) : _.isEmpty(list) ? (
<TextCustom align="center" color="gray">
Tidak ada kabar
</TextCustom>
) : (
list?.map((item: any, index: number) => (
<BaseBox key={index} href={`/donation/[id]/(news)/${item.id}`}>
<Grid>
<Grid.Col span={8}>
<TextCustom truncate bold>
{item?.title || "-"}
</TextCustom>
</Grid.Col>
<Grid.Col span={4} style={{ alignItems: "flex-end" }}>
<TextCustom size="small">
{formatChatTime(item?.createdAt)}
</TextCustom>
</Grid.Col>
</Grid>
</BaseBox>
))
)}
</ViewWrapper>
<DrawerCustom
isVisible={openDrawer}
closeDrawer={() => setOpenDrawer(false)}
height={"auto"}
>
<MenuDrawerDynamicGrid
data={[
{
icon: <IconPlus />,
label: "Tambah Berita",
path: `/donation/${id}/(news)/add-news`,
},
]}
onPressItem={(item) => {
console.log("PATH ", item.path);
router.navigate(item.path as any);
setOpenDrawer(false);
}}
/>
</DrawerCustom>
</>
);
return <Donation_ScreenListOfNews donationId={id as string} />;
}

View File

@@ -1,112 +1,8 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
BackButton,
BaseBox,
DotButton,
DrawerCustom,
Grid,
LoaderCustom,
MenuDrawerDynamicGrid,
TextCustom,
ViewWrapper,
} from "@/components";
import { IconPlus } from "@/components/_Icon";
import { apiDonationGetNewsById } from "@/service/api-client/api-donation";
import { formatChatTime } from "@/utils/formatChatTime";
import {
router,
Stack,
useFocusEffect,
useLocalSearchParams,
} from "expo-router";
import _ from "lodash";
import { useCallback, useState } from "react";
import { useLocalSearchParams } from "expo-router";
import Donation_ScreenRecapOfNews from "@/screens/Donation/ScreenRecapOfNews";
export default function DonationRecapOfNews() {
const { id } = useLocalSearchParams();
const [openDrawer, setOpenDrawer] = useState(false);
const [list, setList] = useState<any[] | null>(null);
const [loadList, setLoadList] = useState<boolean>(false);
useFocusEffect(
useCallback(() => {
onLoadList();
}, [id])
);
const onLoadList = async () => {
try {
setLoadList(true);
const response = await apiDonationGetNewsById({
id: id as string,
category: "get-all",
});
setList(response.data);
} catch (error) {
console.log("[ERROR]", error);
setList([]);
} finally {
setLoadList(false);
}
};
return (
<>
<Stack.Screen
options={{
title: "Rekap Kabar",
headerLeft: () => <BackButton />,
headerRight: () => <DotButton onPress={() => setOpenDrawer(true)} />,
}}
/>
<ViewWrapper>
{loadList ? (
<LoaderCustom />
) : _.isEmpty(list) ? (
<TextCustom align="center" color="gray">
Tidak ada kabar
</TextCustom>
) : (
list?.map((item: any, index: number) => (
<BaseBox key={index} href={`/donation/[id]/(news)/${item.id}`}>
<Grid>
<Grid.Col span={8}>
<TextCustom truncate bold>
{item?.title || "-"}
</TextCustom>
</Grid.Col>
<Grid.Col span={4} style={{ alignItems: "flex-end" }}>
<TextCustom size="small">
{formatChatTime(item?.createdAt)}
</TextCustom>
</Grid.Col>
</Grid>
</BaseBox>
))
)}
</ViewWrapper>
<DrawerCustom
isVisible={openDrawer}
closeDrawer={() => setOpenDrawer(false)}
height={"auto"}
>
<MenuDrawerDynamicGrid
data={[
{
icon: <IconPlus />,
label: "Tambah Berita",
path: `/donation/${id}/(news)/add-news`,
},
]}
onPressItem={(item) => {
console.log("PATH ", item.path);
router.navigate(item.path as any);
setOpenDrawer(false);
}}
/>
</DrawerCustom>
</>
);
return <Donation_ScreenRecapOfNews donationId={id as string} />;
}

View File

@@ -1,6 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
BaseBox,
BoxButtonOnFooter,
ButtonCenteredOnly,
ButtonCustom,
Grid,
@@ -35,7 +36,7 @@ export default function DonationInvoice() {
useFocusEffect(
useCallback(() => {
onLoadData();
}, [invoiceId])
}, [invoiceId]),
);
const onLoadData = async () => {
@@ -100,7 +101,22 @@ export default function DonationInvoice() {
return (
<>
<ViewWrapper>
<ViewWrapper
hideFooter
footerComponent={
<BoxButtonOnFooter>
<ButtonCustom
disabled={!image}
isLoading={isLoading}
onPress={() => {
handlerUpdateInvoice();
}}
>
Simpan
</ButtonCustom>
</BoxButtonOnFooter>
}
>
<StackCustom>
<InformationBox
text={`Mohon transfer donasi anda ke rekening dibawah`}
@@ -204,16 +220,6 @@ export default function DonationInvoice() {
</ButtonCenteredOnly>
</StackCustom>
</BaseBox>
<ButtonCustom
disabled={!image}
isLoading={isLoading}
onPress={() => {
handlerUpdateInvoice();
}}
>
Simpan
</ButtonCustom>
</StackCustom>
<Spacing />
</ViewWrapper>

View File

@@ -4,11 +4,12 @@ import {
DotButton,
DrawerCustom,
MenuDrawerDynamicGrid,
NewWrapper,
Spacing,
ViewWrapper,
} from "@/components";
import { IconEdit, IconNews } from "@/components/_Icon";
import { IMenuDrawerItem } from "@/components/_Interface/types";
import CustomSkeleton from "@/components/_ShareComponent/SkeletonCustom";
import { MainColor } from "@/constants/color-palet";
import { ICON_SIZE_SMALL } from "@/constants/constans-value";
import Donation_ButtonStatusSection from "@/screens/Donation/ButtonStatusSection";
@@ -26,13 +27,14 @@ import {
} from "expo-router";
import _ from "lodash";
import { useCallback, useEffect, useState } from "react";
import { RefreshControl } from "react-native";
export default function DonasiDetailStatus() {
const { id, status } = useLocalSearchParams();
const [openDrawer, setOpenDrawer] = useState(false);
const [openDrawerPublish, setOpenDrawerPublish] = useState(false);
const [data, setData] = useState<any>();
const [refreshing, setRefreshing] = useState(false);
const [data, setData] = useState<any | null>(null);
useFocusEffect(
useCallback(() => {
@@ -80,6 +82,17 @@ export default function DonasiDetailStatus() {
});
};
const onRefresh = useCallback(() => {
try {
setRefreshing(true);
onLoadData();
} catch (error) {
console.log("Error refresh");
} finally {
setRefreshing(false);
}
}, []);
return (
<>
<Stack.Screen
@@ -94,32 +107,50 @@ export default function DonasiDetailStatus() {
) : null,
}}
/>
<ViewWrapper>
<Donation_ComponentBoxDetailData
sisaHari={value.sisa}
reminder={value.reminder}
data={data}
showSisaHari={status === "publish" ? true : false}
bottomSection={
status === "publish" && (
<Donation_ProgressSection
<NewWrapper
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={MainColor.yellow}
colors={[MainColor.yellow]}
/>
}
>
{!data ? (
<CustomSkeleton height={400} />
) : (
<>
<Donation_ComponentBoxDetailData
sisaHari={value.sisa}
reminder={value.reminder}
data={data}
showSisaHari={status === "publish" ? true : false}
bottomSection={
status === "publish" && (
<Donation_ProgressSection
id={id as string}
progres={Number(data?.progres) || 0}
/>
)
}
/>
<Donation_ComponentStoryFunrising
id={id as string}
dataStory={data?.CeritaDonasi}
/>
<Spacing />
{data && (
<Donation_ButtonStatusSection
id={id as string}
progres={Number(data?.progres) || 0}
status={status as string}
/>
)
}
/>
<Donation_ComponentStoryFunrising
id={id as string}
dataStory={data?.CeritaDonasi}
/>
<Spacing />
<Donation_ButtonStatusSection
id={id as string}
status={status as string}
/>
<Spacing />
</ViewWrapper>
)}
<Spacing />
</>
)}
</NewWrapper>
<DrawerCustom
isVisible={openDrawer}

View File

@@ -13,6 +13,7 @@ import {
TextInputCustom,
ViewWrapper,
} from "@/components";
import ListSkeletonComponent from "@/components/_ShareComponent/ListSkeletonComponent";
import API_IMAGE from "@/constants/api-storage";
import DIRECTORY_ID from "@/constants/directory-id";
import {
@@ -200,7 +201,7 @@ export default function DonationEdit() {
>
<InformationBox text="Lengkapi semua data di bawah untuk selanjutnya mengisi cerita penggalangan dana." />
{!data || loadList ? (
<LoaderCustom />
<ListSkeletonComponent />
) : (
<StackCustom gap={"xs"}>
<TextInputCustom

View File

@@ -1,124 +1,8 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
BaseBox,
ButtonCenteredOnly,
Grid,
InformationBox,
LoaderCustom,
StackCustom,
TextCustom,
ViewWrapper,
} from "@/components";
import {
apiDonationDisbursementOfFundsListById,
apiDonationGetOne,
} from "@/service/api-client/api-donation";
import { formatCurrencyDisplay } from "@/utils/formatCurrencyDisplay";
import dayjs from "dayjs";
import { router, useFocusEffect, useLocalSearchParams } from "expo-router";
import _ from "lodash";
import React, { useState } from "react";
import { useLocalSearchParams } from "expo-router";
import Donation_ScreenFundDisbursement from "@/screens/Donation/ScreenFundDisbursement";
export default function DonationFundDisbursement() {
const { id } = useLocalSearchParams();
const [data, setData] = useState({
totalPencairan: 0,
akumulasiPencairan: 0,
});
const [listData, setListData] = React.useState<any[] | null>(null);
const [loadData, setLoadData] = React.useState(false);
useFocusEffect(
React.useCallback(() => {
onLoadData();
}, [id])
);
const onLoadData = async () => {
try {
setLoadData(true);
const responseData = await apiDonationGetOne({
id: id as string,
category: "permanent",
});
if (responseData.success) {
setData({
totalPencairan: responseData.data.totalPencairan,
akumulasiPencairan: responseData.data.akumulasiPencairan,
});
}
const responseList = await apiDonationDisbursementOfFundsListById({
id: id as string,
});
if (responseList.success) {
setListData(responseList.data);
}
} catch (error) {
console.log("[ERROR]", error);
} finally {
setLoadData(false);
}
};
return (
<>
<ViewWrapper>
<InformationBox text="Pencairan dana akan dilakukan oleh Admin HIPMI tanpa campur tangan pihak manapun, jika berita pencairan dana dibawah tidak sesuai dengan kabar yang diberikan oleh PENGGALANG DANA. Maka pegguna lain dapat melaporkannya pada Admin HIPMI !" />
<BaseBox>
<Grid>
<Grid.Col span={6}>
<TextCustom bold color="yellow">
Rp. {formatCurrencyDisplay(data?.totalPencairan)}
</TextCustom>
<TextCustom size="small">Total Pencairan Dana</TextCustom>
</Grid.Col>
<Grid.Col span={6}>
<TextCustom bold color="yellow">
{data?.akumulasiPencairan} kali
</TextCustom>
<TextCustom size="small">Akumulasi Pencairan</TextCustom>
</Grid.Col>
</Grid>
</BaseBox>
{loadData ? (
<LoaderCustom />
) : _.isEmpty(listData) ? (
<TextCustom align="center" color="gray">
Belum ada data
</TextCustom>
) : (
listData?.map((item, index) => (
<BaseBox key={index}>
<StackCustom>
<Grid>
<Grid.Col span={8}>
<TextCustom bold>{item?.title}</TextCustom>
</Grid.Col>
<Grid.Col span={4} style={{ alignItems: "flex-end" }}>
<TextCustom>{dayjs(item?.createdAt).format("DD MMM YYYY")}</TextCustom>
</Grid.Col>
</Grid>
<TextCustom>{item?.deskripsi}</TextCustom>
<ButtonCenteredOnly
onPress={() => {
router.navigate(`/(application)/(image)/preview-image/${item?.imageId}`);
}}
icon="file-text"
>
Bukti Transaksi
</ButtonCenteredOnly>
</StackCustom>
</BaseBox>
))
)}
</ViewWrapper>
</>
);
return <Donation_ScreenFundDisbursement donationId={id as string} />;
}

View File

@@ -6,10 +6,12 @@ import {
DotButton,
DrawerCustom,
MenuDrawerDynamicGrid,
NewWrapper,
StackCustom,
ViewWrapper,
} from "@/components";
import { IconNews } from "@/components/_Icon";
import CustomSkeleton from "@/components/_ShareComponent/SkeletonCustom";
import { useAuth } from "@/hooks/use-auth";
import Donation_ComponentBoxDetailData from "@/screens/Donation/ComponentBoxDetailData";
import Donation_ComponentInfoFundrising from "@/screens/Donation/ComponentInfoFundrising";
@@ -34,7 +36,7 @@ export default function DonasiDetailBeranda() {
useFocusEffect(
useCallback(() => {
onLoadData();
}, [id])
}, [id]),
);
const onLoadData = async () => {
@@ -75,10 +77,10 @@ export default function DonasiDetailBeranda() {
<>
<BoxButtonOnFooter>
<ButtonCustom
disabled={value?.reminder}
disabled={value?.reminder || !data}
onPress={() => router.navigate(`/donation/${id}/(transaction-flow)`)}
>
{value?.reminder ? "Waktu berakhir" : "Donasi"}
{!data ? "Loading..." : value?.reminder ? "Waktu berakhir" : "Donasi"}
</ButtonCustom>
</BoxButtonOnFooter>
</>
@@ -96,21 +98,30 @@ export default function DonasiDetailBeranda() {
) : null,
}}
/>
<ViewWrapper footerComponent={buttonSection}>
<StackCustom>
<Donation_ComponentBoxDetailData
sisaHari={value.sisa}
reminder={value.reminder}
data={data}
bottomSection={<Donation_ProgressSection id={id as string} progres={Number(data?.progres) || 0} />}
/>
<Donation_ComponentInfoFundrising dataAuthor={data?.Author} />
<Donation_ComponentStoryFunrising
id={id as string}
dataStory={data?.CeritaDonasi}
/>
</StackCustom>
</ViewWrapper>
<NewWrapper footerComponent={buttonSection}>
{!data ? (
<CustomSkeleton height={400} />
) : (
<StackCustom>
<Donation_ComponentBoxDetailData
sisaHari={value.sisa}
reminder={value.reminder}
data={data}
bottomSection={
<Donation_ProgressSection
id={id as string}
progres={Number(data?.progres) || 0}
/>
}
/>
<Donation_ComponentInfoFundrising dataAuthor={data?.Author} />
<Donation_ComponentStoryFunrising
id={id as string}
dataStory={data?.CeritaDonasi}
/>
</StackCustom>
)}
</NewWrapper>
<DrawerCustom
isVisible={openDrawer}

View File

@@ -1,94 +1,8 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
BaseBox,
Grid,
LoaderCustom,
Spacing,
StackCustom,
TextCustom,
ViewWrapper,
} from "@/components";
import { MainColor } from "@/constants/color-palet";
import { apiAdminDonationListOfDonaturById } from "@/service/api-admin/api-admin-donation";
import { formatCurrencyDisplay } from "@/utils/formatCurrencyDisplay";
import { FontAwesome6 } from "@expo/vector-icons";
import dayjs from "dayjs";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import _ from "lodash";
import { useCallback, useState } from "react";
import { useLocalSearchParams } from "expo-router";
import Donation_ScreenListOfDonatur from "@/screens/Donation/ScreenListOfDonatur";
export default function Donation_ListOfDonatur() {
export default function DonationListOfDonatur() {
const { id } = useLocalSearchParams();
const [listData, setListData] = useState<any[] | null>(null);
const [loadData, setLoadData] = useState(false);
useFocusEffect(
useCallback(() => {
onLoadData();
}, [id])
);
const onLoadData = async () => {
try {
setLoadData(true);
const response = await apiAdminDonationListOfDonaturById({
id: id as string,
});
if (response.success) {
setListData(response.data);
}
} catch (error) {
console.log("[ERROR]", error);
} finally {
setLoadData(false);
}
};
return (
<>
<ViewWrapper>
{loadData ? (
<LoaderCustom />
) : _.isEmpty(listData) ? (
<TextCustom bold align="center">
Belum ada donatur
</TextCustom>
) : (
listData?.map((item: any, index: number) => (
<BaseBox key={index}>
<Grid>
<Grid.Col
span={3}
style={{ alignItems: "center", justifyContent: "center" }}
>
<FontAwesome6
name="face-smile-wink"
size={50}
style={{ color: MainColor.yellow }}
/>
</Grid.Col>
<Grid.Col span={9}>
<TextCustom bold size="large">
{item?.Author?.username || "-"}
</TextCustom>
<Spacing/>
<StackCustom gap={"xs"}>
<TextCustom size={"small"}>Berdonas sebesar </TextCustom>
<TextCustom bold size="large" color="yellow">
Rp. {formatCurrencyDisplay(item?.nominal)}
</TextCustom>
<TextCustom>
{dayjs(item?.createdAt).format("DD MMM YYYY, HH:mm")}
</TextCustom>
</StackCustom>
</Grid.Col>
</Grid>
</BaseBox>
))
)}
</ViewWrapper>
</>
);
return <Donation_ScreenListOfDonatur donationId={id as string} />;
}

View File

@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable react-hooks/exhaustive-deps */
import { StackCustom, ViewWrapper } from "@/components";
import { BasicWrapper, StackCustom, ViewWrapper } from "@/components";
import { MainColor } from "@/constants/color-palet";
import { useAuth } from "@/hooks/use-auth";
import { useNotificationStore } from "@/hooks/use-notification-store";
@@ -29,14 +29,14 @@ export default function Application() {
checkVersion();
userData(token as string);
syncUnreadCount();
}, [user?.id, token])
}, [user?.id, token]),
);
async function onLoadData() {
const response = await apiUser(user?.id as string);
console.log(
"[Profile ID]>>",
JSON.stringify(response?.data?.Profile?.id, null, 2)
JSON.stringify(response?.data?.Profile?.id, null, 2),
);
setData(response.data);
@@ -61,12 +61,29 @@ export default function Application() {
if (data && data?.active === false) {
console.log("User is not active");
return <Redirect href={`/waiting-room`} />;
return (
<BasicWrapper>
<Redirect href={`/waiting-room`} />
</BasicWrapper>
);
}
if (data && data?.Profile === null) {
console.log("Profile is null");
return <Redirect href={`/profile/create`} />;
return (
<BasicWrapper>
<Redirect href={`/profile/create`} />
</BasicWrapper>
);
}
if (data && data?.masterUserRoleId !== "1") {
console.log("User is not admin");
return (
<BasicWrapper>
<Redirect href={`/admin/dashboard`} />
</BasicWrapper>
);
}
return (
@@ -89,7 +106,12 @@ export default function Application() {
/>
<ViewWrapper
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={MainColor.yellow}
colors={[MainColor.yellow]}
/>
}
footerComponent={
<TabSection

View File

@@ -8,6 +8,8 @@ import {
} from "@/components";
import DrawerAdmin from "@/components/Drawer/DrawerAdmin";
import NavbarMenu from "@/components/Drawer/NavbarMenu";
import NavbarMenu_V2 from "@/components/Drawer/NavbarMenu_V2";
import NavbarMenu_V3 from "@/components/Drawer/NavbarMenu_V3";
import { AccentColor, MainColor } from "@/constants/color-palet";
import {
ICON_SIZE_MEDIUM,
@@ -20,6 +22,10 @@ import {
adminListMenu,
superAdminListMenu,
} from "@/screens/Admin/listPageAdmin";
import {
adminListMenu_V2,
superAdminListMenu_V2,
} from "@/screens/Admin/listPageAdmin_V2";
import { GStyles } from "@/styles/global-styles";
import { FontAwesome6, Ionicons } from "@expo/vector-icons";
import { router, Stack } from "expo-router";
@@ -148,6 +154,24 @@ export default function AdminLayout() {
}
onClose={() => setOpenDrawerNavbar(false)}
/>
{/* <NavbarMenu_V2
items={
user?.masterUserRoleId === "2"
? adminListMenu_V2
: superAdminListMenu_V2
}
onClose={() => setOpenDrawerNavbar(false)}
/> */}
{/* <NavbarMenu_V3
items={
user?.masterUserRoleId === "2"
? adminListMenu_V2
: superAdminListMenu_V2
}
onClose={() => setOpenDrawerNavbar(false)}
/> */}
</StackCustom>
</DrawerAdmin>
@@ -198,7 +222,7 @@ export default function AdminLayout() {
// size={ICON_SIZE_SMALL}
// color={MainColor.white}
// />
<AdminNotificationBell/>
<AdminNotificationBell />
),
path: "/admin/notification",
},

View File

@@ -1,135 +1,5 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
ActionIcon,
BaseBox,
CenterCustom,
LoaderCustom,
Spacing,
StackCustom,
TextCustom,
ViewWrapper,
} from "@/components";
import { IconEdit } from "@/components/_Icon";
import AdminBackButtonAntTitle from "@/components/_ShareComponent/Admin/BackButtonAntTitle";
import { GridSpan_NewComponent } from "@/components/_ShareComponent/GridSpan_NewComponent";
import { MainColor } from "@/constants/color-palet";
import { apiAdminMasterBusinessFieldById } from "@/service/api-admin/api-master-admin";
import { router, useFocusEffect, useLocalSearchParams } from "expo-router";
import { useCallback, useState } from "react";
import { Divider } from "react-native-paper";
import { Admin_ScreenBusinessFieldDetail } from "@/screens/Admin/App-Information/ScreenBusinessFieldDetail";
export default function AdminAppInformation_BusinessFieldDetail() {
const { id } = useLocalSearchParams();
const [data, setData] = useState<any | null>(null);
const [isLoading, setIsLoading] = useState(false);
useFocusEffect(
useCallback(() => {
onLoadDetail();
}, [id])
);
const onLoadDetail = async () => {
try {
const response = await apiAdminMasterBusinessFieldById({
id: id as string,
category: "all",
});
console.log("Response >>", JSON.stringify(response, null, 2));
setData(response.data);
} catch (error) {
console.log("[ERROR]", error);
setData(null);
}
};
return (
<>
<ViewWrapper>
<StackCustom>
<AdminBackButtonAntTitle title="Detail Bidang & Sub Bidang" />
{!data ? (
<LoaderCustom />
) : (
<StackCustom gap={"xs"}>
<TextCustom bold>Nama Bidang</TextCustom>
<Spacing height={5} />
<BaseBox>
<StackCustom gap={"xs"}>
<TextCustom bold>
Status: {data?.bidang?.active ? "Aktif" : "Tidak Aktif"}
</TextCustom>
<GridSpan_NewComponent
span1={10}
span2={2}
text1={
<TextCustom bold size={"large"}>
{data?.bidang?.name}
</TextCustom>
}
text2={
<CenterCustom>
<ActionIcon
icon={<IconEdit size={16} color={MainColor.black} />}
onPress={() =>
router.push(
`/admin/app-information/business-field/${id}/bidang-update`
)
}
/>
</CenterCustom>
}
/>
</StackCustom>
</BaseBox>
{/* <Divider /> */}
<Spacing height={5} />
<TextCustom bold>Sub Bidang Bisnis</TextCustom>
<Spacing height={5} />
{data?.subBidang?.map((item: any, index: number) => (
<BaseBox key={index}>
<StackCustom gap={0}>
<TextCustom bold>
Status: {item?.isActive ? "Aktif" : "Tidak Aktif"}
</TextCustom>
<GridSpan_NewComponent
span1={10}
span2={2}
text1={
<TextCustom bold size={"large"}>
{item.name}
</TextCustom>
}
text2={
<CenterCustom>
<ActionIcon
icon={
<IconEdit size={16} color={MainColor.black} />
}
onPress={() =>
router.push(
`/admin/app-information/business-field/${item?.id}/sub-bidang-update`
)
}
/>
</CenterCustom>
}
/>
</StackCustom>
</BaseBox>
))}
</StackCustom>
)}
{/* <TextCustom>{JSON.stringify(data, null, 2)}</TextCustom> */}
</StackCustom>
</ViewWrapper>
</>
);
return <Admin_ScreenBusinessFieldDetail />;
}

View File

@@ -1,86 +1,5 @@
import { ScrollableCustom, StackCustom, ViewWrapper } from "@/components";
import AdminActionIconPlus from "@/components/_ShareComponent/Admin/ActionIconPlus";
import AdminComp_BoxTitle from "@/components/_ShareComponent/Admin/BoxTitlePage";
import AdminAppInformation_BusinessFieldSection from "@/screens/Admin/App-Information/BusinessFieldSection";
import AdminAppInformation_Bank from "@/screens/Admin/App-Information/InformationBankSection";
import AdminAppInformation_StickerSection from "@/screens/Admin/App-Information/StickerSection";
import { router } from "expo-router";
import { useState } from "react";
import { Alert } from "react-native";
import { Admin_ScreenAppInformation } from "@/screens/Admin/App-Information/ScreenAppInformation";
export default function AdminInformation() {
const [activeCategory, setActiveCategory] = useState<string | null>("bank");
const [activePage, setActivePage] = useState<string>("Informasi Bank");
const handlePress = (item: any) => {
setActiveCategory(item.value);
setActivePage(item.label);
// tambahkan logika lain seperti filter dsb.
};
const scrollComponent = (
<StackCustom>
<ScrollableCustom
data={listPage}
onButtonPress={handlePress}
activeId={activeCategory as any}
/>
</StackCustom>
);
const renderContent = () => {
switch (activeCategory) {
case "bank":
return <AdminAppInformation_Bank />;
case "business":
return <AdminAppInformation_BusinessFieldSection />;
case "sticker":
return <AdminAppInformation_StickerSection />;
default:
return <AdminAppInformation_Bank />;
}
};
return (
<>
<ViewWrapper headerComponent={scrollComponent}>
<AdminComp_BoxTitle
title={activePage}
rightComponent={
<AdminActionIconPlus
onPress={() => {
if (activeCategory === "bank") {
router.push("/admin/app-information/information-bank/create");
} else if (activeCategory === "business") {
router.push("/admin/app-information/business-field/create");
} else if (activeCategory === "sticker") {
Alert.alert("Coming Soon", "Next Update");
// router.push("/admin/app-information/sticker/create");
}
}}
/>
}
/>
{renderContent()}
</ViewWrapper>
</>
);
return <Admin_ScreenAppInformation />;
}
const listPage = [
{
id: "1",
label: "Informasi Bank",
value: "bank",
},
{
id: "2",
label: "Bidang & Sub Bidang",
value: "business",
},
{
id: "3",
label: "Stiker",
value: "sticker",
},
];

View File

@@ -1,139 +1,5 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
BadgeCustom,
CenterCustom,
Divider,
SearchInput,
StackCustom,
TextCustom,
ViewWrapper,
} from "@/components";
import AdminComp_BoxTitle from "@/components/_ShareComponent/Admin/BoxTitlePage";
import { GridViewCustomSpan } from "@/components/_ShareComponent/GridViewCustomSpan";
import { MainColor } from "@/constants/color-palet";
import { ICON_SIZE_XLARGE } from "@/constants/constans-value";
import { apiAdminUserAccessGetAll } from "@/service/api-admin/api-admin-user-access";
import { Ionicons } from "@expo/vector-icons";
import { router, useFocusEffect } from "expo-router";
import _ from "lodash";
import { useCallback, useState } from "react";
import { Admin_ScreenUserAccess } from "@/screens/Admin/User-Access/ScreenUserAccess";
export default function AdminUserAccess() {
const [listData, setListData] = useState<any[] | null>(null);
const [search, setSearch] = useState("");
useFocusEffect(
useCallback(() => {
onLoadData();
}, [search])
);
const onLoadData = async () => {
try {
const response = await apiAdminUserAccessGetAll({
search: search,
category: "only-user",
});
if (response.success) {
setListData(response.data);
}
} catch (error) {
console.log("[ERROR LOAD DATA]", error);
}
};
const rightComponent = () => {
return (
<>
<SearchInput
containerStyle={{ width: "100%", marginBottom: 0 }}
placeholder="Cari User"
onChangeText={(text) => setSearch(text)}
/>
</>
);
};
return (
<>
<ViewWrapper
headerComponent={
<AdminComp_BoxTitle
title="User Access"
rightComponent={rightComponent()}
/>
}
>
<GridViewCustomSpan
span1={2}
span2={5}
span3={5}
component1={
<TextCustom align="center" bold>
Aksi
</TextCustom>
}
component2={<TextCustom bold>Username</TextCustom>}
component3={
<TextCustom align="center" bold>
Status Akses
</TextCustom>
}
/>
<Divider />
<StackCustom>
{_.isEmpty(listData) ? (
<TextCustom align="center" color="gray" size={"small"}>
Tidak ada data
</TextCustom>
) : (
listData?.map((item: any, index: number) => (
<GridViewCustomSpan
key={index}
span1={2}
span2={5}
span3={5}
component1={
<CenterCustom>
<Ionicons
onPress={() =>
router.push(`/admin/user-access/${item?.id}`)
}
name="open"
size={ICON_SIZE_XLARGE}
color={MainColor.yellow}
/>
</CenterCustom>
// <ButtonCustom
// onPress={() =>
// router.push(`/admin/user-access/${item?.id}`)
// }
// >
// Detail
// </ButtonCustom>
}
component2={
<TextCustom bold truncate>
{item?.username || "-"}
</TextCustom>
}
component3={
<CenterCustom>
{item?.active ? (
<BadgeCustom color="green">Aktif</BadgeCustom>
) : (
<BadgeCustom color="red">Tidak Aktif</BadgeCustom>
)}
</CenterCustom>
}
style3={{ alignItems: "center", justifyContent: "center" }}
/>
))
)}
</StackCustom>
</ViewWrapper>
</>
);
return <Admin_ScreenUserAccess />;
}

View File

@@ -0,0 +1,276 @@
import { AccentColor, MainColor } from "@/constants/color-palet";
import { Ionicons } from "@expo/vector-icons";
import { router, usePathname } from "expo-router";
import React, { useEffect, useRef, useState } from "react";
import {
Animated,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
export interface NavbarItem {
label: string;
icon?: keyof typeof Ionicons.glyphMap;
color?: string;
link?: string;
links?: {
label: string;
link: string;
}[];
initiallyOpened?: boolean;
}
interface NavbarMenuProps {
items: NavbarItem[];
onClose?: () => void;
}
export default function NavbarMenuBackup({ items, onClose }: NavbarMenuProps) {
const pathname = usePathname();
const [activeLink, setActiveLink] = useState<string | null>(null);
const [openKeys, setOpenKeys] = useState<string[]>([]); // Untuk kontrol dropdown
// Normalisasi path: hapus trailing slash
const normalizePath = (path: string) => path.replace(/\/+$/, "");
const normalizedPathname = pathname ? normalizePath(pathname) : "";
// Set activeLink saat pathname berubah
useEffect(() => {
if (normalizedPathname) {
setActiveLink(normalizedPathname);
}
}, [normalizedPathname]);
// Toggle dropdown
const toggleOpen = (label: string) => {
setOpenKeys((prev) =>
prev.includes(label) ? prev.filter((key) => key !== label) : [label]
);
};
return (
<View
style={{
// flex: 1,
// backgroundColor: MainColor.black,
marginBottom: 20,
}}
>
<ScrollView
contentContainerStyle={{
paddingVertical: 10, // Opsional: tambahkan padding
}}
// showsVerticalScrollIndicator={false} // Opsional: sembunyikan indikator scroll
>
{items.map((item) => (
<MenuItem
key={item.label}
item={item}
onClose={onClose}
activeLink={activeLink}
setActiveLink={setActiveLink}
isOpen={openKeys.includes(item.label)}
toggleOpen={() => toggleOpen(item.label)}
/>
))}
</ScrollView>
</View>
);
}
// Komponen Item Menu
function MenuItem({
item,
onClose,
activeLink,
setActiveLink,
isOpen,
toggleOpen,
}: {
item: NavbarItem;
onClose?: () => void;
activeLink: string | null;
setActiveLink: (link: string | null) => void;
isOpen: boolean;
toggleOpen: () => void;
}) {
const isActive = activeLink === item.link;
const animatedHeight = useRef(new Animated.Value(0)).current;
// Animasi saat isOpen berubah
React.useEffect(() => {
Animated.timing(animatedHeight, {
toValue: isOpen ? (item.links ? item.links.length * 40 : 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} onPress={toggleOpen}>
<Ionicons
name={item.icon}
size={16}
color={MainColor.white}
style={styles.icon}
/>
<Text style={styles.parentText}>{item.label}</Text>
<Ionicons
name={isOpen ? "chevron-up" : "chevron-down"}
size={16}
color={MainColor.white}
/>
</TouchableOpacity>
{/* Submenu (Animated) */}
<Animated.View
style={[
styles.submenu,
// {
// backgroundColor: "red",
// },
{
height: animatedHeight,
opacity: animatedHeight.interpolate({
inputRange: [0, item.links.length * 40],
outputRange: [0, 1],
extrapolate: "clamp",
}),
},
]}
>
{item.links.map((subItem, index) => {
const isSubActive = activeLink === subItem.link;
return (
<TouchableOpacity
key={index}
style={[styles.subItem, isSubActive && styles.subItemActive]}
onPress={() => {
setActiveLink(subItem.link);
onClose?.();
router.push(subItem.link as any);
}}
>
<Ionicons
name="radio-button-on-outline"
size={16}
color={isSubActive ? MainColor.yellow : MainColor.white}
style={styles.icon}
/>
<Text
style={[
styles.subText,
isSubActive && { color: MainColor.yellow },
]}
>
{subItem.label}
</Text>
</TouchableOpacity>
);
})}
</Animated.View>
</View>
);
}
// Menu tanpa submenu
return (
<TouchableOpacity
style={[styles.singleItem, isActive && styles.singleItemActive]}
onPress={() => {
setActiveLink(item.link || null);
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,
// backgroundColor: AccentColor.darkblue,
borderRadius: 8,
marginBottom: 5,
justifyContent: "space-between",
},
parentText: {
flex: 1,
fontSize: 16,
fontWeight: "500",
marginLeft: 10,
color: MainColor.white,
},
singleItem: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 12,
paddingHorizontal: 10,
// backgroundColor: AccentColor.darkblue,
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",
},
});

View File

@@ -28,7 +28,7 @@ interface NavbarMenuProps {
onClose?: () => void;
}
export default function NavbarMenu({ items, onClose }: NavbarMenuProps) {
export default function NavbarMenuBackup({ items, onClose }: NavbarMenuProps) {
const pathname = usePathname();
const [activeLink, setActiveLink] = useState<string | null>(null);
const [openKeys, setOpenKeys] = useState<string[]>([]); // Untuk kontrol dropdown
@@ -41,13 +41,41 @@ export default function NavbarMenu({ items, onClose }: NavbarMenuProps) {
useEffect(() => {
if (normalizedPathname) {
setActiveLink(normalizedPathname);
// Temukan menu induk yang sesuai dengan path saat ini dan buka dropdown-nya
for (const item of items) {
// Cocokkan dengan link langsung
if (item.link && normalizedPathname.startsWith(item.link)) {
setOpenKeys(prev => {
if (!prev.includes(item.label)) {
return [...prev, item.label];
}
return prev;
});
break; // Hentikan loop setelah menemukan kecocokan pertama
}
// Cocokkan dengan submenu
if (item.links && item.links.length > 0) {
const matchingSubItem = item.links.find(link => normalizedPathname.startsWith(link.link));
if (matchingSubItem) {
setOpenKeys(prev => {
if (!prev.includes(item.label)) {
return [...prev, item.label];
}
return prev;
});
break; // Hentikan loop setelah menemukan kecocokan pertama
}
}
}
}
}, [normalizedPathname]);
}, [normalizedPathname, items]);
// Toggle dropdown
const toggleOpen = (label: string) => {
setOpenKeys((prev) =>
prev.includes(label) ? prev.filter((key) => key !== label) : [label]
prev.includes(label) ? prev.filter((key) => key !== label) : [...prev, label]
);
};
@@ -97,35 +125,71 @@ function MenuItem({
isOpen: boolean;
toggleOpen: () => void;
}) {
// Cek apakah menu ini atau submenu-nya yang aktif
const isActive = activeLink === item.link;
// Cek apakah path saat ini cocok dengan salah satu submenu
const isSubmenuActive = item.links && item.links.some(subItem => activeLink === subItem.link);
// Cek apakah path saat ini adalah detail dari submenu ini (misalnya /admin/event/123/detail)
const isDetailPageOfThisMenu = item.links && item.links.length > 0 && activeLink &&
item.links.some(link => {
const linkPath = link.link.replace(/\/+$/, "");
return activeLink.startsWith(linkPath + "/");
});
// Gabungkan status aktif untuk menentukan apakah menu ini harus aktif
const isMenuActive = isActive || isSubmenuActive || isDetailPageOfThisMenu;
const animatedHeight = useRef(new Animated.Value(0)).current;
// Animasi saat isOpen berubah
React.useEffect(() => {
// Jika ini adalah halaman detail dari menu ini, buka dropdown secara otomatis
const shouldAutoOpen = isDetailPageOfThisMenu && !isOpen;
Animated.timing(animatedHeight, {
toValue: isOpen ? (item.links ? item.links.length * 40 : 0) : 0,
toValue: (isOpen || isDetailPageOfThisMenu) ? (item.links ? item.links.length * 40 : 0) : 0,
duration: 200,
useNativeDriver: false,
}).start();
}, [isOpen, item.links, animatedHeight]);
// Jika perlu membuka dropdown otomatis, panggil toggleOpen
if (shouldAutoOpen) {
toggleOpen();
}
}, [isOpen, item.links, animatedHeight, isDetailPageOfThisMenu, toggleOpen]);
// Jika ada submenu
if (item.links && item.links.length > 0) {
return (
<View>
{/* Parent Item */}
<TouchableOpacity style={styles.parentItem} onPress={toggleOpen}>
<TouchableOpacity
style={[
styles.parentItem,
isMenuActive && styles.parentItemActive,
]}
onPress={toggleOpen}
>
<Ionicons
name={item.icon}
size={16}
color={MainColor.white}
color={isMenuActive ? MainColor.yellow : MainColor.white}
style={styles.icon}
/>
<Text style={styles.parentText}>{item.label}</Text>
<Text
style={[
styles.parentText,
isMenuActive && { color: MainColor.yellow },
]}
>
{item.label}
</Text>
<Ionicons
name={isOpen ? "chevron-up" : "chevron-down"}
size={16}
color={MainColor.white}
color={isMenuActive ? MainColor.yellow : MainColor.white}
/>
</TouchableOpacity>
@@ -222,6 +286,9 @@ const styles = StyleSheet.create({
marginBottom: 5,
justifyContent: "space-between",
},
parentItemActive: {
backgroundColor: AccentColor.blue,
},
parentText: {
flex: 1,
fontSize: 16,

View File

@@ -0,0 +1,550 @@
import { AccentColor, MainColor } from "@/constants/color-palet";
import { Ionicons } from "@expo/vector-icons";
import { router, usePathname } from "expo-router";
import React, { 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; // NEW: Pattern untuk match detail pages
}[];
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 = [
// Actions
'detail', 'edit', 'create', 'new', 'add', 'delete', 'view',
// Status types
'publish', 'review', 'reject', 'status', 'active', 'inactive', 'pending',
// General pages
'category', 'history', 'dashboard', 'index',
// Event specific
'type-of-event', 'type-create', 'type-update',
// Forum specific
'posting', 'report-posting', 'report-comment',
// Collaboration
'group',
// App Information
'business-field', 'information-bank', 'sticker',
'bidang-update', 'sub-bidang-update',
// Transaction/Finance related
'transaction-detail', 'transaction', 'payment',
'disbursement-of-funds', 'detail-disbursement-of-funds',
'list-disbursement-of-funds',
// List pages (CRITICAL!)
'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 => {
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('transaction-detail') || currentPath.includes('disbursement')) {
console.log('🔍 Pattern Match Check:', {
currentPath,
detailPattern,
regex: patternRegex.toString(),
isMatch
});
}
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 = [
// Actions
'detail', 'edit', 'create', 'new', 'add', 'delete', 'view',
// Status types
'publish', 'review', 'reject', 'status', 'active', 'inactive', 'pending',
// General pages
'category', 'history', 'dashboard', 'index',
// Event specific
'type-of-event', 'type-create', 'type-update',
// Forum specific
'posting', 'report-posting', 'report-comment',
// Collaboration
'group',
// App Information
'business-field', 'information-bank', 'sticker',
'bidang-update', 'sub-bidang-update',
// Transaction/Finance related
'transaction-detail', 'transaction', 'payment',
'disbursement-of-funds', 'detail-disbursement-of-funds',
'list-disbursement-of-funds',
// List pages (CRITICAL!)
'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 => {
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) {
// 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 (
<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",
}),
},
]}
>
{submenuActiveStates.map(({ subItem, isActive: isSubActive, pathLength }, index) => {
// CRITICAL FIX: Cek apakah ada submenu lain yang LEBIH PANJANG dan juga aktif
const hasMoreSpecificMatch = submenuActiveStates.some(other => {
if (other.subItem.link === subItem.link) return false; // Skip self
const isOtherLonger = other.pathLength > pathLength;
// Debug log untuk Dashboard
if (subItem.label === "Dashboard" && isSubActive) {
console.log(`🔎 Dashboard checking against ${other.subItem.label}:`, {
dashboardLink: subItem.link,
dashboardLength: pathLength,
otherLabel: other.subItem.label,
otherLink: other.subItem.link,
otherPattern: other.subItem.detailPattern,
otherLength: other.pathLength,
otherIsActive: other.isActive,
isOtherLonger,
willDisableDashboard: other.isActive && isOtherLonger,
currentURL: currentPath
});
}
// Conflict log
if (isSubActive && other.isActive) {
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
const finalIsActive = isSubActive && !hasMoreSpecificMatch;
// Debug final
if (isSubActive) {
console.log('✅ Active check:', {
label: subItem.label,
link: subItem.link,
isSubActive,
hasMoreSpecificMatch,
finalIsActive
});
}
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",
},
});

View File

@@ -0,0 +1,871 @@
import { AccentColor, MainColor } from "@/constants/color-palet";
import { Ionicons } from "@expo/vector-icons";
import { router, usePathname } from "expo-router";
import React, { useEffect, useRef, useState } from "react";
import {
Animated,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
export interface NavbarItem_V3 {
label: string;
icon?: keyof typeof Ionicons.glyphMap;
color?: string;
link?: string;
links?: {
label: string;
link: string;
detailPattern?: string; // NEW: Pattern untuk match detail pages
}[];
initiallyOpened?: boolean;
}
interface NavbarMenuProps {
items: NavbarItem_V3[];
onClose?: () => void;
}
export default function NavbarMenu_V3({ 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 = [
// Actions
'detail', 'edit', 'create', 'new', 'add', 'delete', 'view',
// Status types
'publish', 'review', 'reject', 'status', 'active', 'inactive', 'pending',
// General pages
'category', 'history', 'dashboard', 'index',
// Event specific
'type-of-event', 'type-create', 'type-update',
// Forum specific
'posting', 'report-posting', 'report-comment',
// Collaboration
'group',
// App Information
'business-field', 'information-bank', 'sticker',
'bidang-update', 'sub-bidang-update',
// Transaction/Finance related
'transaction-detail', 'transaction', 'payment',
'disbursement-of-funds', 'detail-disbursement-of-funds',
'list-disbursement-of-funds',
// List pages (CRITICAL!)
'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 => {
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;
};
// Calculate all potential matches for conflict resolution
const allMatches = items.flatMap(item => {
if (!item.links || item.links.length === 0) return [];
return item.links
.filter(subItem => checkPathMatch(subItem.link, subItem.detailPattern))
.map(subItem => ({
parentLabel: item.label,
subItem,
pathLength: subItem.link.length
}));
});
// Find the most specific match for each parent
const uniqueParents = new Map<string, { parentLabel: string, longestPathLength: number }>();
allMatches.forEach(match => {
const existing = uniqueParents.get(match.parentLabel);
if (!existing || match.pathLength > existing.longestPathLength) {
uniqueParents.set(match.parentLabel, {
parentLabel: match.parentLabel,
longestPathLength: match.pathLength
});
}
});
// Add only the parents with the most specific matches
newOpenKeys.push(...Array.from(uniqueParents.values()).map(item => item.parentLabel));
// Additionally, if no specific submenu match was found but the current path
// starts with one of the parent menu links, add that parent
if (newOpenKeys.length === 0) {
// Find the parent whose link is the longest prefix of the current path
let longestMatchParent = null;
let longestMatchLength = 0;
items.forEach(item => {
if (item.links && item.links.length > 0) {
item.links.forEach(link => {
const linkPath = link.link.replace(/\/+$/, "");
if (normalizedPathname.startsWith(linkPath + "/") && linkPath.length > longestMatchLength) {
longestMatchLength = linkPath.length;
longestMatchParent = item.label;
}
});
}
});
if (longestMatchParent) {
newOpenKeys.push(longestMatchParent);
}
}
// NEW: Check if user is on a detail page (contains ID segments or specific keywords)
const isOnDetailPage = (() => {
// Check if current path has ID-like segments or detail keywords
const segments = normalizedPathname.split('/').filter(s => s.length > 0);
if (segments.length === 0) return false;
const commonWords = [
// Actions
'detail', 'edit', 'create', 'new', 'add', 'delete', 'view',
// Status types
'publish', 'review', 'reject', 'status', 'active', 'inactive', 'pending',
// General pages
'category', 'history', 'dashboard', 'index',
// Event specific
'type-of-event', 'type-create', 'type-update',
// Forum specific
'posting', 'report-posting', 'report-comment',
// Collaboration
'group',
// App Information
'business-field', 'information-bank', 'sticker',
'bidang-update', 'sub-bidang-update',
// Transaction/Finance related
'transaction-detail', 'transaction', 'payment',
'disbursement-of-funds', 'detail-disbursement-of-funds',
'list-disbursement-of-funds',
// List pages (CRITICAL!)
'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 => {
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;
})();
// NEW: Check if user is on a detail page (contains ID segments or specific keywords)
const isOnDetailPageGlobal = (() => {
// Check if current path has ID-like segments or detail keywords
const segments = normalizedPathname.split('/').filter(s => s.length > 0);
if (segments.length === 0) return false;
const commonWords = [
// Actions
'detail', 'edit', 'create', 'new', 'add', 'delete', 'view',
// Status types
'publish', 'review', 'reject', 'status', 'active', 'inactive', 'pending',
// General pages
'category', 'history', 'dashboard', 'index',
// Event specific
'type-of-event', 'type-create', 'type-update',
// Forum specific
'posting', 'report-posting', 'report-comment',
// Collaboration
'group',
// App Information
'business-field', 'information-bank', 'sticker',
'bidang-update', 'sub-bidang-update',
// Transaction/Finance related
'transaction-detail', 'transaction', 'payment',
'disbursement-of-funds', 'detail-disbursement-of-funds',
'list-disbursement-of-funds',
// List pages (CRITICAL!)
'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'
];
// Check if any segment is a common word
const hasCommonWord = segments.some(segment => commonWords.includes(segment.toLowerCase()));
// Check if any segment looks like an ID (number, UUID, alphanumeric with numbers)
const hasIdSegment = segments.some(segment => {
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;
});
// A detail page is one that has either common words or ID segments
return hasCommonWord || hasIdSegment;
})();
// NEW: Only open parent menu if the current path is a detail page of the most relevant parent
if (isOnDetailPageGlobal && newOpenKeys.length === 0) {
// Find the parent whose link is the longest prefix of the current path
let longestMatchParent = null;
let longestMatchLength = 0;
items.forEach(item => {
if (item.links && item.links.length > 0) {
item.links.forEach(link => {
const linkPath = link.link.replace(/\/+$/, "");
if (normalizedPathname.startsWith(linkPath + "/") && linkPath.length > longestMatchLength) {
longestMatchLength = linkPath.length;
longestMatchParent = item.label;
}
});
}
});
if (longestMatchParent) {
newOpenKeys.push(longestMatchParent);
}
}
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}
items={items}
onClose={onClose}
currentPath={normalizedPathname}
isOpen={openKeys.includes(item.label)}
toggleOpen={() => toggleOpen(item.label)}
/>
))
) : null}
</ScrollView>
</View>
);
}
// Komponen Item Menu
function MenuItem({
item,
items,
onClose,
currentPath,
isOpen,
toggleOpen,
}: {
item: NavbarItem_V3;
items: NavbarItem_V3[];
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('transaction-detail') || currentPath.includes('disbursement')) {
// console.log('🔍 Pattern Match Check:', {
// currentPath,
// detailPattern,
// regex: patternRegex.toString(),
// isMatch
// });
// }
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 = [
// Actions
'detail', 'edit', 'create', 'new', 'add', 'delete', 'view',
// Status types
'publish', 'review', 'reject', 'status', 'active', 'inactive', 'pending',
// General pages
'category', 'history', 'index',
// Event specific
'type-of-event', 'type-create', 'type-update',
// Forum specific
'posting', 'report-posting', 'report-comment',
// Collaboration
'group',
// App Information
'business-field', 'information-bank', 'sticker',
'bidang-update', 'sub-bidang-update',
// Transaction/Finance related
'transaction-detail', 'transaction', 'payment',
'disbursement-of-funds', 'detail-disbursement-of-funds',
'list-disbursement-of-funds',
// List pages (CRITICAL!)
'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 hasCommonWord = segments.some(segment =>
commonWords.includes(segment.toLowerCase())
);
// Hanya anggap sebagai detail page jika mengandung commonWords
return hasCommonWord;
}
return false;
};
// Check apakah menu item ini atau submenu-nya yang aktif
const isActive = isPathActive(item.link);
// NEW LOGIC: Check if user is on a detail page (contains ID segments or specific keywords)
const isOnDetailPage = (() => {
// Check if current path has ID-like segments or detail keywords
const segments = currentPath.split('/').filter(s => s.length > 0);
if (segments.length === 0) return false;
const commonWords = [
// Actions
'detail', 'edit', 'create', 'new', 'add', 'delete', 'view',
// Status types
'publish', 'review', 'reject', 'status', 'active', 'inactive', 'pending',
// General pages
'category', 'history', 'dashboard', 'index',
// Event specific
'type-of-event', 'type-create', 'type-update',
// Forum specific
'posting', 'report-posting', 'report-comment',
// Collaboration
'group',
// App Information
'business-field', 'information-bank', 'sticker',
'bidang-update', 'sub-bidang-update',
// Transaction/Finance related
'transaction-detail', 'transaction', 'payment',
'disbursement-of-funds', 'detail-disbursement-of-funds',
'list-disbursement-of-funds',
// List pages (CRITICAL!)
'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'
];
// Check if any segment is a common word
const hasCommonWord = segments.some(segment => commonWords.includes(segment.toLowerCase()));
// Check if any segment looks like an ID (number, UUID, alphanumeric with numbers)
const hasIdSegment = segments.some(segment => {
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;
});
// A detail page is one that has either common words or ID segments
return hasCommonWord || hasIdSegment;
})();
// Calculate all submenu active states for conflict resolution
const submenuActiveStates = item.links?.map(subItem => ({
subItem,
isActive: isPathActive(subItem.link, subItem.detailPattern),
pathLength: subItem.link.length
})) || [];
// Determine if any submenu is active considering conflicts
const hasActiveSubmenu = submenuActiveStates.some(({ isActive: isSubActive, pathLength, subItem }) => {
if (!isSubActive) return false;
// Check if there's a more specific match elsewhere
const hasMoreSpecificMatch = submenuActiveStates.some(other => {
if (other.subItem.link === subItem.link) return false; // Skip self
return other.isActive && other.pathLength > pathLength;
});
return isSubActive && !hasMoreSpecificMatch;
}) || false;
// For parent menu detection, if current path contains common words,
// check if this parent menu's link is a prefix of the current path
const isParentOfDetailPage = !isActive && !hasActiveSubmenu && item.links && item.links.length > 0 &&
item.links.some(link => currentPath.startsWith(link.link.replace(/\/+$/, "") + "/"));
// Determine if this is the most relevant parent menu for the current path
const isMostRelevantParent = isParentOfDetailPage && (() => {
let longestMatchLength = 0;
let mostRelevantParent = null;
// Find the parent with the longest matching prefix
items.forEach(parentItem => {
if (parentItem.links && parentItem.links.length > 0) {
parentItem.links.forEach(link => {
const linkPath = link.link.replace(/\/+$/, "");
if (currentPath.startsWith(linkPath + "/") && linkPath.length > longestMatchLength) {
longestMatchLength = linkPath.length;
mostRelevantParent = parentItem.label;
}
});
}
});
return mostRelevantParent === item.label;
})();
// NEW LOGIC: If we're on a detail page, NO submenu should be active regardless of pattern matching
const hasActiveSubmenuOnDetailPage = isOnDetailPage ? false : hasActiveSubmenu;
// NEW LOGIC: If user is on a detail page that belongs to this parent menu,
// activate only the parent menu (open dropdown) without activating any submenu
const isDetailPageOfThisMenu = !isActive && !hasActiveSubmenuOnDetailPage &&
item.links && item.links.length > 0 &&
item.links.some(link => {
const linkPath = link.link.replace(/\/+$/, "");
return currentPath.startsWith(linkPath + "/");
}) &&
!isMostRelevantParent; // Only apply this logic if this isn't the most relevant parent
// NEW LOGIC: Check if this is a page that doesn't belong to any specific menu in the navbar
const isUnlistedPage = !isActive && !hasActiveSubmenu && !isMostRelevantParent && !isDetailPageOfThisMenu && isOnDetailPage;
// NEW LOGIC: If we're on a detail page and this menu is not the relevant parent or detail page owner,
// then it should not be highlighted even if it would normally be the most relevant
const isOnDetailPageAndNotRelevant = isOnDetailPage && !isMostRelevantParent && !isDetailPageOfThisMenu && !isActive;
// NEW LOGIC: If this is an unlisted page, no menu should be highlighted
const isUnlistedPageAndNotRelevant = isUnlistedPage;
// FINAL LOGIC: Only activate this menu if:
// 1. It's the exact match for current path, OR
// 2. It's the most relevant parent, OR
// 3. It's a detail page of this menu
// But NOT if we're on a detail page and this isn't the relevant parent
// And NOT if this is an unlisted page
const isActuallyRelevant = (isActive || isMostRelevantParent || isDetailPageOfThisMenu) && !isOnDetailPageAndNotRelevant && !isUnlistedPageAndNotRelevant;
// Animasi saat isOpen berubah
useEffect(() => {
Animated.timing(animatedHeight, {
toValue: (isOpen || isDetailPageOfThisMenu) ? (item.links ? item.links.length * 44 : 0) : 0,
duration: 200,
useNativeDriver: false,
}).start();
}, [isOpen, item.links, animatedHeight, isDetailPageOfThisMenu]);
// Jika ada submenu
if (item.links && item.links.length > 0) {
return (
<View>
{/* Parent Item */}
<TouchableOpacity
style={[
styles.parentItem,
isActuallyRelevant && styles.parentItemActive,
]}
onPress={toggleOpen}
>
<Ionicons
name={item.icon}
size={16}
color={isActuallyRelevant ? MainColor.yellow : MainColor.white}
style={styles.icon}
/>
<Text
style={[
styles.parentText,
isActuallyRelevant && { color: MainColor.yellow },
]}
>
{item.label}
</Text>
<Ionicons
name={isOpen ? "chevron-up" : "chevron-down"}
size={16}
color={isActuallyRelevant ? 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",
}),
},
]}
>
{submenuActiveStates.map(({ subItem, isActive: isSubActive, pathLength }, index) => {
// CRITICAL FIX: Cek apakah ada submenu lain yang LEBIH PANJANG dan juga aktif
const hasMoreSpecificMatch = submenuActiveStates.some(other => {
if (other.subItem.link === subItem.link) return false; // Skip self
const isOtherLonger = other.pathLength > pathLength;
// Debug log untuk Dashboard
// if (subItem.label === "Dashboard" && isSubActive) {
// console.log(`🔎 Dashboard checking against ${other.subItem.label}:`, {
// dashboardLink: subItem.link,
// dashboardLength: pathLength,
// otherLabel: other.subItem.label,
// otherLink: other.subItem.link,
// otherPattern: other.subItem.detailPattern,
// otherLength: other.pathLength,
// otherIsActive: other.isActive,
// isOtherLonger,
// willDisableDashboard: other.isActive && isOtherLonger,
// currentURL: currentPath
// });
// }
// Conflict log
// if (isSubActive && other.isActive) {
// 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
const finalIsActive = isSubActive && !hasMoreSpecificMatch;
// NEW: If this is a detail page (regardless of which menu), don't highlight any submenu items
// Also don't highlight if this is an unlisted page
const shouldHighlight = (isOnDetailPage || isUnlistedPage) ? false : finalIsActive;
// Debug final
// if (isSubActive) {
// console.log('✅ Active check:', {
// label: subItem.label,
// link: subItem.link,
// isSubActive,
// hasMoreSpecificMatch,
// finalIsActive,
// shouldHighlight,
// isOnDetailPage,
// isUnlistedPage
// });
// }
return (
<TouchableOpacity
key={index}
style={[styles.subItem, shouldHighlight && styles.subItemActive]}
onPress={() => {
onClose?.();
router.push(subItem.link as any);
}}
>
<Ionicons
name="radio-button-on-outline"
size={16}
color={shouldHighlight ? MainColor.yellow : MainColor.white}
style={styles.icon}
/>
<Text
style={[
styles.subText,
shouldHighlight && { 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",
},
});

View File

@@ -0,0 +1,20 @@
import BaseBox from "@/components/Box/BaseBox";
import TextCustom from "@/components/Text/TextCustom";
import { AccentColor } from "@/constants/color-palet";
import { StyleProp, ViewStyle } from "react-native";
interface Props {
children: React.ReactNode;
onPress?: () => void;
style?: StyleProp<ViewStyle>;
}
export default function AdminBasicBox({ children, onPress, style }: Props) {
return (
<>
<BaseBox onPress={onPress} style={style}>
{children}
</BaseBox>
</>
);
}

View File

@@ -1,7 +1,9 @@
import BaseBox from "@/components/Box/BaseBox";
import Grid from "@/components/Grid/GridCustom";
import TextCustom from "@/components/Text/TextCustom";
import { AccentColor } from "@/constants/color-palet";
import { TEXT_SIZE_LARGE } from "@/constants/constans-value";
import { View } from "react-native";
export default function AdminComp_BoxTitle({
title,
@@ -12,13 +14,33 @@ export default function AdminComp_BoxTitle({
}) {
return (
<>
<BaseBox
{/* <BaseBox
style={{ flexDirection: "row", justifyContent: "space-between" }}
paddingTop={5}
paddingBottom={5}
backgroundColor={AccentColor.blue}
> */}
<View
style={{
backgroundColor: AccentColor.darkblue,
borderColor: AccentColor.blue,
paddingBlock: 5,
paddingInline: 10,
borderWidth: 1,
borderRadius: 10,
}}
>
<Grid>
<Grid.Col span={rightComponent ? 6 : 12} style={{ justifyContent: "center" }}>
<Grid
// containerStyle={{
// bottom: 0,
// left: 0,
// right: 0,
// }}
>
<Grid.Col
span={rightComponent ? 6 : 12}
style={{ justifyContent: "center" }}
>
<TextCustom
// style={{ alignSelf: "center" }}
bold
@@ -39,7 +61,8 @@ export default function AdminComp_BoxTitle({
</Grid.Col>
)}
</Grid>
</BaseBox>
</View>
{/* </BaseBox> */}
</>
);
}

View File

@@ -0,0 +1,16 @@
import { MainColor } from "@/constants/color-palet";
import { View } from "react-native";
export default function BasicWrapper({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<View style={{ flex: 1, backgroundColor: MainColor.darkblue }}>
{children}
</View>
</>
);
}

View File

@@ -60,6 +60,7 @@ import SearchInput from "./_ShareComponent/SearchInput";
import DummyLandscapeImage from "./_ShareComponent/DummyLandscapeImage";
import GridComponentView from "./_ShareComponent/GridSectionView";
import NewWrapper from "./_ShareComponent/NewWrapper";
import BasicWrapper from "./_ShareComponent/BasicWrapper";
// Progress
import ProgressCustom from "./Progress/ProgressCustom";
// Loader
@@ -121,6 +122,7 @@ export {
GridComponentView,
Spacing,
NewWrapper,
BasicWrapper,
// Stack
StackCustom,
TabBarBackground,

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.

View File

@@ -1,13 +1,13 @@
<!-- ===================== Start Penerapan Pagination ===================== -->
<!-- ===================== Start Penerapan Pagination Dari Source ===================== -->
File source: app/(application)/(user)/donation/(tabs)/status.tsx
File source: app/(application)/(user)/donation/[id]/fund-disbursement.tsx
Folder tujuan: screens/Donation
Nama file utama: ScreenStatus.tsx
Nama file utama: ScreenFundDisbursement.tsx
Buat file baru pada "Folder tujuan" dengan nama "Nama file utama" dan ubah nama function menjadi "Donation_ScreenStatus" kemudian clean code, import dan panggil function tersebut pada file "File source"
Buat file baru pada "Folder tujuan" dengan nama "Nama file utama" dan ubah nama function menjadi "Donation_ScreenFundDisbursement" kemudian clean code, import dan panggil function tersebut pada file "File source"
Selanjutnya terapkan pagination pada file "Nama file utama"
Function fecth: apiDonationGetByStatus
Function fecth: apiDonationDisbursementOfFundsListById
File function fetch: service/api-client/api-donation.ts
File komponen wrapper: components/_ShareComponent/NewWrapper.tsx
@@ -22,15 +22,15 @@ Jika tidak ada props page maka tambahkan props page dan default page: "1"
Gunakan bahasa indonesia pada cli agar saya mudah membacanya.
<!-- Additional Prompt -->
File refrensi: screens/Event/ScreenStatus.tsx
File refrensi: screens/Donation/ScreenListOfNews.tsx
Anda bisa menggunakan refrensi dari "File refrensi" jika butuh pemahaman dengan tipe fitur yang sama
<!-- ===================== End Penerapan Pagination ===================== -->
<!-- ===================== End Penerapan Pagination ` ===================== -->
<!-- ===================== Start Penerapan NewWrapper ===================== -->
File utama: screens/Invesment/ScreenTransaction.tsx
Function fecth: apiInvestmentGetInvoice
File function fetch: service/api-client/api-investment.ts
<!-- ===================== Start Penerapan NewWrapper & Pagination ===================== -->
File utama: screens/Donation/ScreenFundDisbursement.tsx
Function fecth: apiDonationDisbursementOfFundsListById
File function fetch: service/api-client/api-donation.ts
File komponen wrapper: components/_ShareComponent/NewWrapper.tsx
Terapkan pagination pada file "File utama"
@@ -43,17 +43,65 @@ Jika tidak ada props page maka tambahkan props page dan default page: "1"
Gunakan bahasa indonesia pada cli agar saya mudah membacanya.
<!-- ===================== End Penerapan NewWrapper ===================== -->
<!-- Additinal prompt -->
<!-- ===================== End Penerapan NewWrapper & Pagination ===================== -->
<!-- Start Penerapan NewWrapper -->
Terapkan NewWrapper pada file: app/(application)/(user)/donation/create.tsx
Component yang digunakan: components/_ShareComponent/NewWrapper.tsx
<!-- End Penerapan NewWrapper -->
Bantu saya untuk memperbaiki logika path yang ada di dalam file "screens/Admin/Notification-Admin/ScreenNotificationAdmin2.tsx" , pada function fixPath
Saya ingin jika didalam deeplink ada "/admin/..." contoh "/admin/event/review/status" maka path yang akan di redirect adalah "/admin/event/review/status"
jika tidak maka terapkan sesuai dengan logika yang sudah ada
<!-- Start Random Prompt -->
Bagaimana menangani bug berikut pada file berikut: screens/Invesment/Document/ScreenRecap.tsx
Ini adalah halaman yang memiliki fungsi pagination , saya membuat data dummy dimana menghasilkan data urut 1-9, saya mencoba memuat halaman setiap page nya 4 saja untuk percobaan.
Saat awal muncul komponent box dengan data 9 - 6, kemudian saya hapus data ke 8 . lalu saya coba scroll ke bawah seharusnya angka akan tetap urut 9, 7, 6, 5, 4 ... 1. Tapi dalam case ini setelah 8 di hapus kemudian saya scroll box ke 5 tidak muncul saat di scroll. Apakah anda mengerti maksud saya ?
Gunakan bahasa indonesia pada cli agar saya mudah membacanya.eclar
<!-- End Random Prompt -->
<!-- START Prompt Admin Refactoring -->
<!-- Pindah kode ke Screen Component -->
File source: app/(application)/admin/app-information/business-field/[id]/index.tsx
Folder tujuan: screens/Admin/App-Information
Nama file utama: ScreenBusinessFieldDetail.tsx
Nama function utama: Admin_ScreenBusinessFieldDetail
File komponen wrapper: components/_ShareComponent/NewWrapper.tsx
Buat file baru pada "Folder tujuan" dengan nama "Nama file utama" dan ubah nama function menjadi "Nama function utama" kemudian clean code, import dan panggil function tersebut pada file "File source"
Analisa juga file "Nama file utama" , jika belum menggunakan NewWrapper pada file "File komponen wrapper" , maka terapkan juga dan ganti wrapper lama yaitu komponen ViewWrapper
<!-- Penerapan Pagination -->
Function fecth: apiAdminMasterBank
File function fetch: service/api-admin/api-master-admin.ts
Terapkan pagination pada file "Nama file utama"
Komponen pagination yang digunaka berada pada file hooks/use-pagination.tsx dan helpers/paginationHelpers.tsx
Perbaiki fetch "Function fecth" , pada file "File function fetch"
Jika tidak ada props page maka tambahkan props page dan default page: "1"
Gunakan bahasa indonesia pada cli agar saya mudah membacanya.
<!-- END Prompt Admin Refactoring -->
<!-- Use Prompt Now -->
Terapkan NewWrapper pada file: screens/Admin/App-Information/InformationBankSection.tsx
Component yang digunakan: components/_ShareComponent/NewWrapper.tsx
Function fecth: apiAdminMasterBank
File function fetch: service/api-admin/api-master-admin.ts
Terapkan pagination pada file "Nama file utama"
Komponen pagination yang digunaka berada pada file hooks/use-pagination.tsx dan helpers/paginationHelpers.tsx
Perbaiki fetch "Function fecth" , pada file "File function fetch"
Jika tidak ada props page maka tambahkan props page dan default page: "1" ( string )
<!-- Baru -->
File Utama: screens/Admin/App-Information/InformationBankSection.tsx
Terapkan FlatList dan pagination pada file "File Utama"
Komponen pagination yang digunaka berada pada file hooks/use-pagination.tsx dan helpers/paginationHelpers.tsx
Function fecth: apiAdminMasterBank
File function fetch: service/api-admin/api-master-admin.ts
Jika tidak ada props page maka tambahkan props page dan default page: "1" ( string )
Jika butuh refrensi FlatList bisa lihat pada file components/_ShareComponent/NewWrapper.tsx
<!-- END Use Prompt Now -->

View File

@@ -1,114 +1,56 @@
import {
ActionIcon,
BadgeCustom,
CenterCustom,
Grid,
LoaderCustom,
StackCustom,
TextCustom,
TextCustom
} from "@/components";
import { AccentColor } from "@/constants/color-palet";
import { ICON_SIZE_BUTTON } from "@/constants/constans-value";
import { apiAdminMasterBusinessField } from "@/service/api-admin/api-master-admin";
import { FontAwesome5 } from "@expo/vector-icons";
import { router, useFocusEffect } from "expo-router";
import _ from "lodash";
import { useCallback, useState } from "react";
import { View } from "react-native";
import { Divider } from "react-native-paper";
import AdminBasicBox from "@/components/_ShareComponent/Admin/AdminBasicBox";
import { router } from "expo-router";
export default function AdminAppInformation_BusinessFieldSection() {
const [listData, setListData] = useState<any[] | null>(null);
const [loadData, setLoadData] = useState(false);
useFocusEffect(
useCallback(() => {
onLoadList();
}, [])
);
const onLoadList = async () => {
try {
setLoadData(true);
const response = await apiAdminMasterBusinessField();
if (response.success) {
setListData(response.data);
}
} catch (error) {
console.log("[ERROR LIST BUSINESS FIELD]", error);
setListData([]);
} finally {
setLoadData(false);
}
interface Bidang {
item: {
id: string;
name: string;
slug: string;
active: boolean;
createdAt: string;
updatedAt: string;
};
}
export default function AdminAppInformation_BusinessFieldSection({
item,
}: {
item: any;
}) {
return (
<>
<StackCustom>
<AdminBasicBox
onPress={() =>
router.push(`/admin/app-information/business-field/${item.item.id}`)
}
style={{ marginHorizontal: 10, marginVertical: 5 }}
>
<Grid>
<Grid.Col span={2} style={{ alignItems: "center" }}>
<TextCustom bold>Aksi</TextCustom>
<Grid.Col span={8} style={{ alignSelf: "center" }}>
<StackCustom gap={"xs"}>
<TextCustom bold truncate>
{item?.item?.name || "-"}
</TextCustom>
</StackCustom>
</Grid.Col>
<Grid.Col span={4} style={{ alignItems: "center" }}>
<TextCustom bold>Status</TextCustom>
</Grid.Col>
<Grid.Col span={6}>
<TextCustom bold>Nama Bidang Bisnis</TextCustom>
<Grid.Col span={4} style={{ alignItems: "flex-end" }}>
<CenterCustom>
{item?.item?.active ? (
<BadgeCustom color="green">Aktif</BadgeCustom>
) : (
<BadgeCustom color="red">Tidak Aktif</BadgeCustom>
)}
</CenterCustom>
</Grid.Col>
</Grid>
<Divider />
{loadData ? (
<LoaderCustom />
) : _.isEmpty(listData) ? (
<TextCustom align="center">Tidak ada data</TextCustom>
) : (
<StackCustom>
{listData?.map((item: any, index: number) => (
<View key={index}>
<Grid>
<Grid.Col span={2} style={{ alignItems: "center" }}>
<ActionIcon
icon={
<FontAwesome5
name="edit"
size={ICON_SIZE_BUTTON}
color="black"
/>
}
onPress={() => {
router.push(
`/admin/app-information/business-field/${item.id}`
);
}}
/>
</Grid.Col>
<Grid.Col
span={4}
style={{ alignItems: "center", justifyContent: "center" }}
>
<CenterCustom>
<BadgeCustom
color={
item.active ? AccentColor.blue : AccentColor.blackgray
}
>
{item.active ? "Aktif" : "Tidak Aktif"}
</BadgeCustom>
</CenterCustom>
</Grid.Col>
<Grid.Col span={6} style={{ justifyContent: "center" }}>
<TextCustom>{item.name}</TextCustom>
</Grid.Col>
</Grid>
</View>
))}
</StackCustom>
)}
</StackCustom>
</AdminBasicBox>
</>
);
}

View File

@@ -1,119 +1,59 @@
import {
ActionIcon,
BadgeCustom,
CenterCustom,
Grid,
LoaderCustom,
StackCustom,
TextCustom,
TextCustom
} from "@/components";
import { AccentColor } from "@/constants/color-palet";
import { ICON_SIZE_BUTTON } from "@/constants/constans-value";
import { apiAdminMasterBank } from "@/service/api-admin/api-master-admin";
import { FontAwesome5 } from "@expo/vector-icons";
import { router, useFocusEffect } from "expo-router";
import _ from "lodash";
import { useCallback, useState } from "react";
import { View } from "react-native";
import { Divider } from "react-native-paper";
import AdminBasicBox from "@/components/_ShareComponent/Admin/AdminBasicBox";
import { router } from "expo-router";
export default function AdminAppInformation_Bank() {
const [listData, setListData] = useState<any | null>(null);
const [loadData, setLoadData] = useState(false);
useFocusEffect(
useCallback(() => {
loadMasterBank();
}, [])
);
const loadMasterBank = async () => {
try {
setLoadData(true);
const response = await apiAdminMasterBank();
setListData(response.data);
} catch (error) {
console.log("[ERROR LIST BANK]", error);
setListData([]);
} finally {
setLoadData(false);
}
interface BankProps {
item: {
id: string;
namaBank: string;
namaAkun: string;
norek: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
};
}
export default function AdminAppInformation_Bank({
item,
}: {
item: BankProps;
}) {
return (
<>
<StackCustom>
<AdminBasicBox
onPress={() =>
router.push(`/admin/app-information/information-bank/${item.item.id}`)
}
style={{ marginHorizontal: 10, marginVertical: 5 }}
>
<Grid>
<Grid.Col span={3}>
<TextCustom bold align="center">
Aksi
</TextCustom>
<Grid.Col span={8}>
<StackCustom gap={"xs"}>
<TextCustom bold truncate>
{item?.item?.namaBank || "-"}
</TextCustom>
<TextCustom size={"small"} bold truncate color="gray">
{item?.item?.norek || "-"}
</TextCustom>
</StackCustom>
</Grid.Col>
<Grid.Col span={3}>
<TextCustom bold align="center">
Status
</TextCustom>
</Grid.Col>
<Grid.Col span={6}>
<TextCustom bold align="center">
Nama Bank
</TextCustom>
<Grid.Col span={4} style={{ alignItems: "flex-end" }}>
<CenterCustom>
{item?.item?.isActive ? (
<BadgeCustom color="green">Aktif</BadgeCustom>
) : (
<BadgeCustom color="red">Tidak Aktif</BadgeCustom>
)}
</CenterCustom>
</Grid.Col>
</Grid>
<Divider />
{loadData ? (
<LoaderCustom />
) : _.isEmpty(listData) ? (
<TextCustom align="center">Tidak ada data</TextCustom>
) : (
<StackCustom>
{listData?.map((item: any, index: number) => (
<View key={index}>
<Grid>
<Grid.Col span={3} style={{ alignItems: "center" }}>
<ActionIcon
icon={
<FontAwesome5
name="edit"
size={ICON_SIZE_BUTTON}
color="black"
/>
}
onPress={() => {
router.push(
`/admin/app-information/information-bank/${item.id}`
);
}}
/>
</Grid.Col>
<Grid.Col
span={3}
style={{ alignItems: "center", justifyContent: "center" }}
>
<CenterCustom>
<BadgeCustom
color={
item.isActive
? AccentColor.blue
: AccentColor.blackgray
}
>
{item.isActive ? "Aktif" : "Tidak Aktif"}
</BadgeCustom>
</CenterCustom>
</Grid.Col>
<Grid.Col span={6} style={{ justifyContent: "center" }}>
<TextCustom align="center">{item.namaBank}</TextCustom>
</Grid.Col>
</Grid>
</View>
))}
</StackCustom>
)}
</StackCustom>
</AdminBasicBox>
</>
);
}

View File

@@ -0,0 +1,153 @@
import { ScrollableCustom, StackCustom } from "@/components";
import AdminActionIconPlus from "@/components/_ShareComponent/Admin/ActionIconPlus";
import AdminComp_BoxTitle from "@/components/_ShareComponent/Admin/BoxTitlePage";
import NewWrapper from "@/components/_ShareComponent/NewWrapper";
import { MainColor } from "@/constants/color-palet";
import { PAGINATION_DEFAULT_TAKE } from "@/constants/constans-value";
import { createPaginationComponents } from "@/helpers/paginationHelpers";
import { usePagination } from "@/hooks/use-pagination";
import AdminAppInformation_BusinessFieldSection from "@/screens/Admin/App-Information/BusinessFieldSection";
import AdminAppInformation_Bank_Component from "@/screens/Admin/App-Information/InformationBankSection";
import { apiFetchAdminMasterAppInformation } from "@/service/api-admin/api-master-admin";
import { router, useFocusEffect } from "expo-router";
import { useCallback, useState } from "react";
import { Alert, RefreshControl } from "react-native";
export function Admin_ScreenAppInformation() {
const [activeCategory, setActiveCategory] = useState<string | null>("bank");
const [activePage, setActivePage] = useState<string>("Informasi Bank");
const pagination = usePagination({
fetchFunction: async (page) => {
return await apiFetchAdminMasterAppInformation({
category: activeCategory as string,
page: String(page),
});
},
pageSize: PAGINATION_DEFAULT_TAKE,
dependencies: [activeCategory],
onError: (error) => console.error("[ERROR] Fetch job by status:", error),
});
const { ListEmptyComponent, ListFooterComponent } =
createPaginationComponents({
loading: pagination.loading,
refreshing: pagination.refreshing,
listData: pagination.listData,
emptyMessage: `Tidak ada data ${activeCategory}`,
skeletonCount: PAGINATION_DEFAULT_TAKE,
skeletonHeight: 100,
});
const handlePress = (item: any) => {
setActiveCategory(item.value);
setActivePage(item.label);
// tambahkan logika lain seperti filter dsb.
};
useFocusEffect(
useCallback(() => {
pagination.onRefresh();
}, [activeCategory]),
);
const scrollComponent = (
<StackCustom>
<ScrollableCustom
data={listPage.map((e, i) => ({
id: i,
label: e.label,
value: e.value,
}))}
onButtonPress={handlePress}
activeId={activeCategory as any}
/>
<AdminComp_BoxTitle
title={activePage}
rightComponent={
<AdminActionIconPlus
onPress={() => {
if (activeCategory === "bank") {
router.push("/admin/app-information/information-bank/create");
} else if (activeCategory === "business") {
router.push("/admin/app-information/business-field/create");
} else if (activeCategory === "sticker") {
Alert.alert("Coming Soon", "Next Update");
// router.push("/admin/app-information/sticker/create");
}
}}
/>
}
/>
</StackCustom>
);
// const renderContent = () => {
// switch (activeCategory) {
// case "bank":
// return <AdminAppInformation_Bank_Component />;
// case "business":
// return <AdminAppInformation_BusinessFieldSection />;
// // case "sticker":
// // return <AdminAppInformation_StickerSection />;
// default:
// return <AdminAppInformation_Bank_Component />;
// }
// };
const renderItem = (item: any) => {
if (activeCategory === "bank") {
return <AdminAppInformation_Bank_Component key={item.id} item={item} />;
} else if (activeCategory === "business") {
return (
<AdminAppInformation_BusinessFieldSection key={item.id} item={item} />
);
} else {
return <AdminAppInformation_Bank_Component key={item.id} item={item} />;
}
};
return (
<NewWrapper
headerComponent={scrollComponent}
// ListHeaderComponent={
// }
refreshControl={
<RefreshControl
tintColor={MainColor.yellow}
colors={[MainColor.yellow]}
refreshing={pagination.refreshing}
onRefresh={pagination.onRefresh}
/>
}
onEndReached={pagination.loadMore}
ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={ListFooterComponent}
hideFooter
// Data dan render
listData={pagination.listData}
renderItem={(item: any) => renderItem(item)}
/>
// {renderContent()}
// </NewWrapper>
);
}
const listPage = [
{
id: "1",
label: "Informasi Bank",
value: "bank",
},
{
id: "2",
label: "Bidang & Sub Bidang",
value: "business",
},
// {
// id: "3",
// label: "Stiker",
// value: "sticker",
// },
];

View File

@@ -0,0 +1,185 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
BadgeCustom,
BaseBox,
CenterCustom,
NewWrapper,
Spacing,
StackCustom,
TextCustom,
} from "@/components";
import AdminBackButtonAntTitle from "@/components/_ShareComponent/Admin/BackButtonAntTitle";
import { GridSpan_NewComponent } from "@/components/_ShareComponent/GridSpan_NewComponent";
import { MainColor } from "@/constants/color-palet";
import {
ICON_SIZE_SMALL,
PAGINATION_DEFAULT_TAKE,
} from "@/constants/constans-value";
import { createPaginationComponents } from "@/helpers/paginationHelpers";
import { usePagination } from "@/hooks/use-pagination";
import { apiAdminMasterBusinessFieldById } from "@/service/api-admin/api-master-admin";
import { Ionicons } from "@expo/vector-icons";
import { router, useFocusEffect, useLocalSearchParams } from "expo-router";
import { useCallback, useState } from "react";
import { RefreshControl, View } from "react-native";
export function Admin_ScreenBusinessFieldDetail() {
const { id } = useLocalSearchParams();
const [bidang, setBidang] = useState<any | null>(null);
const pagination = usePagination({
fetchFunction: async (page) => {
return await apiAdminMasterBusinessFieldById({
category: "only-sub-bidang",
id: id as any,
page: String(page),
});
// Pastikan mengembalikan struktur data yang sesuai dengan yang diharapkan oleh usePagination
},
pageSize: PAGINATION_DEFAULT_TAKE,
dependencies: [id],
onError: (error) => {
console.log("Error fetching data sub bidang", error);
},
});
const { ListEmptyComponent, ListFooterComponent } =
createPaginationComponents({
loading: pagination.loading,
refreshing: pagination.refreshing,
listData: pagination.listData,
searchQuery: "",
emptyMessage: "Tidak ada data pengguna",
emptySearchMessage: "Tidak ada hasil pencarian",
skeletonCount: PAGINATION_DEFAULT_TAKE,
skeletonHeight: 100,
isInitialLoad: pagination.isInitialLoad,
});
useFocusEffect(
useCallback(() => {
onLoadBidang();
pagination.onRefresh();
}, [id]),
);
const onLoadBidang = async () => {
try {
const response = await apiAdminMasterBusinessFieldById({
id: id as string,
category: "all",
});
setBidang(response.data);
} catch (error) {
console.log("[ERROR]", error);
setBidang(null);
}
};
const renderHeader = () => (
<View>
<BaseBox
onPress={() =>
router.push(
`/admin/app-information/business-field/${id}/bidang-update`,
)
}
>
<StackCustom gap={"xs"}>
<GridSpan_NewComponent
span1={10}
span2={2}
text1={
<StackCustom>
<TextCustom bold size={"large"}>
{bidang?.bidang?.name}
</TextCustom>
{bidang?.bidang.active ? (
<BadgeCustom color="green">Aktif</BadgeCustom>
) : (
<BadgeCustom color="red">Tidak Aktif</BadgeCustom>
)}
</StackCustom>
}
text2={
<CenterCustom>
<Ionicons
name="caret-forward"
size={ICON_SIZE_SMALL}
color="white"
/>
</CenterCustom>
}
/>
</StackCustom>
</BaseBox>
<CenterCustom>
<TextCustom bold>Sub Bidang</TextCustom>
</CenterCustom>
<Spacing height={5} />
</View>
);
const renderItem = ({ item }: { item: any }) => (
<BaseBox
onPress={() =>
router.push(
`/admin/app-information/business-field/${item?.id}/sub-bidang-update`,
)
}
>
<StackCustom gap={"xs"}>
<GridSpan_NewComponent
span1={10}
span2={2}
text1={
<StackCustom>
<TextCustom bold size={"large"}>
{item.name}
</TextCustom>
{item?.isActive ? (
<BadgeCustom color="green">Aktif</BadgeCustom>
) : (
<BadgeCustom color="red">Tidak Aktif</BadgeCustom>
)}
</StackCustom>
}
text2={
<CenterCustom>
<Ionicons
name="caret-forward"
size={ICON_SIZE_SMALL}
color="white"
/>
</CenterCustom>
}
/>
</StackCustom>
</BaseBox>
);
return (
<>
<NewWrapper
listData={pagination.listData}
onEndReached={pagination.loadMore}
ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={ListFooterComponent}
hideFooter
refreshControl={
<RefreshControl
refreshing={pagination.refreshing}
onRefresh={pagination.onRefresh}
tintColor={MainColor.yellow}
colors={[MainColor.yellow]}
/>
}
headerComponent={
<AdminBackButtonAntTitle title="Detail Bidang & Sub Bidang" />
}
ListHeaderComponent={renderHeader()}
renderItem={renderItem}
/>
</>
);
}

View File

@@ -0,0 +1,127 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
BadgeCustom,
CenterCustom,
Grid,
SearchInput,
StackCustom,
TextCustom,
} from "@/components";
import AdminBasicBox from "@/components/_ShareComponent/Admin/AdminBasicBox";
import AdminComp_BoxTitle from "@/components/_ShareComponent/Admin/BoxTitlePage";
import NewWrapper from "@/components/_ShareComponent/NewWrapper";
import { MainColor } from "@/constants/color-palet";
import { PAGINATION_DEFAULT_TAKE } from "@/constants/constans-value";
import { createPaginationComponents } from "@/helpers/paginationHelpers";
import { usePagination } from "@/hooks/use-pagination";
import { apiAdminUserAccessGetAll } from "@/service/api-admin/api-admin-user-access";
import { router, useFocusEffect } from "expo-router";
import { useCallback, useState } from "react";
import { RefreshControl } from "react-native";
export function Admin_ScreenUserAccess() {
const [search, setSearch] = useState("");
const pagination = usePagination({
fetchFunction: async (page, searchQuery) => {
return await apiAdminUserAccessGetAll({
search: searchQuery || "",
category: "only-user",
page: String(page),
});
// Pastikan mengembalikan struktur data yang sesuai dengan yang diharapkan oleh usePagination
},
pageSize: PAGINATION_DEFAULT_TAKE,
searchQuery: search,
dependencies: [],
onError: (error) => {
console.log("Error fetching data user access", error);
},
});
const { ListEmptyComponent, ListFooterComponent } =
createPaginationComponents({
loading: pagination.loading,
refreshing: pagination.refreshing,
listData: pagination.listData,
searchQuery: search,
emptyMessage: "Tidak ada data pengguna",
emptySearchMessage: "Tidak ada hasil pencarian",
skeletonCount: PAGINATION_DEFAULT_TAKE,
skeletonHeight: 100,
isInitialLoad: pagination.isInitialLoad,
});
useFocusEffect(
useCallback(() => {
pagination.onRefresh();
}, []),
);
const rightComponent = () => {
return (
<>
<SearchInput
containerStyle={{ width: "100%", marginBottom: 0 }}
placeholder="Cari User"
onChangeText={(text) => setSearch(text)}
/>
</>
);
};
const renderItem = ({ item, index }: { item: any; index: number }) => (
<AdminBasicBox
key={index}
onPress={() => router.push(`/admin/user-access/${item?.id}`)}
style={{ marginHorizontal: 10, marginVertical: 5 }}
>
<Grid>
<Grid.Col span={8}>
<StackCustom gap={"xs"}>
<TextCustom bold truncate>
{item?.username || "-"}
</TextCustom>
<TextCustom size={"small"} bold truncate color="gray">
{item?.nomor || "-"}
</TextCustom>
</StackCustom>
</Grid.Col>
<Grid.Col span={4} style={{ alignItems: "flex-end" }}>
<CenterCustom>
{item?.active ? (
<BadgeCustom color="green">Aktif</BadgeCustom>
) : (
<BadgeCustom color="red">Tidak Aktif</BadgeCustom>
)}
</CenterCustom>
</Grid.Col>
</Grid>
</AdminBasicBox>
);
return (
<NewWrapper
headerComponent={
<AdminComp_BoxTitle
title="User Access"
rightComponent={rightComponent()}
/>
}
refreshControl={
<RefreshControl
refreshing={pagination.refreshing}
onRefresh={pagination.onRefresh}
tintColor={MainColor.yellow}
colors={[MainColor.yellow]}
/>
}
renderItem={renderItem}
listData={pagination.listData}
onEndReached={pagination.loadMore}
ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={ListFooterComponent}
hideFooter
/>
);
}

View File

@@ -0,0 +1,461 @@
import { NavbarItem_V2 } from "@/components/Drawer/NavbarMenu_V2";
export { adminListMenu_V2, superAdminListMenu_V2 }
const adminListMenu_V2: NavbarItem_V2[] = [
{
label: "Main Dashboard",
icon: "home",
link: "/admin/dashboard",
},
{
label: "Investasi",
icon: "wallet",
links: [
{
label: "Dashboard",
link: "/admin/investment",
// Dashboard tidak perlu detailPattern, akan auto-match dengan /admin/investment/123/...
},
{
label: "Publish",
link: "/admin/investment/publish/status",
detailPattern: "/admin/investment/*/publish", // Match: /admin/investment/123/publish
},
{
label: "Review",
link: "/admin/investment/review/status",
detailPattern: "/admin/investment/*/review", // Match: /admin/investment/123/review
},
{
label: "Reject",
link: "/admin/investment/reject/status",
detailPattern: "/admin/investment/*/reject", // Match: /admin/investment/123/reject
},
],
},
{
label: "Donasi",
icon: "hand-right",
links: [
{
label: "Dashboard",
link: "/admin/donation",
},
{
label: "Publish",
link: "/admin/donation/publish/status",
detailPattern: "/admin/donation/*/publish",
},
{
label: "Review",
link: "/admin/donation/review/status",
detailPattern: "/admin/donation/*/review",
},
{
label: "Reject",
link: "/admin/donation/reject/status",
detailPattern: "/admin/donation/*/reject",
},
{
label: "Kategori",
link: "/admin/donation/category",
},
],
},
{
label: "Event",
icon: "calendar-clear",
links: [
{
label: "Dashboard",
link: "/admin/event",
},
{
label: "Publish",
link: "/admin/event/publish/status",
detailPattern: "/admin/event/*/publish",
},
{
label: "Review",
link: "/admin/event/review/status",
detailPattern: "/admin/event/*/review",
},
{
label: "Reject",
link: "/admin/event/reject/status",
detailPattern: "/admin/event/*/reject",
},
{
label: "Tipe Acara",
link: "/admin/event/type-of-event",
},
{
label: "Riwayat",
link: "/admin/event/history/status",
detailPattern: "/admin/event/*/history",
},
],
},
{
label: "Voting",
icon: "accessibility-outline",
links: [
{
label: "Dashboard",
link: "/admin/voting",
},
{
label: "Publish",
link: "/admin/voting/publish/status",
detailPattern: "/admin/voting/*/publish",
},
{
label: "Review",
link: "/admin/voting/review/status",
detailPattern: "/admin/voting/*/review",
},
{
label: "Reject",
link: "/admin/voting/reject/status",
detailPattern: "/admin/voting/*/reject",
},
{
label: "Riwayat",
link: "/admin/voting/history",
detailPattern: "/admin/voting/*/history",
},
],
},
{
label: "Job",
icon: "desktop-outline",
links: [
{
label: "Dashboard",
link: "/admin/job",
},
{
label: "Publish",
link: "/admin/job/publish/status",
detailPattern: "/admin/job/*/publish",
},
{
label: "Review",
link: "/admin/job/review/status",
detailPattern: "/admin/job/*/review",
},
{
label: "Reject",
link: "/admin/job/reject/status",
detailPattern: "/admin/job/*/reject",
},
],
},
{
label: "Forum",
icon: "chatbubble-ellipses-outline",
links: [
{
label: "Dashboard",
link: "/admin/forum",
},
{
label: "Posting",
link: "/admin/forum/posting",
},
{
label: "Report Posting",
link: "/admin/forum/report-posting",
},
{
label: "Report Komentar",
link: "/admin/forum/report-comment",
},
],
},
{
label: "Collaboration",
icon: "people",
links: [
{
label: "Dashboard",
link: "/admin/collaboration",
},
{
label: "Publish",
link: "/admin/collaboration/publish",
},
{
label: "Group",
link: "/admin/collaboration/group",
},
{
label: "Reject",
link: "/admin/collaboration/reject",
},
],
},
{
label: "Maps",
icon: "map",
link: "/admin/maps",
},
{
label: "App Information",
icon: "information-circle",
link: "/admin/app-information",
},
{
label: "User Access",
icon: "people",
link: "/admin/user-access",
},
];
const superAdminListMenu_V2: NavbarItem_V2[] = [
{
label: "Main Dashboard",
icon: "home",
link: "/admin/dashboard",
},
{
label: "Investasi",
icon: "wallet",
links: [
{
label: "Dashboard",
link: "/admin/investment",
},
{
label: "Publish",
link: "/admin/investment/publish/status",
detailPattern: "/admin/investment/*/publish",
},
{
label: "Review",
link: "/admin/investment/review/status",
detailPattern: "/admin/investment/*/review",
},
{
label: "Reject",
link: "/admin/investment/reject/status",
detailPattern: "/admin/investment/*/reject",
},
],
},
{
label: "Donasi",
icon: "hand-right",
links: [
{
label: "Dashboard",
link: "/admin/donation",
},
{
label: "Publish",
link: "/admin/donation/publish/status",
detailPattern: "/admin/donation/*/publish",
},
{
label: "Review",
link: "/admin/donation/review/status",
detailPattern: "/admin/donation/*/review",
},
{
label: "Reject",
link: "/admin/donation/reject/status",
detailPattern: "/admin/donation/*/reject",
},
{
label: "Kategori",
link: "/admin/donation/category",
},
],
},
{
label: "Event",
icon: "calendar-clear",
links: [
{
label: "Dashboard",
link: "/admin/event",
},
{
label: "Publish",
link: "/admin/event/publish/status",
detailPattern: "/admin/event/*/publish",
},
{
label: "Review",
link: "/admin/event/review/status",
detailPattern: "/admin/event/*/review",
},
{
label: "Reject",
link: "/admin/event/reject/status",
detailPattern: "/admin/event/*/reject",
},
{
label: "Tipe Acara",
link: "/admin/event/type-of-event",
},
{
label: "Riwayat",
link: "/admin/event/history/status",
detailPattern: "/admin/event/*/history",
},
],
},
{
label: "Voting",
icon: "accessibility-outline",
links: [
{
label: "Dashboard",
link: "/admin/voting",
},
{
label: "Publish",
link: "/admin/voting/publish/status",
detailPattern: "/admin/voting/*/publish",
},
{
label: "Review",
link: "/admin/voting/review/status",
detailPattern: "/admin/voting/*/review",
},
{
label: "Reject",
link: "/admin/voting/reject/status",
detailPattern: "/admin/voting/*/reject",
},
{
label: "Riwayat",
link: "/admin/voting/history",
detailPattern: "/admin/voting/*/history",
},
],
},
{
label: "Job",
icon: "desktop-outline",
links: [
{
label: "Dashboard",
link: "/admin/job",
},
{
label: "Publish",
link: "/admin/job/publish/status",
detailPattern: "/admin/job/*/publish",
},
{
label: "Review",
link: "/admin/job/review/status",
detailPattern: "/admin/job/*/review",
},
{
label: "Reject",
link: "/admin/job/reject/status",
detailPattern: "/admin/job/*/reject",
},
],
},
{
label: "Forum",
icon: "chatbubble-ellipses-outline",
links: [
{
label: "Dashboard",
link: "/admin/forum",
},
{
label: "Posting",
link: "/admin/forum/posting",
},
{
label: "Report Posting",
link: "/admin/forum/report-posting",
},
{
label: "Report Komentar",
link: "/admin/forum/report-comment",
},
],
},
{
label: "Collaboration",
icon: "people",
links: [
{
label: "Dashboard",
link: "/admin/collaboration",
},
{
label: "Publish",
link: "/admin/collaboration/publish",
},
{
label: "Group",
link: "/admin/collaboration/group",
},
{
label: "Reject",
link: "/admin/collaboration/reject",
},
],
},
{
label: "Maps",
icon: "map",
link: "/admin/maps",
},
{
label: "App Information",
icon: "information-circle",
link: "/admin/app-information",
},
{
label: "User Access",
icon: "people",
link: "/admin/user-access",
},
{
label: "Super Admin",
icon: "globe",
link: "/admin/super-admin",
},
];
/*
=================================================================================
PENJELASAN detailPattern:
=================================================================================
detailPattern digunakan untuk match dengan URL detail page yang strukturnya:
/admin/{module}/[id]/[status]
Contoh untuk Job Review:
- Link: /admin/job/review/status (halaman list review)
- detailPattern: /admin/job/* /review (detail dari review)
- Match dengan: /admin/job/123/review, /admin/job/456/review, dll
Wildcard "*" akan match dengan ID apapun (angka, UUID, alphanumeric).
Modul yang PERLU detailPattern:
✅ Investasi - Publish, Review, Reject (ada [id]/[status])
✅ Donasi - Publish, Review, Reject (ada [id]/[status])
✅ Event - Publish, Review, Reject, Riwayat (ada [id]/[status])
✅ Voting - Publish, Review, Reject, Riwayat (ada [id]/[status])
✅ Job - Publish, Review, Reject (ada [id]/[status])
Modul yang TIDAK PERLU detailPattern:
❌ Forum - posting, report-posting, report-comment (struktur berbeda)
❌ Collaboration - struktur berbeda
❌ Maps, App Information, User Access - single page
❌ Dashboard submenu - auto-match dengan parent path
=================================================================================
*/

View File

@@ -0,0 +1,21 @@
import { BaseBox, Grid, Spacing, TextCustom } from "@/components";
import { formatChatTime } from "@/utils/formatChatTime";
export default function Donation_BoxNews({item}: {item: any}){
return <>
<BaseBox href={`/donation/[id]/(news)/${item?.id}`}>
<Grid>
<Grid.Col span={8}>
<TextCustom truncate bold>
{item?.title || "-"}
</TextCustom>
</Grid.Col>
<Grid.Col span={4} style={{ alignItems: "flex-end" }}>
<TextCustom size="small">
{formatChatTime(item?.createdAt)}
</TextCustom>
</Grid.Col>
</Grid>
</BaseBox>
</>
}

View File

@@ -0,0 +1,62 @@
import FloatingButton from "@/components/Button/FloatingButton";
import NewWrapper from "@/components/_ShareComponent/NewWrapper";
import { MainColor } from "@/constants/color-palet";
import { PAGINATION_DEFAULT_TAKE } from "@/constants/constans-value";
import { createPaginationComponents } from "@/helpers/paginationHelpers";
import { usePagination } from "@/hooks/use-pagination";
import Donation_BoxPublish from "@/screens/Donation/BoxPublish";
import { apiDonationGetAll } from "@/service/api-client/api-donation";
import { router } from "expo-router";
import { RefreshControl } from "react-native";
export default function Donation_ScreenBeranda() {
const pagination = usePagination({
fetchFunction: async (page, search) => {
return await apiDonationGetAll({
category: "beranda",
page: String(page),
}).then((res) => {
console.log("RES", JSON.stringify(res, null, 2));
return res;
});
},
pageSize: PAGINATION_DEFAULT_TAKE, // Sesuaikan dengan jumlah item per halaman yang diinginkan
onError: (error) => console.error("[ERROR] Fetch event beranda:", error),
dependencies: [],
});
const { ListEmptyComponent, ListFooterComponent } =
createPaginationComponents({
loading: pagination.loading,
refreshing: pagination.refreshing,
listData: pagination.listData,
isInitialLoad: pagination.isInitialLoad,
emptyMessage: "Belum ada donasi",
skeletonCount: PAGINATION_DEFAULT_TAKE,
skeletonHeight: 150,
});
return (
<NewWrapper
listData={pagination.listData}
renderItem={({ item }) => (
<Donation_BoxPublish data={item} id={item.id} />
)}
onEndReached={pagination.loadMore}
ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={ListFooterComponent}
refreshControl={
<RefreshControl
refreshing={pagination.refreshing}
onRefresh={pagination.onRefresh}
tintColor={MainColor.yellow}
colors={[MainColor.yellow]}
/>
}
hideFooter
floatingButton={
<FloatingButton onPress={() => router.push("/donation/create")} />
}
/>
);
}

View File

@@ -0,0 +1,174 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
ActionIcon,
BaseBox,
Grid,
InformationBox,
Spacing,
StackCustom,
TextCustom,
} from "@/components";
import { MainColor } from "@/constants/color-palet";
import { usePagination } from "@/hooks/use-pagination";
import {
apiDonationDisbursementOfFundsListById,
apiDonationGetOne,
} from "@/service/api-client/api-donation";
import { formatCurrencyDisplay } from "@/utils/formatCurrencyDisplay";
import { Feather } from "@expo/vector-icons";
import dayjs from "dayjs";
import { router, useLocalSearchParams } from "expo-router";
import _ from "lodash";
import React, { useState } from "react";
import { RefreshControl, View } from "react-native";
import { createPaginationComponents } from "@/helpers/paginationHelpers";
import { PAGINATION_DEFAULT_TAKE } from "@/constants/constans-value";
import NewWrapper from "@/components/_ShareComponent/NewWrapper";
import { Divider } from "react-native-paper";
interface Donation_ScreenFundDisbursementProps {
donationId: string;
}
export default function Donation_ScreenFundDisbursement({
donationId,
}: Donation_ScreenFundDisbursementProps) {
const [data, setData] = useState({
totalPencairan: 0,
akumulasiPencairan: 0,
});
// Ambil data utama (total pencairan, dll) terpisah dari pagination
React.useEffect(() => {
const onLoadData = async () => {
try {
const responseData = await apiDonationGetOne({
id: donationId,
category: "permanent",
});
if (responseData.success) {
setData({
totalPencairan: responseData.data.totalPencairan,
akumulasiPencairan: responseData.data.akumulasiPencairan,
});
}
} catch (error) {
console.log("[ERROR]", error);
}
};
onLoadData();
}, [donationId]);
const pagination = usePagination({
fetchFunction: async (page) => {
return await apiDonationDisbursementOfFundsListById({
id: donationId,
page: String(page),
});
},
pageSize: PAGINATION_DEFAULT_TAKE, // Sesuaikan dengan jumlah item per halaman dari API
dependencies: [donationId],
});
const renderItem = ({ item, index }: { item: any; index: number }) => (
<BaseBox key={index}>
<StackCustom>
<Grid>
<Grid.Col span={8}>
<TextCustom bold>{item?.title}</TextCustom>
</Grid.Col>
<Grid.Col span={4} style={{ alignItems: "flex-end" }}>
<TextCustom size="small">
{dayjs(item?.createdAt).format("DD MMM YYYY")}
</TextCustom>
</Grid.Col>
</Grid>
<TextCustom>{item?.deskripsi}</TextCustom>
{/* <Spacing /> */}
<Divider />
<Grid>
<Grid.Col span={8} style={{ alignSelf: "center" }}>
<TextCustom bold size={"large"}>
Rp. {formatCurrencyDisplay(item?.nominalCair)}
</TextCustom>
</Grid.Col>
<Grid.Col span={4} style={{ alignItems: "flex-end" }}>
<ActionIcon
icon={<Feather name="file-text" color={MainColor.black} />}
onPress={() => {
router.navigate(
`/(application)/(image)/preview-image/${item?.imageId}`,
);
}}
/>
</Grid.Col>
</Grid>
{/*
<ButtonCenteredOnly
onPress={() => {
router.navigate(
`/(application)/(image)/preview-image/${item?.imageId}`,
);
}}
icon="file-text"
>
Bukti Transaksi
</ButtonCenteredOnly> */}
</StackCustom>
</BaseBox>
);
const { ListEmptyComponent, ListFooterComponent } =
createPaginationComponents({
loading: pagination.loading,
refreshing: pagination.refreshing,
listData: pagination.listData,
isInitialLoad: pagination.isInitialLoad,
emptyMessage: "Belum ada data",
skeletonCount: PAGINATION_DEFAULT_TAKE,
skeletonHeight: 150,
});
// Komponen header yang akan ditampilkan di atas daftar
const ListHeaderComponent = (
<View>
<InformationBox text="Pencairan dana akan dilakukan oleh Admin HIPMI tanpa campur tangan pihak manapun, jika berita pencairan dana dibawah tidak sesuai dengan kabar yang diberikan oleh PENGGALANG DANA. Maka pegguna lain dapat melaporkannya pada Admin HIPMI !" />
<BaseBox>
<Grid>
<Grid.Col span={6}>
<TextCustom bold color="yellow">
Rp. {formatCurrencyDisplay(data?.totalPencairan)}
</TextCustom>
<TextCustom size="small">Total Pencairan Dana</TextCustom>
</Grid.Col>
<Grid.Col span={6}>
<TextCustom bold color="yellow">
{data?.akumulasiPencairan} kali
</TextCustom>
<TextCustom size="small">Akumulasi Pencairan</TextCustom>
</Grid.Col>
</Grid>
</BaseBox>
</View>
);
return (
<NewWrapper
listData={pagination.listData}
renderItem={renderItem}
onEndReached={pagination.loadMore}
ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={ListFooterComponent}
ListHeaderComponent={ListHeaderComponent}
refreshControl={
<RefreshControl
refreshing={pagination.refreshing}
onRefresh={pagination.onRefresh}
/>
}
hideFooter
/>
);
}

View File

@@ -0,0 +1,98 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
BaseBox,
Grid,
LoaderCustom,
Spacing,
StackCustom,
TextCustom,
} from "@/components";
import { MainColor } from "@/constants/color-palet";
import { usePagination } from "@/hooks/use-pagination";
import { apiDonationListOfDonaturById } from "@/service/api-client/api-donation";
import { formatCurrencyDisplay } from "@/utils/formatCurrencyDisplay";
import { FontAwesome6 } from "@expo/vector-icons";
import dayjs from "dayjs";
import { RefreshControl } from "react-native";
import { createPaginationComponents } from "@/helpers/paginationHelpers";
import { PAGINATION_DEFAULT_TAKE } from "@/constants/constans-value";
import NewWrapper from "@/components/_ShareComponent/NewWrapper";
interface Donation_ScreenListOfDonaturProps {
donationId: string;
}
export default function Donation_ScreenListOfDonatur({
donationId,
}: Donation_ScreenListOfDonaturProps) {
const pagination = usePagination({
fetchFunction: async (page) => {
return await apiDonationListOfDonaturById({
id: donationId,
page: String(page),
});
},
pageSize: PAGINATION_DEFAULT_TAKE, // Sesuaikan dengan jumlah item per halaman dari API
dependencies: [donationId],
});
const renderItem = ({ item, index }: { item: any; index: number }) => (
<BaseBox key={index}>
<Grid>
<Grid.Col
span={3}
style={{ alignItems: "center", justifyContent: "center" }}
>
<FontAwesome6
name="face-smile-wink"
size={50}
style={{ color: MainColor.yellow }}
/>
</Grid.Col>
<Grid.Col span={9}>
<TextCustom bold size="large">
{item?.Author?.username || "-"}
</TextCustom>
<Spacing />
<StackCustom gap={"xs"}>
<TextCustom size={"small"}>Berdonas sebesar </TextCustom>
<TextCustom bold size="large" color="yellow">
Rp. {formatCurrencyDisplay(item?.nominal)}
</TextCustom>
<TextCustom>
{dayjs(item?.createdAt).format("DD MMM YYYY, HH:mm")}
</TextCustom>
</StackCustom>
</Grid.Col>
</Grid>
</BaseBox>
);
const { ListEmptyComponent, ListFooterComponent } =
createPaginationComponents({
loading: pagination.loading,
refreshing: pagination.refreshing,
listData: pagination.listData,
isInitialLoad: pagination.isInitialLoad,
emptyMessage: "Belum ada donatur",
skeletonCount: PAGINATION_DEFAULT_TAKE,
skeletonHeight: 120,
});
return (
<NewWrapper
hideFooter
listData={pagination.listData}
renderItem={renderItem}
onEndReached={pagination.loadMore}
ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={ListFooterComponent}
refreshControl={
<RefreshControl
refreshing={pagination.refreshing}
onRefresh={pagination.onRefresh}
/>
}
/>
);
}

View File

@@ -0,0 +1,97 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { BackButton, DrawerCustom, MenuDrawerDynamicGrid } from "@/components";
import { IconPlus } from "@/components/_Icon";
import NewWrapper from "@/components/_ShareComponent/NewWrapper";
import { MainColor } from "@/constants/color-palet";
import { PAGINATION_DEFAULT_TAKE } from "@/constants/constans-value";
import { createPaginationComponents } from "@/helpers/paginationHelpers";
import { usePagination } from "@/hooks/use-pagination";
import { apiDonationGetNewsById } from "@/service/api-client/api-donation";
import { router, Stack } from "expo-router";
import { useState } from "react";
import { RefreshControl } from "react-native";
import Donation_BoxNews from "./BoxNews";
interface Donation_ScreenListOfNewsProps {
donationId: string;
}
export default function Donation_ScreenListOfNews({
donationId,
}: Donation_ScreenListOfNewsProps) {
const [openDrawer, setOpenDrawer] = useState(false);
const pagination = usePagination({
fetchFunction: async (page) => {
return await apiDonationGetNewsById({
id: donationId,
category: "get-all",
page: String(page),
});
},
pageSize: PAGINATION_DEFAULT_TAKE, // Sesuaikan dengan jumlah item per halaman dari API
dependencies: [donationId],
});
const renderItem = ({ item, index }: { item: any; index: number }) => (
<Donation_BoxNews key={index} item={item} />
);
const { ListEmptyComponent, ListFooterComponent } =
createPaginationComponents({
loading: pagination.loading,
refreshing: pagination.refreshing,
listData: pagination.listData,
isInitialLoad: pagination.isInitialLoad,
emptyMessage: "Tidak ada kabar",
skeletonCount: PAGINATION_DEFAULT_TAKE,
skeletonHeight: 80,
});
return (
<>
<Stack.Screen
options={{
title: "Daftar Kabar",
headerLeft: () => <BackButton />,
}}
/>
<NewWrapper
listData={pagination.listData}
renderItem={renderItem}
onEndReached={pagination.loadMore}
ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={ListFooterComponent}
refreshControl={
<RefreshControl
refreshing={pagination.refreshing}
onRefresh={pagination.onRefresh}
tintColor={MainColor.yellow}
colors={[MainColor.yellow]}
/>
}
/>
<DrawerCustom
isVisible={openDrawer}
closeDrawer={() => setOpenDrawer(false)}
height={"auto"}
>
<MenuDrawerDynamicGrid
data={[
{
icon: <IconPlus />,
label: "Tambah Berita",
path: `/donation/${donationId}/(news)/add-news`,
},
]}
onPressItem={(item) => {
console.log("PATH ", item.path);
router.navigate(item.path as any);
setOpenDrawer(false);
}}
/>
</DrawerCustom>
</>
);
}

View File

@@ -0,0 +1,152 @@
import {
BadgeCustom,
BaseBox,
DummyLandscapeImage,
Grid,
StackCustom,
TextCustom,
} from "@/components";
import FloatingButton from "@/components/Button/FloatingButton";
import NewWrapper from "@/components/_ShareComponent/NewWrapper";
import { MainColor } from "@/constants/color-palet";
import { PAGINATION_DEFAULT_TAKE } from "@/constants/constans-value";
import { createPaginationComponents } from "@/helpers/paginationHelpers";
import { useAuth } from "@/hooks/use-auth";
import { usePagination } from "@/hooks/use-pagination";
import { apiDonationGetAll } from "@/service/api-client/api-donation";
import { formatCurrencyDisplay } from "@/utils/formatCurrencyDisplay";
import { Href, router } from "expo-router";
import _ from "lodash";
import { RefreshControl, View } from "react-native";
export default function Donation_ScreenMyDonation() {
const { user } = useAuth();
const pagination = usePagination({
fetchFunction: async (page, search) => {
if (!user?.id) {
throw new Error("User tidak ditemukan");
}
return await apiDonationGetAll({
category: "my-donation",
authorId: user?.id,
page: String(page),
});
},
pageSize: PAGINATION_DEFAULT_TAKE, // Sesuaikan dengan jumlah item per halaman yang diinginkan
dependencies: [user?.id], // Reload ketika user.id berubah
});
const { ListEmptyComponent, ListFooterComponent } =
createPaginationComponents({
loading: pagination.loading,
refreshing: pagination.refreshing,
listData: pagination.listData,
isInitialLoad: pagination.isInitialLoad,
emptyMessage: "Belum ada transaksi",
skeletonCount: PAGINATION_DEFAULT_TAKE,
skeletonHeight: 150,
});
const handlerColor = (status: string) => {
if (status === "menunggu") {
return "orange";
} else if (status === "proses") {
return "white";
} else if (status === "berhasil") {
return "green";
} else if (status === "gagal") {
return "red";
}
};
const handlePress = ({
invoiceId,
donationId,
status,
}: {
invoiceId: string;
donationId: string;
status: string;
}) => {
const url: Href = `../${donationId}/(transaction-flow)/${invoiceId}`;
if (status === "menunggu") {
router.push(`${url}/invoice`);
} else if (status === "proses") {
router.push(`${url}/process`);
} else if (status === "berhasil") {
router.push(`${url}/success`);
} else if (status === "gagal") {
router.push(`${url}/failed`);
}
};
const renderItem = ({ item }: { item: any }) => (
<BaseBox
paddingTop={7}
paddingBottom={7}
onPress={() => {
handlePress({
status: _.lowerCase(item.statusInvoice),
invoiceId: item.id,
donationId: item.donasiId,
});
}}
>
<Grid>
<Grid.Col span={5}>
<DummyLandscapeImage
height={100}
unClickPath
imageId={item.imageId}
/>
</Grid.Col>
<Grid.Col span={1}>
<View />
</Grid.Col>
<Grid.Col span={6}>
<StackCustom>
<TextCustom truncate={2} bold>
{item.title || "-"}
</TextCustom>
<TextCustom bold color="yellow">
Rp. {formatCurrencyDisplay(item.nominal)}
</TextCustom>
<BadgeCustom
variant="light"
color={handlerColor(_.lowerCase(item.statusInvoice))}
fullWidth
>
{item.statusInvoice}
</BadgeCustom>
</StackCustom>
</Grid.Col>
</Grid>
</BaseBox>
);
return (
<NewWrapper
listData={pagination.listData}
renderItem={renderItem}
onEndReached={pagination.loadMore}
ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={ListFooterComponent}
refreshControl={
<RefreshControl
refreshing={pagination.refreshing}
onRefresh={pagination.onRefresh}
tintColor={MainColor.yellow}
colors={[MainColor.yellow]}
/>
}
hideFooter
floatingButton={
<FloatingButton onPress={() => router.push("/donation/create")} />
}
/>
);
}

View File

@@ -0,0 +1,105 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
BackButton,
DotButton,
DrawerCustom,
MenuDrawerDynamicGrid,
} from "@/components";
import { IconPlus } from "@/components/_Icon";
import NewWrapper from "@/components/_ShareComponent/NewWrapper";
import { PAGINATION_DEFAULT_TAKE } from "@/constants/constans-value";
import { createPaginationComponents } from "@/helpers/paginationHelpers";
import { usePagination } from "@/hooks/use-pagination";
import { apiDonationGetNewsById } from "@/service/api-client/api-donation";
import { router, Stack, useFocusEffect } from "expo-router";
import { useCallback, useState } from "react";
import { RefreshControl } from "react-native";
import Donation_BoxNews from "./BoxNews";
interface Donation_ScreenRecapOfNewsProps {
donationId: string;
}
export default function Donation_ScreenRecapOfNews({
donationId,
}: Donation_ScreenRecapOfNewsProps) {
const [openDrawer, setOpenDrawer] = useState(false);
const pagination = usePagination({
fetchFunction: async (page) => {
return await apiDonationGetNewsById({
id: donationId,
category: "get-all",
page: String(page),
});
},
pageSize: PAGINATION_DEFAULT_TAKE, // Sesuaikan dengan jumlah item per halaman dari API
dependencies: [donationId],
});
useFocusEffect(
useCallback(() => {
pagination.onRefresh();
}, [donationId]),
);
const renderItem = ({ item, index }: { item: any; index: number }) => (
<Donation_BoxNews key={index} item={item} />
);
const { ListEmptyComponent, ListFooterComponent } =
createPaginationComponents({
loading: pagination.loading,
refreshing: pagination.refreshing,
listData: pagination.listData,
isInitialLoad: pagination.isInitialLoad,
emptyMessage: "Tidak ada kabar",
skeletonCount: PAGINATION_DEFAULT_TAKE,
skeletonHeight: 80,
});
return (
<>
<Stack.Screen
options={{
title: "Rekap Kabar",
headerLeft: () => <BackButton />,
headerRight: () => <DotButton onPress={() => setOpenDrawer(true)} />,
}}
/>
<NewWrapper
listData={pagination.listData}
renderItem={renderItem}
onEndReached={pagination.loadMore}
ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={ListFooterComponent}
refreshControl={
<RefreshControl
refreshing={pagination.refreshing}
onRefresh={pagination.onRefresh}
/>
}
/>
<DrawerCustom
isVisible={openDrawer}
closeDrawer={() => setOpenDrawer(false)}
height={"auto"}
>
<MenuDrawerDynamicGrid
data={[
{
icon: <IconPlus />,
label: "Tambah Berita",
path: `/donation/${donationId}/(news)/add-news`,
},
]}
onPressItem={(item) => {
console.log("PATH ", item.path);
router.navigate(item.path as any);
setOpenDrawer(false);
}}
/>
</DrawerCustom>
</>
);
}

View File

@@ -1,28 +1,29 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
LoaderCustom,
ScrollableCustom,
TextCustom,
} from "@/components";
import { ScrollableCustom } from "@/components";
import NewWrapper from "@/components/_ShareComponent/NewWrapper";
import { MainColor } from "@/constants/color-palet";
import { PAGINATION_DEFAULT_TAKE } from "@/constants/constans-value";
import { createPaginationComponents } from "@/helpers/paginationHelpers";
import { useAuth } from "@/hooks/use-auth";
import { usePagination } from "@/hooks/use-pagination";
import { dummyMasterStatus } from "@/lib/dummy-data/_master/status";
import Donasi_BoxStatus from "@/screens/Donation/BoxStatus";
import { usePagination } from "@/hooks/use-pagination";
import { apiDonationGetByStatus } from "@/service/api-client/api-donation";
import { useFocusEffect } from "expo-router";
import _ from "lodash";
import { useCallback, useState } from "react";
import { RefreshControl } from "react-native";
import { createPaginationComponents } from "@/helpers/paginationHelpers";
interface DonationStatusProps {
initialStatus?: string;
}
export default function Donation_ScreenStatus({ initialStatus = "publish" }: DonationStatusProps) {
export default function Donation_ScreenStatus({
initialStatus = "publish",
}: DonationStatusProps) {
const { user } = useAuth();
const [activeCategory, setActiveCategory] = useState<string | null>(initialStatus);
const [activeCategory, setActiveCategory] = useState<string | null>(
initialStatus,
);
const pagination = usePagination({
fetchFunction: async (page) => {
@@ -32,18 +33,19 @@ export default function Donation_ScreenStatus({ initialStatus = "publish" }: Don
page: String(page),
});
},
pageSize: 5, // Sesuaikan dengan jumlah item per halaman dari API
pageSize: PAGINATION_DEFAULT_TAKE, // Sesuaikan dengan jumlah item per halaman dari API
dependencies: [user?.id, activeCategory],
});
useFocusEffect(
useCallback(() => {
pagination.onRefresh();
}, [user?.id, activeCategory])
}, [user?.id, activeCategory]),
);
const handlePress = (item: any) => {
setActiveCategory(item.value);
pagination.reset();
};
const scrollComponent = (
@@ -59,21 +61,19 @@ export default function Donation_ScreenStatus({ initialStatus = "publish" }: Don
);
const renderItem = ({ item, index }: { item: any; index: number }) => (
<Donasi_BoxStatus
data={item}
status={activeCategory as string}
/>
<Donasi_BoxStatus data={item} status={activeCategory as string} />
);
const { ListEmptyComponent, ListFooterComponent } = createPaginationComponents({
loading: pagination.loading,
refreshing: pagination.refreshing,
listData: pagination.listData,
isInitialLoad: pagination.isInitialLoad,
emptyMessage: `Tidak ada data ${activeCategory}`,
skeletonCount: 5,
skeletonHeight: 200,
});
const { ListEmptyComponent, ListFooterComponent } =
createPaginationComponents({
loading: pagination.loading,
refreshing: pagination.refreshing,
listData: pagination.listData,
isInitialLoad: pagination.isInitialLoad,
emptyMessage: `Tidak ada data ${activeCategory}`,
skeletonCount: 5,
skeletonHeight: 120,
});
return (
<NewWrapper
@@ -83,13 +83,15 @@ export default function Donation_ScreenStatus({ initialStatus = "publish" }: Don
ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={ListFooterComponent}
refreshControl={
<RefreshControl
refreshing={pagination.refreshing}
onRefresh={pagination.onRefresh}
<RefreshControl
refreshing={pagination.refreshing}
onRefresh={pagination.onRefresh}
tintColor={MainColor.yellow}
colors={[MainColor.yellow]}
/>
}
hideFooter
headerComponent={scrollComponent}
/>
);
}
}

View File

@@ -52,15 +52,18 @@ export async function apiAdminDonationUpdateStatus({
export async function apiAdminDonationListOfDonatur({
id,
status,
page = "1"
}: {
id: string;
status: "berhasil" | "gagal" | "proses" | "menunggu" | null;
page?: string;
}) {
const query = status && status !== null ? `?status=${status}` : "";
const statusQuery = status && status !== null ? `&status=${status}` : "";
const pageQuery = `&page=${page}`;
try {
const response = await apiConfig.get(
`/mobile/admin/donation/${id}/donatur${query}`
`/mobile/admin/donation/${id}/donatur?${statusQuery}${pageQuery}`
);
return response.data;
} catch (error) {
@@ -105,19 +108,6 @@ export async function apiAdminDonationInvoiceUpdateById({
}
}
export async function apiAdminDonationListOfDonaturById({
id,
}: {
id: string;
}) {
try {
const response = await apiConfig.get(`/mobile/donation/${id}/donatur`);
return response.data;
} catch (error) {
throw error;
}
}
export async function apiAdminDonationDisbursementOfFundsCreated({
id,
data,

View File

@@ -3,15 +3,31 @@ import { apiConfig } from "../api-config";
export const apiAdminUserAccessGetAll = async ({
search,
category,
page = "1",
}: {
search?: string;
category: "only-user" | "only-admin" | "all-role";
page?: string;
}) => {
try {
const response = await apiConfig.get(`/mobile/admin/user?category=${category}&search=${search}`);
return response.data;
const response = await apiConfig.get(
`/mobile/admin/user?category=${category}&search=${search}&page=${page}`,
);
// Pastikan mengembalikan struktur data yang konsisten
return {
success: response.data.success,
message: response.data.message,
data: response.data.data || [], // Gunakan data yang sebenarnya atau array kosong
pagination: response.data.pagination, // Jika ada info pagination
};
} catch (error) {
console.log(error);
return {
success: false,
message: "Error fetching data",
data: [],
pagination: null,
};
}
};
@@ -36,12 +52,15 @@ export const apiAdminUserAccessUpdateStatus = async ({
category: "access" | "role";
}) => {
try {
const response = await apiConfig.put(`/mobile/admin/user/${id}?category=${category}`, {
data: {
active,
role,
const response = await apiConfig.put(
`/mobile/admin/user/${id}?category=${category}`,
{
data: {
active,
role,
},
},
});
);
return response.data;
} catch (error) {
console.log(error);

View File

@@ -1,9 +1,11 @@
import { apiConfig } from "../api-config";
// ================== START MASTER BANK ================== //
export async function apiAdminMasterBank() {
export async function apiAdminMasterBank({ page = "1" }: { page?: string }) {
try {
const response = await apiConfig.get(`/mobile/admin/master/bank`);
const response = await apiConfig.get(
`/mobile/admin/master/bank?page=${page}`,
);
return response.data;
} catch (error) {
throw error;
@@ -51,9 +53,15 @@ export async function apiAdminMasterBankCreate({ data }: { data: any }) {
// ================== START BUSINNES FIELD ================== //
export async function apiAdminMasterBusinessField() {
export async function apiAdminMasterBusinessField({
page = "1",
}: {
page: string;
}) {
try {
const response = await apiConfig.get(`/mobile/admin/master/business-field`);
const response = await apiConfig.get(
`/mobile/admin/master/business-field?page=${page}`,
);
return response.data;
} catch (error) {
throw error;
@@ -64,16 +72,19 @@ export async function apiAdminMasterBusinessFieldById({
id,
subBidangId,
category,
page = "1"
}: {
id: string;
subBidangId?: string | null;
category: "bidang" | "sub-bidang" | "all";
category: "bidang" | "sub-bidang" | "all" | "only-sub-bidang"
page?: string
}) {
const queryCategory = category ? `?category=${category}` : "";
const querySubBidang = subBidangId ? `&subBidangId=${subBidangId}` : "";
const queryPage = page ? `&page=${page}` : "";
try {
const response = await apiConfig.get(
`/mobile/admin/master/business-field/${id}${queryCategory}${querySubBidang}`
`/mobile/admin/master/business-field/${id}${queryCategory}${querySubBidang}${queryPage}`,
);
return response.data;
} catch (error) {
@@ -95,7 +106,7 @@ export async function apiAdminMasterBusinessFieldUpdate({
`/mobile/admin/master/business-field/${id}?category=${category}`,
{
data: data,
}
},
);
return response.data;
} catch (error) {
@@ -113,7 +124,7 @@ export async function apiAdminMasterBusinessFieldCreate({
`/mobile/admin/master/business-field`,
{
data: data,
}
},
);
return response.data;
} catch (error) {
@@ -139,7 +150,7 @@ export async function apiEventCreateTypeOfEvent({ data }: { data: string }) {
`/mobile/admin/master/type-of-event`,
{
data: data,
}
},
);
return response.data;
} catch (error) {
@@ -150,7 +161,7 @@ export async function apiEventCreateTypeOfEvent({ data }: { data: string }) {
export async function apiAdminMasterTypeOfEventGetOne({ id }: { id: string }) {
try {
const response = await apiConfig.get(
`/mobile/admin/master/type-of-event/${id}`
`/mobile/admin/master/type-of-event/${id}`,
);
return response.data;
} catch (error) {
@@ -170,7 +181,7 @@ export async function apiAdminMasterTypeOfEventUpdate({
`/mobile/admin/master/type-of-event/${id}`,
{
data: data,
}
},
);
return response.data;
} catch (error) {
@@ -216,7 +227,7 @@ export async function apiAdminMasterDonationCategoryUpdate({
`/mobile/admin/master/donation/${id}`,
{
data: data,
}
},
);
return response.data;
} catch (error) {
@@ -240,3 +251,27 @@ export async function apiAdminMasterDonationCategoryCreate({
}
// ================== END DONATION ================== //
// ================== START FECTH APP INFORMATION ================== //
export async function apiFetchAdminMasterAppInformation({
page = "1",
category,
}: {
page: string;
category?: "bank" | "business" | string
}) {
if (category === "bank") {
const response = await apiAdminMasterBank({ page });
// TODO: implement bank logic
return response;
} else if (category === "business") {
const response = await apiAdminMasterBusinessField({ page });
// TODO: implement business logic
return response
} else {
throw new Error("Category is required");
}
}
// ================== END FECTH APP INFORMATION ================== //

View File

@@ -109,14 +109,17 @@ export async function apiDonationUpdateData({
export async function apiDonationGetAll({
category,
authorId,
page = "1"
}: {
category: "beranda" | "my-donation";
authorId?: string;
page?: string;
}) {
const authorQuery = authorId ? `&authorId=${authorId}` : "";
const pageQuery = `&page=${page}`;
try {
const response = await apiConfig.get(
`/mobile/donation?category=${category}${authorQuery}`
`/mobile/donation?category=${category}${authorQuery}${pageQuery}`
);
return response.data;
} catch (error) {
@@ -219,13 +222,15 @@ export async function apiDonationCreateNews({
export async function apiDonationGetNewsById({
id,
category,
page = "1"
}: {
id: string;
category: "get-all" | "get-one";
page?: string;
}) {
try {
const response = await apiConfig.get(
`/mobile/donation/${id}/news?category=${category}`
`/mobile/donation/${id}/news?category=${category}&page=${page}`
);
return response.data;
} catch (error) {
@@ -261,11 +266,28 @@ export async function apiDonationDeleteNews({ id }: { id: string }) {
export async function apiDonationDisbursementOfFundsListById({
id,
page = "1"
}: {
id: string;
page?: string;
}) {
try {
const response = await apiConfig.get(`/mobile/donation/${id}/disbursement`);
const response = await apiConfig.get(`/mobile/donation/${id}/disbursement?page=${page}`);
return response.data;
} catch (error) {
throw error;
}
}
export async function apiDonationListOfDonaturById({
id,
page = "1"
}: {
id: string;
page?: string;
}) {
try {
const response = await apiConfig.get(`/mobile/donation/${id}/donatur?page=${page}`);
return response.data;
} catch (error) {
throw error;

View File

@@ -16,7 +16,7 @@ export const GStyles = StyleSheet.create({
// =============== Main Styles =============== //
container: {
flex: 1,
paddingInline: PADDING_MEDIUM,
paddingInline: PADDING_SMALL,
paddingTop: PADDING_EXTRA_SMALL,
paddingBottom: 5,
// paddingBlock: PADDING_EXTRA_SMALL,

View File

@@ -31,5 +31,5 @@ export const formatChatTime = (date: string | Date): string => {
}
// Lebih dari 7 hari lalu
return messageDate.format('DD - MM - YYYY, HH.mm'); // "05 - 11 - 2025, 14.00"
return messageDate.format('DD/MM/YYYY, HH.mm'); // "05/11/2025, 14.00"
};