Compare commits

..

54 Commits

Author SHA1 Message Date
e2ffef1085 Merge pull request 'amalia/23-apr-26' (#41) from amalia/23-apr-26 into join
Reviewed-on: #41
2026-04-23 17:32:41 +08:00
cb2a57ee8e feat: tambah versi aplikasi di bagian bawah halaman setting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 17:29:38 +08:00
f3b677f847 fix: ganti pesan error boundary dengan teks yang lebih ramah pengguna
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 11:26:33 +08:00
6ffe599ad0 docs: pecah CLAUDE.md jadi file terpisah di docs/
Pindahkan konten architecture dan conventions ke docs/ARCHITECTURE.md
dan docs/CONVENTIONS.md, lalu referensikan via @path di CLAUDE.md
agar file tetap ramping.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 11:19:49 +08:00
4a92def490 Merge pull request 'amalia/22-apr-26' (#40) from amalia/22-apr-26 into join
Reviewed-on: #40
2026-04-22 17:30:00 +08:00
0bad792ce8 feat: tambah pengecekan villageIsActive saat desa dinonaktifkan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 16:42:59 +08:00
6c9623954c feat: tambah pengecekan isActive dan bersihkan cache saat logout
- Tampilkan Alert jika admin menonaktifkan akun user yang sedang login
- Clear React Query cache saat signOut agar data akun lama tidak bocor ke sesi berikutnya

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 15:17:21 +08:00
f39d5a4c85 Merge pull request 'feat: tambah error logger ke monitoring dashboard dengan offline queue' (#39) from amalia/21-apr-26 into join
Reviewed-on: #39
2026-04-21 17:32:08 +08:00
6021d17b5a feat: tambah error logger ke monitoring dashboard dengan offline queue
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 15:25:10 +08:00
2d86a77a48 Merge pull request 'amalia/20-apr-26' (#38) from amalia/20-apr-26 into join
Reviewed-on: #38
2026-04-20 17:49:34 +08:00
b7165c5990 hapus komen 2026-04-20 17:45:19 +08:00
7dc51bd2b9 fix: variable yg tidak terpakai 2026-04-20 17:12:41 +08:00
de5ad545a7 upd: hapus console.log 2026-04-20 17:07:49 +08:00
47cb146c5a upd: claude 2026-04-20 17:05:40 +08:00
8b8ea61a13 fix: grafik bar 2026-04-20 16:27:44 +08:00
5dac451754 fix: grafik jumlah dokumen 2026-04-20 14:37:36 +08:00
ccf8ee1caf upd: caching data
Deskripsi:
- update caching pada fitur utama -yg fitur divisi belom
2026-04-20 14:23:14 +08:00
887e787a99 Merge pull request 'amalia/17-apr-26' (#37) from amalia/17-apr-26 into join
Reviewed-on: #37
2026-04-17 17:40:04 +08:00
772551a917 upd: page kosong atau data udah di hapus pada pengumuman 2026-04-17 16:46:49 +08:00
555b9e4037 upd: fix laopran grafik divisi 2026-04-17 15:26:06 +08:00
d4b4db4251 upd: fix: jumlah dokumen grafik pada home 2026-04-17 15:01:14 +08:00
17d92cba25 Merge pull request 'amalia/10-mar-26' (#36) from amalia/10-mar-26 into join
Reviewed-on: #36
2026-03-10 16:30:52 +08:00
e1b62be6da upd:build number 2026-03-10 15:31:57 +08:00
b2b125c410 upd: build eas 2026-03-10 15:19:40 +08:00
1cfecbbdd5 upd : border di android 2026-03-10 11:09:19 +08:00
21006e8eee upd: gambar caraousel 2026-03-10 11:02:26 +08:00
91231d60e4 upd: md 2026-03-09 16:43:04 +08:00
7174e27be1 upd: version dan prebuild 2026-03-09 14:17:36 +08:00
9d4b931aa6 Merge pull request 'amalia/05-mar-26' (#35) from amalia/05-mar-26 into join
Reviewed-on: #35
2026-03-05 16:19:03 +08:00
166d8f1c16 upd: notifikasi
Deskripsi:
- update notifikasi android

No Issues
2026-03-05 14:38:45 +08:00
7060a2d165 upd: otp
Deskripsi:
- link otp baru

NOIssues
2026-03-05 14:15:36 +08:00
d6217aecf1 Merge pull request 'upd: notifikasi' (#34) from amalia/04-mar-26 into join
Reviewed-on: #34
2026-03-04 16:39:05 +08:00
608381673f upd: notifikasi
Deskripsi:
- notifikasi saat allowed device
- ios dan android

No Issues
2026-03-04 16:35:59 +08:00
3cc7f76346 Merge pull request 'amalia/03-mar-26' (#33) from amalia/03-mar-26 into join
Reviewed-on: #33
2026-03-03 16:46:19 +08:00
868b712fbb upd: notifikasi
Deskripsi:
- belom selesai notifikasi

No Issues
2026-03-03 16:44:02 +08:00
a53b99b39d version 2026-03-03 10:57:55 +08:00
25d521f013 Merge pull request 'amalia/26-feb-26' (#32) from amalia/26-feb-26 into join
Reviewed-on: #32
2026-02-26 17:48:06 +08:00
aee0823cb1 upd: fiksasi 2026-02-26 17:42:33 +08:00
2a0e1f4c1f upd: bun lock 2026-02-26 14:54:51 +08:00
ef08c821fa Merge pull request 'upd: fiksasi' (#30) from amalia/25-feb-26 into join
Reviewed-on: #30
2026-02-25 16:09:31 +08:00
fd5d582092 upd: fiksasi
Deskripsi:
-tampilan

No Issues
2026-02-25 16:07:17 +08:00
7729dc38f8 Merge pull request 'amalia/24-feb-26' (#29) from amalia/24-feb-26 into join
Reviewed-on: #29
2026-02-24 18:01:29 +08:00
8c6ff06216 upd: toast alert 2026-02-24 17:44:49 +08:00
214a243e44 upd: upd version
Deskripsi:
- tampilan jika update versi terbaru atau sedang maintenance

NO Issues
2026-02-24 15:51:29 +08:00
449f6f96cc Merge pull request 'upd: redesign' (#28) from amalia/23-feb-26 into join
Reviewed-on: #28
2026-02-24 13:36:49 +08:00
e351f54f6c Merge pull request 'upd: redesign' (#27) from amalia/20-feb-26 into join
Reviewed-on: #27
2026-02-23 10:17:00 +08:00
d58a35bde2 Merge pull request 'amalia/19-feb-26' (#26) from amalia/19-feb-26 into join
Reviewed-on: #26
2026-02-20 10:50:38 +08:00
e2a601c590 Merge pull request 'amalia/18-feb-26' (#25) from amalia/18-feb-26 into join
Reviewed-on: #25
2026-02-18 17:32:10 +08:00
f0373ef479 Merge pull request 'upd: redesign' (#24) from amalia/13-feb-26 into join
Reviewed-on: http://wibugit.wibudev.com/wibu/mobile-darmasaba/pulls/24
2026-02-13 17:26:53 +08:00
700192dd8d Merge pull request 'upd: redesign' (#23) from amalia/12-feb-26 into join
Reviewed-on: http://wibugit.wibudev.com/wibu/mobile-darmasaba/pulls/23
2026-02-12 17:53:14 +08:00
27b0b7d51f Merge pull request 'redesign aplikasi' (#22) from amalia/11-feb-26 into join
Reviewed-on: http://wibugit.wibudev.com/wibu/mobile-darmasaba/pulls/22
2026-02-11 17:07:40 +08:00
65278df750 Merge pull request 'upd: redesign aplikasi' (#21) from amalia/10-feb-26 into join
Reviewed-on: http://wibugit.wibudev.com/wibu/mobile-darmasaba/pulls/21
2026-02-10 17:34:54 +08:00
8b98fee632 Merge pull request 'upd: redesign' (#20) from amalia/09-feb-25 into join
Reviewed-on: http://wibugit.wibudev.com/wibu/mobile-darmasaba/pulls/20
2026-02-09 17:50:22 +08:00
e254cf8ed2 Merge pull request 'upd: panduan' (#18) from amalia/06-feb-26 into join
Reviewed-on: http://wibugit.wibudev.com/wibu/mobile-darmasaba/pulls/18
2026-02-06 17:48:37 +08:00
139 changed files with 4936 additions and 1324 deletions

33
CLAUDE.md Normal file
View File

@@ -0,0 +1,33 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**Desa+** is a React Native (Expo) mobile app for village administration — managing announcements, projects, discussions, members, divisions, and documents. Primary platforms are Android and iOS.
## Commands
```bash
npm run start # Start Expo dev server
npm run android # Run on Android
npm run ios # Run on iOS
npm run lint # Expo lint
npm run test # Jest tests
npm run build:android # Production Android build via EAS (bumps version first)
```
Run a single test file:
```bash
bunx jest path/to/test.tsx --no-coverage
```
> Project uses **Bun** as the package manager (`bun.lock` present). Use `bun add` / `bunx` instead of `npm install` / `npx`.
## Architecture
See @docs/ARCHITECTURE.md
## Key Conventions
See @docs/CONVENTIONS.md

View File

@@ -1,6 +1,6 @@
# Desa+ # Desa+
Desa+ adalah aplikasi mobile berbasis React Native yang dikembangkan dengan Expo untuk membantu pengelolaan dan komunikasi di lingkungan desa/kelurahan. Aplikasi ini menyediakan berbagai fitur untuk memudahkan administrasi desa, komunikasi antar warga, dan pengelolaan informasi penting. Desa+ (Desa Plus) adalah aplikasi mobile berbasis React Native yang dikembangkan dengan Expo untuk membantu pengelolaan dan komunikasi di lingkungan desa/kelurahan. Aplikasi ini menyediakan berbagai fitur untuk memudahkan administrasi desa, komunikasi antar warga, dan pengelolaan informasi penting.
## Fitur Utama ## Fitur Utama

View File

@@ -0,0 +1,77 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import ErrorBoundary from '@/components/ErrorBoundary';
// Komponen yang sengaja throw error saat render
const BrokenComponent = () => {
throw new Error('Test error boundary!');
};
// Komponen normal
const NormalComponent = () => <></>;
// Suppress React's error boundary console output selama test
beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
(console.error as jest.Mock).mockRestore();
});
describe('ErrorBoundary', () => {
it('merender children dengan normal jika tidak ada error', () => {
// Tidak boleh throw dan tidak menampilkan teks error
const { queryByText } = render(
<ErrorBoundary>
<NormalComponent />
</ErrorBoundary>
);
expect(queryByText('Terjadi Kesalahan')).toBeNull();
});
it('menampilkan UI fallback ketika child throw error', () => {
const { getByText } = render(
<ErrorBoundary>
<BrokenComponent />
</ErrorBoundary>
);
expect(getByText('Terjadi Kesalahan')).toBeTruthy();
});
it('menampilkan pesan error yang dilempar', () => {
const { getByText } = render(
<ErrorBoundary>
<BrokenComponent />
</ErrorBoundary>
);
expect(getByText('Test error boundary!')).toBeTruthy();
});
it('merender custom fallback jika prop fallback diberikan', () => {
const { getByText } = render(
<ErrorBoundary fallback={<></>}>
<BrokenComponent />
</ErrorBoundary>
);
// Custom fallback fragment kosong — pastikan teks default tidak muncul
expect(() => getByText('Terjadi Kesalahan')).toThrow();
});
it('mereset error state saat tombol Coba Lagi ditekan', () => {
const { getByText } = render(
<ErrorBoundary>
<BrokenComponent />
</ErrorBoundary>
);
const button = getByText('Coba Lagi');
expect(button).toBeTruthy();
// Tekan tombol reset — hasError kembali false, BrokenComponent throw lagi
// sehingga fallback muncul kembali (membuktikan reset berjalan)
fireEvent.press(button);
expect(getByText('Terjadi Kesalahan')).toBeTruthy();
});
});

View File

@@ -92,8 +92,8 @@ android {
applicationId 'mobiledarmasaba.app' applicationId 'mobiledarmasaba.app'
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 6 versionCode 16
versionName "1.0.2" versionName "2.1.0"
} }
signingConfigs { signingConfigs {
debug { debug {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 904 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -1,9 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar"> <style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item> <item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
<item name="android:windowOptOutEdgeToEdgeEnforcement" tools:targetApi="35">true</item>
<item name="colorPrimary">@color/colorPrimary</item> <item name="colorPrimary">@color/colorPrimary</item>
<item name="android:statusBarColor">#ffffff</item> <item name="android:statusBarColor">#ffffff</item>
<item name="android:windowOptOutEdgeToEdgeEnforcement" tools:targetApi="35">true</item>
</style> </style>
<style name="Theme.App.SplashScreen" parent="Theme.SplashScreen"> <style name="Theme.App.SplashScreen" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/splashscreen_background</item> <item name="windowSplashScreenBackground">@color/splashscreen_background</item>

View File

@@ -4,7 +4,7 @@ export default {
expo: { expo: {
name: "Desa+", name: "Desa+",
slug: "mobile-darmasaba", slug: "mobile-darmasaba",
version: "2.0.5", // Versi aplikasi (App Store) version: "2.1.0", // Versi aplikasi (App Store)
jsEngine: "jsc", jsEngine: "jsc",
orientation: "portrait", orientation: "portrait",
icon: "./assets/images/logo-icon-small.png", icon: "./assets/images/logo-icon-small.png",
@@ -14,7 +14,7 @@ export default {
ios: { ios: {
supportsTablet: true, supportsTablet: true,
bundleIdentifier: "mobiledarmasaba.app", bundleIdentifier: "mobiledarmasaba.app",
buildNumber: "7", buildNumber: "9",
infoPlist: { infoPlist: {
ITSAppUsesNonExemptEncryption: false, ITSAppUsesNonExemptEncryption: false,
CFBundleDisplayName: "Desa+" CFBundleDisplayName: "Desa+"
@@ -23,7 +23,7 @@ export default {
}, },
android: { android: {
package: "mobiledarmasaba.app", package: "mobiledarmasaba.app",
versionCode: 15, versionCode: 16,
adaptiveIcon: { adaptiveIcon: {
foregroundImage: "./assets/images/logo-icon-small.png", foregroundImage: "./assets/images/logo-icon-small.png",
backgroundColor: "#ffffff" backgroundColor: "#ffffff"
@@ -85,6 +85,8 @@ export default {
FIREBASE_STORAGE_BUCKET: process.env.FIREBASE_STORAGE_BUCKET, FIREBASE_STORAGE_BUCKET: process.env.FIREBASE_STORAGE_BUCKET,
FIREBASE_MESSAGING_SENDER_ID: process.env.FIREBASE_MESSAGING_SENDER_ID, FIREBASE_MESSAGING_SENDER_ID: process.env.FIREBASE_MESSAGING_SENDER_ID,
FIREBASE_APP_ID: process.env.FIREBASE_APP_ID, FIREBASE_APP_ID: process.env.FIREBASE_APP_ID,
URL_MONITORING: process.env.URL_MONITORING,
KEY_API_MONITORING: process.env.KEY_API_MONITORING,
} }
} }
}; };

View File

@@ -9,16 +9,18 @@ import HeaderRightPositionList from "@/components/position/headerRightPositionLi
import HeaderRightProjectList from "@/components/project/headerProjectList"; import HeaderRightProjectList from "@/components/project/headerProjectList";
import Text from "@/components/Text"; import Text from "@/components/Text";
import ToastCustom from "@/components/toastCustom"; import ToastCustom from "@/components/toastCustom";
import { apiReadOneNotification } from "@/lib/api"; import ModalUpdateMaintenance from "@/components/ModalUpdateMaintenance";
import { apiGetVersion, apiReadOneNotification } from "@/lib/api";
import { pushToPage } from "@/lib/pushToPage"; import { pushToPage } from "@/lib/pushToPage";
import store from "@/lib/store"; import store from "@/lib/store";
import { useAuthSession } from "@/providers/AuthProvider"; import { useAuthSession } from "@/providers/AuthProvider";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import Constants from "expo-constants";
import { getApp } from "@react-native-firebase/app"; import { getApp } from "@react-native-firebase/app";
import { getMessaging, onMessage } from "@react-native-firebase/messaging"; import { getMessaging, onMessage } from "@react-native-firebase/messaging";
import { Redirect, router, Stack, usePathname } from "expo-router"; import { Redirect, router, Stack, usePathname } from "expo-router";
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { Easing, Notifier, NotifierComponents } from 'react-native-notifier'; import { Easing, Notifier, NotifierComponents } from 'react-native-notifier';
import { Provider } from "react-redux"; import { Provider } from "react-redux";
import { useTheme } from "@/providers/ThemeProvider"; import { useTheme } from "@/providers/ThemeProvider";
@@ -27,12 +29,86 @@ export default function RootLayout() {
const { token, decryptToken, isLoading } = useAuthSession() const { token, decryptToken, isLoading } = useAuthSession()
const pathname = usePathname() const pathname = usePathname()
const { colors } = useTheme() const { colors } = useTheme()
const [modalUpdateMaintenance, setModalUpdateMaintenance] = useState(false)
const [modalType, setModalType] = useState<'update' | 'maintenance'>('update')
const [isForceUpdate, setIsForceUpdate] = useState(false)
const [updateMessage, setUpdateMessage] = useState('')
const currentVersion = Constants.expoConfig?.version ?? '0.0.0'
const compareVersions = (v1: string, v2: string) => {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
const p1 = parts1[i] || 0;
const p2 = parts2[i] || 0;
if (p1 < p2) return -1;
if (p1 > p2) return 1;
}
return 0;
};
useEffect(() => {
const checkVersion = async () => {
try {
const response = await apiGetVersion();
if (response.success && response.data) {
const maintenance = response.data.find((item: any) => item.id === 'mobile_maintenance')?.value === 'true';
const latestVersion = response.data.find((item: any) => item.id === 'mobile_latest_version')?.value || '0.0.0';
const minVersion = response.data.find((item: any) => item.id === 'mobile_minimum_version')?.value || '0.0.0';
const message = response.data.find((item: any) => item.id === 'mobile_message_update')?.value || '';
if (maintenance) {
setModalType('maintenance');
setModalUpdateMaintenance(true);
setIsForceUpdate(true);
return;
}
if (compareVersions(currentVersion, minVersion) === -1) {
setModalType('update');
setIsForceUpdate(true);
setUpdateMessage(message);
setModalUpdateMaintenance(true);
} else if (compareVersions(currentVersion, latestVersion) === -1) {
// Check if this soft update version was already dismissed
const dismissedVersion = await AsyncStorage.getItem('dismissed_update_version');
if (dismissedVersion !== latestVersion) {
setModalType('update');
setIsForceUpdate(false);
setUpdateMessage(message);
setModalUpdateMaintenance(true);
}
}
}
} catch (error) {
console.error('Failed to check version:', error);
}
};
checkVersion();
}, [currentVersion]);
const handleDismissUpdate = async () => {
if (!isForceUpdate) {
try {
const response = await apiGetVersion();
const latestVersion = response.data.find((item: any) => item.id === 'mobile_latest_version')?.value;
if (latestVersion) {
await AsyncStorage.setItem('dismissed_update_version', latestVersion);
}
} catch (e) {
console.error(e);
}
setModalUpdateMaintenance(false);
}
}
async function handleReadNotification(id: string, category: string, idContent: string, title: string) { async function handleReadNotification(id: string, category: string, idContent: string, title: string) {
try { try {
if (title != "Komentar Baru") { if (title !== "Komentar Baru") {
const hasil = await decryptToken(String(token?.current)) const hasil = await decryptToken(String(token?.current))
const response = await apiReadOneNotification({ user: hasil, id: id }) await apiReadOneNotification({ user: hasil, id: id })
} }
pushToPage(category, idContent) pushToPage(category, idContent)
} catch (error) { } catch (error) {
@@ -127,8 +203,6 @@ export default function RootLayout() {
<Stack.Screen name="feature" options={{ title: 'Fitur' }} /> <Stack.Screen name="feature" options={{ title: 'Fitur' }} />
<Stack.Screen name="search" options={{ title: 'Pencarian' }} /> <Stack.Screen name="search" options={{ title: 'Pencarian' }} />
<Stack.Screen name="notification" options={{ <Stack.Screen name="notification" options={{
title: 'Notifikasi',
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
headerTitle: 'Notifikasi', headerTitle: 'Notifikasi',
headerTitleAlign: 'center', headerTitleAlign: 'center',
header: () => ( header: () => (
@@ -137,10 +211,8 @@ export default function RootLayout() {
}} /> }} />
<Stack.Screen name="profile" options={{ title: 'Profile' }} /> <Stack.Screen name="profile" options={{ title: 'Profile' }} />
<Stack.Screen name="setting/index" options={{ <Stack.Screen name="setting/index" options={{
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
title: 'Pengaturan', title: 'Pengaturan',
headerTitleAlign: 'center', headerTitleAlign: 'center',
// headerRight: () => <HeaderRightProjectList />
header: () => ( header: () => (
<AppHeader title="Pengaturan" <AppHeader title="Pengaturan"
showBack={true} showBack={true}
@@ -149,10 +221,8 @@ export default function RootLayout() {
) )
}} /> }} />
<Stack.Screen name="member/index" options={{ <Stack.Screen name="member/index" options={{
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
title: 'Anggota', title: 'Anggota',
headerTitleAlign: 'center', headerTitleAlign: 'center',
// headerRight: () => <HeaderMemberList />
header: () => ( header: () => (
<AppHeader title="Anggota" <AppHeader title="Anggota"
showBack={true} showBack={true}
@@ -162,10 +232,8 @@ export default function RootLayout() {
) )
}} /> }} />
<Stack.Screen name="discussion/index" options={{ <Stack.Screen name="discussion/index" options={{
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
title: 'Diskusi Umum', title: 'Diskusi Umum',
headerTitleAlign: 'center', headerTitleAlign: 'center',
// headerRight: () => <HeaderDiscussionGeneral />
header: () => ( header: () => (
<AppHeader <AppHeader
title="Diskusi Umum" title="Diskusi Umum"
@@ -176,10 +244,8 @@ export default function RootLayout() {
) )
}} /> }} />
<Stack.Screen name="project/index" options={{ <Stack.Screen name="project/index" options={{
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
title: 'Kegiatan', title: 'Kegiatan',
headerTitleAlign: 'center', headerTitleAlign: 'center',
// headerRight: () => <HeaderRightProjectList />
header: () => ( header: () => (
<AppHeader title="Kegiatan" <AppHeader title="Kegiatan"
showBack={true} showBack={true}
@@ -189,10 +255,8 @@ export default function RootLayout() {
) )
}} /> }} />
<Stack.Screen name="division/index" options={{ <Stack.Screen name="division/index" options={{
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
title: 'Divisi', title: 'Divisi',
headerTitleAlign: 'center', headerTitleAlign: 'center',
// headerRight: () => <HeaderRightDivisionList />
header: () => ( header: () => (
<AppHeader title="Divisi" <AppHeader title="Divisi"
showBack={true} showBack={true}
@@ -203,10 +267,8 @@ export default function RootLayout() {
}} /> }} />
<Stack.Screen name="division/[id]/(fitur-division)" options={{ headerShown: false }} /> <Stack.Screen name="division/[id]/(fitur-division)" options={{ headerShown: false }} />
<Stack.Screen name="group/index" options={{ <Stack.Screen name="group/index" options={{
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
headerTitle: 'Lembaga Desa', headerTitle: 'Lembaga Desa',
headerTitleAlign: 'center', headerTitleAlign: 'center',
// headerRight: () => <HeaderRightGroupList />
header: () => ( header: () => (
<AppHeader title="Lembaga Desa" <AppHeader title="Lembaga Desa"
showBack={true} showBack={true}
@@ -216,10 +278,8 @@ export default function RootLayout() {
) )
}} /> }} />
<Stack.Screen name="position/index" options={{ <Stack.Screen name="position/index" options={{
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
headerTitle: 'Jabatan', headerTitle: 'Jabatan',
headerTitleAlign: 'center', headerTitleAlign: 'center',
// headerRight: () => <HeaderRightPositionList />
header: () => ( header: () => (
<AppHeader title="Jabatan" <AppHeader title="Jabatan"
showBack={true} showBack={true}
@@ -230,10 +290,8 @@ export default function RootLayout() {
}} /> }} />
<Stack.Screen name="announcement/index" <Stack.Screen name="announcement/index"
options={{ options={{
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
headerTitle: 'Pengumuman', headerTitle: 'Pengumuman',
headerTitleAlign: 'center', headerTitleAlign: 'center',
// headerRight: () => <HeaderRightAnnouncementList />
header: () => ( header: () => (
<AppHeader title="Pengumuman" <AppHeader title="Pengumuman"
showBack={true} showBack={true}
@@ -246,6 +304,13 @@ export default function RootLayout() {
</Stack> </Stack>
<StatusBar style={'light'} translucent={false} backgroundColor="black" /> <StatusBar style={'light'} translucent={false} backgroundColor="black" />
<ToastCustom /> <ToastCustom />
<ModalUpdateMaintenance
visible={modalUpdateMaintenance}
type={modalType}
isForceUpdate={isForceUpdate}
customDescription={updateMessage}
onDismiss={handleDismissUpdate}
/>
</Provider> </Provider>
) )
} }

View File

@@ -3,6 +3,7 @@ import AppHeader from "@/components/AppHeader";
import BorderBottomItem from "@/components/borderBottomItem"; import BorderBottomItem from "@/components/borderBottomItem";
import Skeleton from "@/components/skeleton"; import Skeleton from "@/components/skeleton";
import Text from '@/components/Text'; import Text from '@/components/Text';
import ErrorView from "@/components/ErrorView";
import { ConstEnv } from "@/constants/ConstEnv"; import { ConstEnv } from "@/constants/ConstEnv";
import { isImageFile } from "@/constants/FileExtensions"; import { isImageFile } from "@/constants/FileExtensions";
import Styles from "@/constants/Styles"; import Styles from "@/constants/Styles";
@@ -65,6 +66,7 @@ export default function DetailAnnouncement() {
const [loadingOpen, setLoadingOpen] = useState(false) const [loadingOpen, setLoadingOpen] = useState(false)
const [preview, setPreview] = useState(false) const [preview, setPreview] = useState(false)
const [chooseFile, setChooseFile] = useState<FileData>() const [chooseFile, setChooseFile] = useState<FileData>()
const [isError, setIsError] = useState(false)
/** /**
* Opens the image preview modal for the selected image file * Opens the image preview modal for the selected image file
@@ -79,6 +81,7 @@ export default function DetailAnnouncement() {
async function handleLoad(loading: boolean) { async function handleLoad(loading: boolean) {
try { try {
setIsError(false)
setLoading(loading) setLoading(loading)
const hasil = await decryptToken(String(token?.current)) const hasil = await decryptToken(String(token?.current))
const response: ApiResponse = await apiGetAnnouncementOne({ id: id, user: hasil }) const response: ApiResponse = await apiGetAnnouncementOne({ id: id, user: hasil })
@@ -87,11 +90,15 @@ export default function DetailAnnouncement() {
setDataMember(response.member) setDataMember(response.member)
setDataFile(response.file) setDataFile(response.file)
} else { } else {
setIsError(true)
Toast.show({ type: 'small', text1: response.message }) Toast.show({ type: 'small', text1: response.message })
} }
} catch (error) { } catch (error: any) {
console.error(error) console.error(error);
Toast.show({ type: 'small', text1: 'Gagal mengambil data' }) setIsError(true)
const message = error?.response?.data?.message || "Gagal mengambil data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -204,104 +211,110 @@ export default function DetailAnnouncement() {
/> />
} }
> >
<View style={[Styles.p15, Styles.mb50]}> {isError && !loading ? (
<View style={[Styles.wrapPaper, Styles.borderAll, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}> <View style={[Styles.mv50]}>
{ <ErrorView />
loading ?
<View>
<View style={[Styles.rowOnly]}>
<Skeleton width={30} height={30} borderRadius={10} />
<View style={[Styles.flex1, Styles.ph05]}>
<Skeleton width={100} widthType="percent" height={30} borderRadius={10} />
</View>
</View>
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
</View>
:
<>
<View style={[Styles.rowOnly, Styles.alignStart]}>
<MaterialIcons name="campaign" size={25} color={colors.text} style={[Styles.mr05]} />
<Text style={[Styles.textDefaultSemiBold, Styles.w90, Styles.mt02]}>{data?.title}</Text>
</View>
<View style={[Styles.mt10]}>
{
hasHtmlTags(data?.desc) ?
<RenderHTML
contentWidth={contentWidth}
source={{ html: data?.desc }}
baseStyle={{ color: colors.text }}
/>
:
<Text>{data?.desc}</Text>
}
</View>
</>
}
</View> </View>
{ ) : (
dataFile.length > 0 && ( <View style={[Styles.p15, Styles.mb50]}>
<View style={[Styles.wrapPaper, Styles.borderAll, Styles.mt10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}> <View style={[Styles.wrapPaper, Styles.borderAll, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
<View style={[Styles.mb05]}> {
<Text style={[Styles.textDefaultSemiBold]}>File</Text> loading ?
</View> <View>
{dataFile.map((item, index) => ( <View style={[Styles.rowOnly]}>
<BorderBottomItem <Skeleton width={30} height={30} borderRadius={10} />
key={`${item.id}-${index}`} <View style={[Styles.flex1, Styles.ph05]}>
borderType="bottom" <Skeleton width={100} widthType="percent" height={30} borderRadius={10} />
icon={<MaterialCommunityIcons </View>
name={isImageFile(item.extension) ? "file-image-outline" : "file-document-outline"}
size={25}
color={colors.text}
/>}
title={item.name + '.' + item.extension}
titleWeight="normal"
onPress={() => {
isImageFile(item.extension) ?
handleChooseFile(item)
: openFile(item)
}}
/>
))}
</View>
)
}
<View style={[Styles.wrapPaper, Styles.borderAll, Styles.mt10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
loading ?
arrSkeleton.map((item, index) => {
return (
<View key={index}>
<Skeleton width={30} widthType="percent" height={10} borderRadius={10} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
</View> </View>
) <Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
}) <Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
: <Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
Object.keys(dataMember).map((v: any, i: any) => { </View>
return ( :
<View key={i} style={[Styles.mb05]}> <>
<Text style={[Styles.textDefaultSemiBold]}>{dataMember[v]?.[0].group}</Text> <View style={[Styles.rowOnly, Styles.alignStart]}>
<MaterialIcons name="campaign" size={25} color={colors.text} style={[Styles.mr05]} />
<Text style={[Styles.textDefaultSemiBold, Styles.w90, Styles.mt02]}>{data?.title}</Text>
</View>
<View style={[Styles.mt10]}>
{ {
dataMember[v].map((item: any, x: any) => { hasHtmlTags(data?.desc) ?
return ( <RenderHTML
<View key={x} style={[Styles.rowItemsCenter, Styles.w90]}> contentWidth={contentWidth}
<Entypo name="dot-single" size={24} color={colors.text} /> source={{ html: data?.desc }}
<Text style={[Styles.textDefault]} numberOfLines={1} ellipsizeMode='tail'>{item.division}</Text> baseStyle={{ color: colors.text }}
</View> />
) :
}) <Text>{data?.desc}</Text>
} }
</View> </View>
) </>
}) }
</View>
{
dataFile.length > 0 && (
<View style={[Styles.wrapPaper, Styles.borderAll, Styles.mt10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
<View style={[Styles.mb05]}>
<Text style={[Styles.textDefaultSemiBold]}>File</Text>
</View>
{dataFile.map((item, index) => (
<BorderBottomItem
key={`${item.id}-${index}`}
borderType={index === dataFile.length - 1 ? 'none' : 'bottom'}
icon={<MaterialCommunityIcons
name={isImageFile(item.extension) ? "file-image-outline" : "file-document-outline"}
size={25}
color={colors.text}
/>}
title={item.name + '.' + item.extension}
titleWeight="normal"
onPress={() => {
isImageFile(item.extension) ?
handleChooseFile(item)
: openFile(item)
}}
/>
))}
</View>
)
} }
<View style={[Styles.wrapPaper, Styles.borderAll, Styles.mt10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
loading ?
arrSkeleton.map((item, index) => {
return (
<View key={index}>
<Skeleton width={30} widthType="percent" height={10} borderRadius={10} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
</View>
)
})
:
Object.keys(dataMember).map((v: any, i: any) => {
return (
<View key={i} style={[Styles.mb05]}>
<Text style={[Styles.textDefaultSemiBold]}>{dataMember[v]?.[0].group}</Text>
{
dataMember[v].map((item: any, x: any) => {
return (
<View key={x} style={[Styles.rowItemsCenter, Styles.w90]}>
<Entypo name="dot-single" size={24} color={colors.text} />
<Text style={[Styles.textDefault]} numberOfLines={1} ellipsizeMode='tail'>{item.division}</Text>
</View>
)
})
}
</View>
)
})
}
</View>
</View> </View>
</View> )}
</ScrollView> </ScrollView>
<ImageViewing <ImageViewing

View File

@@ -100,11 +100,16 @@ export default function CreateAnnouncement() {
Toast.show({ type: 'small', text1: 'Berhasil menambahkan data', }) Toast.show({ type: 'small', text1: 'Berhasil menambahkan data', })
router.back(); router.back();
} else { } else {
Toast.show({ type: 'small', text1: 'Gagal menambahkan data', }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
Toast.show({ type: 'small', text1: 'Gagal menambahkan data', }) const message = error?.response?.data?.message || "Tidak dapat terhubung ke server"
Toast.show({
type: 'small',
text1: message
})
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -147,9 +147,11 @@ export default function EditAnnouncement() {
} else { } else {
Toast.show({ type: 'small', text1: 'Gagal mengubah data', }) Toast.show({ type: 'small', text1: 'Gagal mengubah data', })
} }
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
Toast.show({ type: 'small', text1: 'Gagal mengubah data', }) const message = error?.response?.data?.message || "Gagal mengubah data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -309,7 +311,7 @@ export default function EditAnnouncement() {
<View style={[Styles.rowSpaceBetween, Styles.mv05]}> <View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>Divisi</Text> <Text style={[Styles.textDefaultSemiBold]}>Divisi</Text>
</View> </View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, {backgroundColor: colors.card, borderColor: colors.icon + '20' }]}> <View style={[Styles.borderAll, Styles.round05, Styles.p10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{ {
dataMember.map((item: { name: any; Division: any }, index: any) => { dataMember.map((item: { name: any; Division: any }, index: any) => {
return ( return (

View File

@@ -2,14 +2,14 @@ import BorderBottomItem from "@/components/borderBottomItem";
import InputSearch from "@/components/inputSearch"; import InputSearch from "@/components/inputSearch";
import SkeletonContent from "@/components/skeletonContent"; import SkeletonContent from "@/components/skeletonContent";
import Text from '@/components/Text'; import Text from '@/components/Text';
import { ColorsStatus } from "@/constants/ColorsStatus";
import Styles from "@/constants/Styles"; import Styles from "@/constants/Styles";
import { apiGetAnnouncement } from "@/lib/api"; import { apiGetAnnouncement } from "@/lib/api";
import { useAuthSession } from "@/providers/AuthProvider"; import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider"; import { useTheme } from "@/providers/ThemeProvider";
import { MaterialIcons } from "@expo/vector-icons"; import { MaterialIcons } from "@expo/vector-icons";
import { useInfiniteQuery } from "@tanstack/react-query";
import { router } from "expo-router"; import { router } from "expo-router";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { RefreshControl, View, VirtualizedList } from "react-native"; import { RefreshControl, View, VirtualizedList } from "react-native";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
@@ -20,68 +20,60 @@ type Props = {
createdAt: string createdAt: string
} }
export default function Announcement() { export default function Announcement() {
const { token, decryptToken } = useAuthSession() const { token, decryptToken } = useAuthSession()
const { colors } = useTheme(); const { colors } = useTheme();
const [data, setData] = useState<Props[]>([])
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const update = useSelector((state: any) => state.announcementUpdate) const update = useSelector((state: any) => state.announcementUpdate)
const [loading, setLoading] = useState(true)
const arrSkeleton = Array.from({ length: 5 }, (_, index) => index) const arrSkeleton = Array.from({ length: 5 }, (_, index) => index)
const [page, setPage] = useState(1)
const [waiting, setWaiting] = useState(false)
const [refreshing, setRefreshing] = useState(false)
async function handleLoad(loading: boolean, thisPage: number) { // TanStack Query Infinite Query
try { const {
setWaiting(true) data,
setLoading(loading) fetchNextPage,
setPage(thisPage) hasNextPage,
isFetchingNextPage,
isLoading,
refetch,
isRefetching
} = useInfiniteQuery({
queryKey: ['announcements', search],
queryFn: async ({ pageParam = 1 }) => {
const hasil = await decryptToken(String(token?.current)) const hasil = await decryptToken(String(token?.current))
const response = await apiGetAnnouncement({ user: hasil, search: search, page: thisPage }) const response = await apiGetAnnouncement({
if (thisPage == 1) { user: hasil,
setData(response.data) search: search,
} else if (thisPage > 1 && response.data.length > 0) { page: pageParam
setData([...data, ...response.data]) })
} else { return response.data
return; },
} initialPageParam: 1,
} catch (error) { getNextPageParam: (lastPage, allPages) => {
console.error(error) return lastPage.length > 0 ? allPages.length + 1 : undefined
} finally { },
setLoading(false) })
setWaiting(false)
}
}
// Trigger refetch when Redux state 'update' changes
useEffect(() => { useEffect(() => {
handleLoad(false, 1) refetch()
}, [update]) }, [update, refetch])
useEffect(() => { // Flatten data from pages
handleLoad(true, 1) const flattenedData = useMemo(() => {
}, [search]) return data?.pages.flat() || []
}, [data])
const loadMoreData = () => { const loadMoreData = () => {
if (waiting) return if (hasNextPage && !isFetchingNextPage) {
setTimeout(() => { fetchNextPage()
handleLoad(false, page + 1) }
}, 1000);
};
const handleRefresh = async () => {
setRefreshing(true)
handleLoad(false, 1)
await new Promise(resolve => setTimeout(resolve, 2000));
setRefreshing(false)
}; };
const getItem = (_data: unknown, index: number): Props => ({ const getItem = (_data: unknown, index: number): Props => ({
id: data[index].id, id: flattenedData[index].id,
title: data[index].title, title: flattenedData[index].title,
desc: data[index].desc, desc: flattenedData[index].desc,
createdAt: data[index].createdAt, createdAt: flattenedData[index].createdAt,
}) })
return ( return (
@@ -91,18 +83,18 @@ export default function Announcement() {
</View> </View>
<View style={[Styles.flex2, Styles.mt05]}> <View style={[Styles.flex2, Styles.mt05]}>
{ {
loading ? isLoading && !flattenedData.length ?
arrSkeleton.map((item, index) => { arrSkeleton.map((item, index) => {
return ( return (
<SkeletonContent key={index} /> <SkeletonContent key={index} />
) )
}) })
: :
data.length > 0 flattenedData.length > 0
? ?
<VirtualizedList <VirtualizedList
data={data} data={flattenedData}
getItemCount={() => data.length} getItemCount={() => flattenedData.length}
getItem={getItem} getItem={getItem}
renderItem={({ item, index }: { item: Props, index: number }) => { renderItem={({ item, index }: { item: Props, index: number }) => {
return ( return (
@@ -112,9 +104,7 @@ export default function Announcement() {
borderType="bottom" borderType="bottom"
bgColor="transparent" bgColor="transparent"
icon={ icon={
// <View style={[Styles.iconContent]}>
<MaterialIcons name="campaign" size={25} color={colors.text} /> <MaterialIcons name="campaign" size={25} color={colors.text} />
// </View>
} }
title={item.title} title={item.title}
desc={item.desc.replace(/<[^>]*>?/gm, '').replace(/\r?\n|\r/g, ' ')} desc={item.desc.replace(/<[^>]*>?/gm, '').replace(/\r?\n|\r/g, ' ')}
@@ -122,14 +112,14 @@ export default function Announcement() {
/> />
) )
}} }}
keyExtractor={(item, index) => String(index)} keyExtractor={(item, index) => String(item.id || index)}
onEndReached={loadMoreData} onEndReached={loadMoreData}
onEndReachedThreshold={0.5} onEndReachedThreshold={0.5}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={refreshing} refreshing={isRefetching && !isFetchingNextPage}
onRefresh={handleRefresh} onRefresh={refetch}
tintColor={colors.icon} tintColor={colors.icon}
/> />
} }

View File

@@ -106,9 +106,11 @@ export default function EditBanner() {
} else { } else {
Toast.show({ type: 'small', text1: 'Gagal mengupdate data', }) Toast.show({ type: 'small', text1: 'Gagal mengupdate data', })
} }
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
Toast.show({ type: 'small', text1: 'Gagal mengupdate data', }) const message = error?.response?.data?.message || "Gagal mengupdate data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -88,9 +88,11 @@ export default function CreateBanner() {
} else { } else {
Toast.show({ type: 'small', text1: 'Gagal menambahkan data', }) Toast.show({ type: 'small', text1: 'Gagal menambahkan data', })
} }
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
Toast.show({ type: 'small', text1: 'Gagal menambahkan data', }) const message = error?.response?.data?.message || "Gagal menambahkan data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -1,3 +1,4 @@
import styles from "@/components/AppHeader"
import AppHeader from "@/components/AppHeader" import AppHeader from "@/components/AppHeader"
import HeaderRightBannerList from "@/components/banner/headerBannerList" import HeaderRightBannerList from "@/components/banner/headerBannerList"
import BorderBottomItem from "@/components/borderBottomItem" import BorderBottomItem from "@/components/borderBottomItem"
@@ -5,6 +6,7 @@ import DrawerBottom from "@/components/drawerBottom"
import MenuItemRow from "@/components/menuItemRow" import MenuItemRow from "@/components/menuItemRow"
import ModalConfirmation from "@/components/ModalConfirmation" import ModalConfirmation from "@/components/ModalConfirmation"
import ModalLoading from "@/components/modalLoading" import ModalLoading from "@/components/modalLoading"
import Skeleton from "@/components/skeleton"
import Text from "@/components/Text" import Text from "@/components/Text"
import { ConstEnv } from "@/constants/ConstEnv" import { ConstEnv } from "@/constants/ConstEnv"
import Styles from "@/constants/Styles" import Styles from "@/constants/Styles"
@@ -13,11 +15,12 @@ import { setEntities } from "@/lib/bannerSlice"
import { useAuthSession } from "@/providers/AuthProvider" import { useAuthSession } from "@/providers/AuthProvider"
import { useTheme } from "@/providers/ThemeProvider" import { useTheme } from "@/providers/ThemeProvider"
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons" import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import * as FileSystem from 'expo-file-system' import * as FileSystem from 'expo-file-system'
import { startActivityAsync } from 'expo-intent-launcher' import { startActivityAsync } from 'expo-intent-launcher'
import { router, Stack } from "expo-router" import { router, Stack } from "expo-router"
import * as Sharing from 'expo-sharing' import * as Sharing from 'expo-sharing'
import { useState } from "react" import { useEffect, useState } from "react"
import { Alert, Image, Platform, RefreshControl, SafeAreaView, ScrollView, View } from "react-native" import { Alert, Image, Platform, RefreshControl, SafeAreaView, ScrollView, View } from "react-native"
import ImageViewing from 'react-native-image-viewing' import ImageViewing from 'react-native-image-viewing'
import * as mime from 'react-native-mime-types' import * as mime from 'react-native-mime-types'
@@ -43,34 +46,51 @@ export default function BannerList() {
const [loadingOpen, setLoadingOpen] = useState(false) const [loadingOpen, setLoadingOpen] = useState(false)
const [viewImg, setViewImg] = useState(false) const [viewImg, setViewImg] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false)
const queryClient = useQueryClient()
const handleDeleteEntity = async () => { // 1. Fetching logic with useQuery
try { const { data: bannersRes, isLoading } = useQuery({
const hasil = await decryptToken(String(token?.current)); queryKey: ['banners'],
const deletedEntity = await apiDeleteBanner({ user: hasil }, dataId); queryFn: async () => {
if (deletedEntity.success) { const hasil = await decryptToken(String(token?.current))
Toast.show({ type: 'small', text1: 'Berhasil menghapus data', }) const response = await apiGetBanner({ user: hasil })
apiGetBanner({ user: hasil }).then((data) => return response.data || []
dispatch(setEntities(data.data)) },
); enabled: !!token?.current,
} else { staleTime: 0,
Toast.show({ type: 'small', text1: 'Gagal menghapus data', }) })
}
} catch (error) { // Sync results with Redux
console.error(error) useEffect(() => {
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) if (bannersRes) {
} finally { dispatch(setEntities(bannersRes))
setModal(false)
} }
}, [bannersRes, dispatch])
// 2. Deletion logic with useMutation
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
const hasil = await decryptToken(String(token?.current))
return await apiDeleteBanner({ user: hasil }, id)
},
onSuccess: () => {
Toast.show({ type: 'small', text1: 'Berhasil menghapus data' })
queryClient.invalidateQueries({ queryKey: ['banners'] })
},
onError: (error: any) => {
const message = error?.response?.data?.message || "Gagal menghapus data"
Toast.show({ type: 'small', text1: message })
}
})
const handleDeleteEntity = () => {
deleteMutation.mutate(dataId)
setModal(false)
}; };
const handleRefresh = async () => { const handleRefresh = async () => {
setRefreshing(true) setRefreshing(true)
const hasil = await decryptToken(String(token?.current)); await queryClient.invalidateQueries({ queryKey: ['banners'] })
apiGetBanner({ user: hasil }).then((data) =>
dispatch(setEntities(data.data))
);
await new Promise(resolve => setTimeout(resolve, 2000));
setRefreshing(false) setRefreshing(false)
}; };
@@ -138,36 +158,40 @@ export default function BannerList() {
} }
style={[Styles.h100, { backgroundColor: colors.background }]} style={[Styles.h100, { backgroundColor: colors.background }]}
> >
{ <View style={[Styles.p15, Styles.mb100]}>
entities.length > 0 {
? isLoading ? (
<View style={[Styles.p15, Styles.mb100]}> <>
{entities.map((index: any, key: number) => ( <Skeleton width={100} height={150} borderRadius={10} widthType="percent" />
<BorderBottomItem <Skeleton width={100} height={150} borderRadius={10} widthType="percent" />
key={key} <Skeleton width={100} height={150} borderRadius={10} widthType="percent" />
onPress={() => { </>
setDataId(index.id) ) :
setSelectFile(index) entities.length > 0 ?
setModal(true) entities.map((index: any, key: number) => (
}} <BorderBottomItem
borderType="all" key={key}
icon={ onPress={() => {
<Image setDataId(index.id)
source={{ uri: `${ConstEnv.url_storage}/files/${index.image}` }} setSelectFile(index)
style={[Styles.imgListBanner]} setModal(true)
/> }}
} borderType="all"
title={index.title} icon={
/> <Image
))} source={{ uri: `${ConstEnv.url_storage}/files/${index.image}` }}
</View> style={[Styles.imgListBanner]}
: />
<View style={[Styles.p15, Styles.mb100]}> }
<Text style={[Styles.textDefault, Styles.textCenter]}>Tidak ada data</Text> title={index.title}
</View> />
} ))
:
<View style={[Styles.p15, Styles.mb100]}>
<Text style={[Styles.textDefault, Styles.textCenter]}>Tidak ada data</Text>
</View>
}
</View>
</ScrollView> </ScrollView>
<DrawerBottom animation="slide" isVisible={isModal} setVisible={() => setModal(false)} title="Menu"> <DrawerBottom animation="slide" isVisible={isModal} setVisible={() => setModal(false)} title="Menu">

View File

@@ -158,8 +158,11 @@ export default function DetailDiscussionGeneral() {
Toast.show({ type: 'small', text1: response.message }) Toast.show({ type: 'small', text1: response.message })
} }
} }
} catch (error) { } catch (error: any) {
console.error(error) console.error(error);
const message = error?.response?.data?.message || "Gagal menambahkan data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoadingSendKomentar(false) setLoadingSendKomentar(false)
} }
@@ -175,8 +178,11 @@ export default function DetailDiscussionGeneral() {
} else { } else {
Toast.show({ type: 'small', text1: response.message }) Toast.show({ type: 'small', text1: response.message })
} }
} catch (error) { } catch (error: any) {
console.error(error) console.error(error);
const message = error?.response?.data?.message || "Gagal mengupdate data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoadingSendKomentar(false) setLoadingSendKomentar(false)
handleViewEditKomentar() handleViewEditKomentar()
@@ -193,8 +199,11 @@ export default function DetailDiscussionGeneral() {
} else { } else {
Toast.show({ type: 'small', text1: response.message }) Toast.show({ type: 'small', text1: response.message })
} }
} catch (error) { } catch (error: any) {
console.error(error) console.error(error);
const message = error?.response?.data?.message || "Gagal menghapus data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoadingSendKomentar(false) setLoadingSendKomentar(false)
setVisible(false) setVisible(false)

View File

@@ -84,9 +84,11 @@ export default function AddMemberDiscussionDetail() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error: any) {
console.error(error) console.error(error);
Toast.show({ type: 'small', text1: 'Gagal menambahkan anggota', }) const message = error?.response?.data?.message || "Gagal menambahkan anggota"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -156,9 +156,11 @@ export default function CreateDiscussionGeneral() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal menambahkan data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -157,9 +157,11 @@ export default function EditDiscussionGeneral() {
} else { } else {
Toast.show({ type: 'small', text1: 'Gagal mengubah data', }) Toast.show({ type: 'small', text1: 'Gagal mengubah data', })
} }
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal mengubah data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -11,8 +11,9 @@ import { apiGetDiscussionGeneral } from "@/lib/api";
import { useAuthSession } from "@/providers/AuthProvider"; import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider"; import { useTheme } from "@/providers/ThemeProvider";
import { AntDesign, Feather, Ionicons, MaterialIcons } from "@expo/vector-icons"; import { AntDesign, Feather, Ionicons, MaterialIcons } from "@expo/vector-icons";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { router, useLocalSearchParams } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { RefreshControl, View, VirtualizedList } from "react-native"; import { RefreshControl, View, VirtualizedList } from "react-native";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
@@ -32,70 +33,76 @@ export default function Discussion() {
const { colors } = useTheme(); const { colors } = useTheme();
const { active, group } = useLocalSearchParams<{ active?: string, group?: string }>() const { active, group } = useLocalSearchParams<{ active?: string, group?: string }>()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [nameGroup, setNameGroup] = useState('')
const [data, setData] = useState<Props[]>([])
const update = useSelector((state: any) => state.discussionGeneralDetailUpdate) const update = useSelector((state: any) => state.discussionGeneralDetailUpdate)
const [loading, setLoading] = useState(true) const queryClient = useQueryClient()
const arrSkeleton = Array.from({ length: 5 }, (_, index) => index) const [status, setStatus] = useState<'true' | 'false'>(active == 'false' ? 'false' : 'true')
const [status, setStatus] = useState<'true' | 'false'>('true')
const [page, setPage] = useState(1)
const [waiting, setWaiting] = useState(false)
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
async function handleLoad(loading: boolean, thisPage: number) { // TanStack Query for Discussions with Infinite Scroll
try { const {
setWaiting(true) data,
setLoading(loading) fetchNextPage,
setPage(thisPage) hasNextPage,
isFetchingNextPage,
isLoading,
refetch
} = useInfiniteQuery({
queryKey: ['discussions', { status, search, group }],
queryFn: async ({ pageParam = 1 }) => {
const hasil = await decryptToken(String(token?.current)) const hasil = await decryptToken(String(token?.current))
const response = await apiGetDiscussionGeneral({ user: hasil, active: status, search: search, group: String(group), page: thisPage }) const response = await apiGetDiscussionGeneral({
if (thisPage == 1) { user: hasil,
setData(response.data) active: status,
} else if (thisPage > 1 && response.data.length > 0) { search: search,
setData([...data, ...response.data]) group: String(group),
} else { page: pageParam
return; })
} return response;
setNameGroup(response.filter.name) },
} catch (error) { initialPageParam: 1,
console.error(error) getNextPageParam: (lastPage, allPages) => {
} finally { return lastPage.data.length > 0 ? allPages.length + 1 : undefined;
setLoading(false) },
setWaiting(false) enabled: !!token?.current,
} staleTime: 0,
} })
// Flatten pages into a single data array
const flatData = useMemo(() => {
return data?.pages.flatMap(page => page.data) || [];
}, [data])
// Get nameGroup from the first available page
const nameGroup = useMemo(() => {
return data?.pages[0]?.filter?.name || "";
}, [data])
// Refetch when manual update state changes
useEffect(() => { useEffect(() => {
handleLoad(false, 1) refetch()
}, [update]) }, [update, refetch])
useEffect(() => {
handleLoad(true, 1)
}, [status, search, group])
const loadMoreData = () => {
if (waiting) return
setTimeout(() => {
handleLoad(false, page + 1)
}, 1000);
};
const handleRefresh = async () => { const handleRefresh = async () => {
setRefreshing(true) setRefreshing(true)
handleLoad(false, 1) await queryClient.invalidateQueries({ queryKey: ['discussions'] })
await new Promise(resolve => setTimeout(resolve, 2000));
setRefreshing(false) setRefreshing(false)
}; };
const loadMoreData = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
};
const arrSkeleton = [0, 1, 2, 3, 4]
const getItem = (_data: unknown, index: number): Props => ({ const getItem = (_data: unknown, index: number): Props => ({
id: data[index].id, id: flatData[index]?.id,
title: data[index].title, title: flatData[index]?.title,
desc: data[index].desc, desc: flatData[index]?.desc,
status: data[index].status, status: flatData[index]?.status,
total_komentar: data[index].total_komentar, total_komentar: flatData[index]?.total_komentar,
createdAt: data[index].createdAt, createdAt: flatData[index]?.createdAt,
}) })
return ( return (
@@ -132,18 +139,18 @@ export default function Discussion() {
</View> </View>
<View style={[Styles.flex2, Styles.mt05]}> <View style={[Styles.flex2, Styles.mt05]}>
{ {
loading ? isLoading ?
arrSkeleton.map((item: any, i: number) => { arrSkeleton.map((item: any, i: number) => {
return ( return (
<SkeletonContent key={i} /> <SkeletonContent key={i} />
) )
}) })
: :
data.length > 0 flatData.length > 0
? ?
<VirtualizedList <VirtualizedList
data={data} data={flatData}
getItemCount={() => data.length} getItemCount={() => flatData.length}
getItem={getItem} getItem={getItem}
renderItem={({ item, index }: { item: Props, index: number }) => { renderItem={({ item, index }: { item: Props, index: number }) => {
return ( return (
@@ -153,16 +160,14 @@ export default function Discussion() {
onPress={() => { router.push(`/discussion/${item.id}`) }} onPress={() => { router.push(`/discussion/${item.id}`) }}
borderType="bottom" borderType="bottom"
icon={ icon={
// <View style={[Styles.iconContent]}>
<MaterialIcons name="chat" size={25} color={colors.text} /> <MaterialIcons name="chat" size={25} color={colors.text} />
// </View>
} }
title={item.title} title={item.title}
subtitle={ subtitle={
status != "false" && <LabelStatus category={item.status === 1 ? "success" : "error"} text={item.status === 1 ? "BUKA" : "TUTUP"} size="small" /> status != "false" && <LabelStatus category={item.status === 1 ? "success" : "error"} text={item.status === 1 ? "BUKA" : "TUTUP"} size="small" />
} }
rightTopInfo={item.createdAt} rightTopInfo={item.createdAt}
desc={item.desc.replace(/<[^>]*>?/gm, ' ').replace(/\r?\n|\r/g, ' ')} desc={item.desc?.replace(/<[^>]*>?/gm, ' ').replace(/\r?\n|\r/g, ' ')}
leftBottomInfo={ leftBottomInfo={
<View style={[Styles.rowItemsCenter]}> <View style={[Styles.rowItemsCenter]}>
<Ionicons name="chatbox-ellipses-outline" size={18} color={colors.dimmed} style={Styles.mr05} /> <Ionicons name="chatbox-ellipses-outline" size={18} color={colors.dimmed} style={Styles.mr05} />

View File

@@ -67,8 +67,11 @@ export default function MemberDiscussionDetail() {
await apiDeleteMemberDiscussionGeneral({ user: hasil, idUser: chooseUser.idUser }, id) await apiDeleteMemberDiscussionGeneral({ user: hasil, idUser: chooseUser.idUser }, id)
Toast.show({ type: 'small', text1: 'Berhasil mengeluarkan anggota dari diskusi', }) Toast.show({ type: 'small', text1: 'Berhasil mengeluarkan anggota dari diskusi', })
handleLoad(false) handleLoad(false)
} catch (error) { } catch (error: any) {
console.error(error) console.error(error);
const message = error?.response?.data?.message || "Gagal mengeluarkan anggota"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setModal(false) setModal(false)
} }

View File

@@ -92,9 +92,11 @@ export default function AddMemberCalendarEvent() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error: any) {
console.error(error) console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal menambahkan anggota"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -57,9 +57,11 @@ export default function EditEventCalendar() {
setData({ ...response.data, dateStart: moment(response.data.dateStartFormat, 'DD-MM-YYYY').format('DD-MM-YYYY') }) setData({ ...response.data, dateStart: moment(response.data.dateStartFormat, 'DD-MM-YYYY').format('DD-MM-YYYY') })
setIdCalendar(response.data.idCalendar) setIdCalendar(response.data.idCalendar)
setChoose({ val: response.data.repeatEventTyper, label: valueTypeEventRepeat.find((item) => item.id == response.data.repeatEventTyper)?.name || "" }) setChoose({ val: response.data.repeatEventTyper, label: valueTypeEventRepeat.find((item) => item.id == response.data.repeatEventTyper)?.name || "" })
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
Toast.show({ type: 'small', text1: 'Gagal mendapatkan data', }) const message = error?.response?.data?.message || "Gagal mendapatkan data"
Toast.show({ type: 'small', text1: message })
} }
} }
@@ -154,9 +156,11 @@ export default function EditEventCalendar() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error: any) {
console.error(error) console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal mengubah acara"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -137,9 +137,11 @@ export default function DetailEventCalendar() {
dispatch(setUpdateCalendar({ ...update, member: !update.member })); dispatch(setUpdateCalendar({ ...update, member: !update.member }));
} }
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal menghapus anggota"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setModalMember(false) setModalMember(false)
} }

View File

@@ -83,9 +83,11 @@ export default function CreateCalendarAddMember() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error: any) {
console.error(error) console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal membuat acara"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -89,9 +89,11 @@ export default function DiscussionDivisionEdit() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal mengubah data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -202,8 +202,11 @@ export default function DiscussionDetail() {
setKomentar("") setKomentar("")
updateTrigger() updateTrigger()
} }
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
const message = error?.response?.data?.message || "Gagal menambahkan komentar"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoadingSend(false); setLoadingSend(false);
} }
@@ -222,8 +225,11 @@ export default function DiscussionDetail() {
} else { } else {
Toast.show({ type: 'small', text1: response.message }) Toast.show({ type: 'small', text1: response.message })
} }
} catch (error) { } catch (error : any ) {
console.error(error); console.error(error);
const message = error?.response?.data?.message || "Gagal mengedit komentar"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoadingSend(false); setLoadingSend(false);
handleViewEditKomentar() handleViewEditKomentar()
@@ -243,8 +249,11 @@ export default function DiscussionDetail() {
} else { } else {
Toast.show({ type: 'small', text1: response.message }) Toast.show({ type: 'small', text1: response.message })
} }
} catch (error) { } catch (error : any ) {
console.error(error); console.error(error);
const message = error?.response?.data?.message || "Gagal menghapus komentar"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoadingSend(false) setLoadingSend(false)
setVisible(false) setVisible(false)

View File

@@ -81,9 +81,11 @@ export default function CreateDiscussionDivision() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error: any) {
console.error(error) console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal menambahkan data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -235,9 +235,11 @@ export default function DocumentDivision() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error : any ) {
console.error(error); console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal mengubah nama"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoadingRename(false) setLoadingRename(false)
setRename(false) setRename(false)
@@ -258,9 +260,11 @@ export default function DocumentDivision() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error : any ) {
console.error(error); console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal menghapus"
Toast.show({ type: 'small', text1: message })
} }
} }
@@ -284,9 +288,11 @@ export default function DocumentDivision() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error : any ) {
console.error(error); console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal membagikan"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setShare(false); setShare(false);
} }

View File

@@ -120,9 +120,11 @@ export default function TaskDivisionAddFile() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error : any ) {
console.error(error); console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal menambahkan file"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -86,9 +86,11 @@ export default function AddMemberTask() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error : any ) {
console.error(error) console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal menambahkan anggota"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -133,9 +133,11 @@ export default function TaskDivisionAddTask() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error : any ) {
console.error(error); console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal menambahkan data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -62,9 +62,11 @@ export default function TaskDivisionCancel() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error : any ) {
console.error(error); console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal membatalkan kegiatan"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -80,9 +80,11 @@ export default function TaskDivisionEdit() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error : any ) {
console.error(error); console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal mengubah data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -80,9 +80,11 @@ export default function TaskDivisionReport() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error : any ) {
console.error(error); console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal mengubah data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -105,9 +105,11 @@ export default function CreateTaskDivision() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error : any ) {
console.error(error) console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal menambahkan data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -125,9 +125,11 @@ export default function UpdateProjectTaskDivision() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error : any ) {
console.error(error); console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal mengubah data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoadingSubmit(false) setLoadingSubmit(false)
} }

View File

@@ -89,9 +89,11 @@ export default function AddMemberDivision() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error: any) {
console.error(error) console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal menambahkan anggota"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -56,9 +56,11 @@ export default function EditDivision() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error: any) {
console.error(error) console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal mengubah data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -78,9 +78,11 @@ export default function InformationDivision() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error: any) {
console.error(error) console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal mengeluarkan anggota"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setModal(false) setModal(false)
} }
@@ -96,9 +98,11 @@ export default function InformationDivision() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error: any) {
console.error(error) console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal mengubah status admin"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setModal(false) setModal(false)
} }

View File

@@ -94,8 +94,11 @@ export default function ReportDivision() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }); Toast.show({ type: 'small', text1: response.message, });
} }
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
const message = error?.response?.data?.message || "Gagal mengambil data"
Toast.show({ type: 'small', text1: message })
} }
} }

View File

@@ -77,9 +77,11 @@ export default function CreateDivision() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error: any) {
console.error(error) console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal menambahkan data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoadingBtn(false) setLoadingBtn(false)
} }

View File

@@ -66,9 +66,11 @@ export default function CreateDivisionAddAdmin() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error : any ) {
console.error(error) console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal menambahkan data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -17,8 +17,9 @@ import {
Ionicons, Ionicons,
MaterialCommunityIcons MaterialCommunityIcons
} from "@expo/vector-icons"; } from "@expo/vector-icons";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { router, useLocalSearchParams } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Pressable, RefreshControl, View, VirtualizedList } from "react-native"; import { Pressable, RefreshControl, View, VirtualizedList } from "react-native";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
@@ -40,23 +41,23 @@ export default function ListDivision() {
const { token, decryptToken } = useAuthSession() const { token, decryptToken } = useAuthSession()
const { colors } = useTheme(); const { colors } = useTheme();
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [nameGroup, setNameGroup] = useState("") const queryClient = useQueryClient()
const [data, setData] = useState<Props[]>([]) const [status, setStatus] = useState<'true' | 'false'>(active == 'false' ? 'false' : 'true')
// ... state same ... const [category, setCategory] = useState<'divisi-saya' | 'semua'>(cat == 'semua' ? 'semua' : 'divisi-saya')
const update = useSelector((state: any) => state.divisionUpdate) const update = useSelector((state: any) => state.divisionUpdate)
const arrSkeleton = Array.from({ length: 3 }, (_, index) => index)
const [loading, setLoading] = useState(false)
const [status, setStatus] = useState<'true' | 'false'>('true')
const [category, setCategory] = useState<'divisi-saya' | 'semua'>('divisi-saya')
const [page, setPage] = useState(1)
const [waiting, setWaiting] = useState(false)
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
async function handleLoad(loading: boolean, thisPage: number) { // TanStack Query for Divisions with Infinite Scroll
try { const {
setWaiting(true) data,
setLoading(loading) fetchNextPage,
setPage(thisPage) hasNextPage,
isFetchingNextPage,
isLoading,
refetch
} = useInfiniteQuery({
queryKey: ['divisions', { status, search, group, category }],
queryFn: async ({ pageParam = 1 }) => {
const hasil = await decryptToken(String(token?.current)); const hasil = await decryptToken(String(token?.current));
const response = await apiGetDivision({ const response = await apiGetDivision({
user: hasil, user: hasil,
@@ -64,54 +65,52 @@ export default function ListDivision() {
search: search, search: search,
group: String(group), group: String(group),
kategori: category, kategori: category,
page: thisPage page: pageParam
}); });
return response;
},
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) => {
return lastPage.data.length > 0 ? allPages.length + 1 : undefined;
},
enabled: !!token?.current,
staleTime: 0,
})
if (response.success) { // Refetch when manual update state changes
if (thisPage == 1) {
setData(response.data);
} else if (thisPage > 1 && response.data.length > 0) {
setData([...data, ...response.data]);
} else {
return;
}
setNameGroup(response.filter.name);
}
} catch (error) {
console.error(error);
} finally {
setLoading(false)
setWaiting(false)
}
}
useEffect(() => { useEffect(() => {
handleLoad(false, 1); refetch()
}, [update]); }, [update, refetch])
useEffect(() => { // Flatten pages into a single data array
handleLoad(true, 1); const flatData = useMemo(() => {
}, [status, search, group, category]); return data?.pages.flatMap(page => page.data) || [];
}, [data])
const loadMoreData = () => { // Get nameGroup from the first available page
if (waiting) return const nameGroup = useMemo(() => {
setTimeout(() => { return data?.pages[0]?.filter?.name || "";
handleLoad(false, page + 1) }, [data])
}, 1000);
};
const handleRefresh = async () => { const handleRefresh = async () => {
setRefreshing(true) setRefreshing(true)
handleLoad(false, 1) await queryClient.invalidateQueries({ queryKey: ['divisions'] })
await new Promise(resolve => setTimeout(resolve, 2000));
setRefreshing(false) setRefreshing(false)
}; };
const loadMoreData = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
};
const arrSkeleton = [0, 1, 2]
const getItem = (_data: unknown, index: number): Props => ({ const getItem = (_data: unknown, index: number): Props => ({
id: data[index].id, id: flatData[index]?.id,
name: data[index].name, name: flatData[index]?.name,
desc: data[index].desc, desc: flatData[index]?.desc,
jumlah_member: data[index].jumlah_member, jumlah_member: flatData[index]?.jumlah_member,
}) })
@@ -206,7 +205,7 @@ export default function ListDivision() {
</View> </View>
<View style={[{ flex: 2 }, Styles.mt10]}> <View style={[{ flex: 2 }, Styles.mt10]}>
{ {
loading ? isLoading ?
isList ? isList ?
arrSkeleton.map((item, index) => ( arrSkeleton.map((item, index) => (
<SkeletonTwoItem key={index} /> <SkeletonTwoItem key={index} />
@@ -216,7 +215,7 @@ export default function ListDivision() {
<Skeleton key={index} width={100} height={180} widthType="percent" borderRadius={10} /> <Skeleton key={index} width={100} height={180} widthType="percent" borderRadius={10} />
)) ))
: :
data.length == 0 ? ( flatData.length == 0 ? (
<View style={[Styles.mt15]}> <View style={[Styles.mt15]}>
<Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>Tidak ada data</Text> <Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>Tidak ada data</Text>
</View> </View>
@@ -224,9 +223,9 @@ export default function ListDivision() {
isList ? ( isList ? (
<View style={[Styles.h100]}> <View style={[Styles.h100]}>
<VirtualizedList <VirtualizedList
data={data} data={flatData}
style={[{ paddingBottom: 100 }]} style={[{ paddingBottom: 100 }]}
getItemCount={() => data.length} getItemCount={() => flatData.length}
getItem={getItem} getItem={getItem}
renderItem={({ item, index }: { item: Props, index: number }) => { renderItem={({ item, index }: { item: Props, index: number }) => {
return ( return (
@@ -241,7 +240,6 @@ export default function ListDivision() {
</View> </View>
} }
title={item.name} title={item.name}
titleWeight="normal"
/> />
) )
}} }}
@@ -261,9 +259,9 @@ export default function ListDivision() {
) : ( ) : (
<View style={[Styles.h100]}> <View style={[Styles.h100]}>
<VirtualizedList <VirtualizedList
data={data} data={flatData}
style={[{ paddingBottom: 100 }]} style={[{ paddingBottom: 100 }]}
getItemCount={() => data.length} getItemCount={() => flatData.length}
getItem={getItem} getItem={getItem}
renderItem={({ item, index }: { item: Props, index: number }) => { renderItem={({ item, index }: { item: Props, index: number }) => {
return ( return (

View File

@@ -112,8 +112,11 @@ export default function Report() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error: any) {
console.error(error); console.error(error);
const message = error?.response?.data?.message || "Gagal mengambil data"
Toast.show({ type: 'small', text1: message })
} }
} }

View File

@@ -188,9 +188,14 @@ export default function EditProfile() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error: any) {
console.error(error) console.error(error);
Toast.show({ type: 'small', text1: 'Gagal mengupdate data', }) const message = error?.response?.data?.message || "Gagal mengupdate data"
Toast.show({
type: 'small',
text1: message
})
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -15,7 +15,8 @@ import { setUpdateGroup } from "@/lib/groupSlice";
import { useAuthSession } from "@/providers/AuthProvider"; import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider"; import { useTheme } from "@/providers/ThemeProvider";
import { AntDesign, Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { AntDesign, Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { useEffect, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { RefreshControl, View, VirtualizedList } from "react-native"; import { RefreshControl, View, VirtualizedList } from "react-native";
import Toast from "react-native-toast-message"; import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
@@ -31,16 +32,14 @@ export default function Index() {
const { colors } = useTheme(); const { colors } = useTheme();
const [isModal, setModal] = useState(false) const [isModal, setModal] = useState(false)
const [isVisibleEdit, setVisibleEdit] = useState(false) const [isVisibleEdit, setVisibleEdit] = useState(false)
const [data, setData] = useState<Props[]>([])
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const arrSkeleton = Array.from({ length: 5 }, (_, index) => index)
const [loading, setLoading] = useState(true)
const [showDeleteModal, setShowDeleteModal] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false)
const [status, setStatus] = useState<'true' | 'false'>('true') const [status, setStatus] = useState<'true' | 'false'>('true')
const [loadingSubmit, setLoadingSubmit] = useState(false) const [loadingSubmit, setLoadingSubmit] = useState(false)
const [idChoose, setIdChoose] = useState('') const [idChoose, setIdChoose] = useState('')
const [activeChoose, setActiveChoose] = useState(true) const [activeChoose, setActiveChoose] = useState(true)
const [titleChoose, setTitleChoose] = useState('') const [titleChoose, setTitleChoose] = useState('')
const queryClient = useQueryClient()
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const dispatch = useDispatch() const dispatch = useDispatch()
@@ -49,12 +48,38 @@ export default function Index() {
title: false, title: false,
}); });
// TanStack Query for Groups
const {
data: queryData,
isLoading,
refetch
} = useQuery({
queryKey: ['groups', { status, search }],
queryFn: async () => {
const hasil = await decryptToken(String(token?.current))
const response = await apiGetGroup({
user: hasil,
active: status,
search: search
})
return response;
},
enabled: !!token?.current,
staleTime: 0,
})
const data = useMemo(() => queryData?.data || [], [queryData])
useEffect(() => {
refetch()
}, [update, refetch])
async function handleEdit() { async function handleEdit() {
try { try {
setLoadingSubmit(true) setLoadingSubmit(true)
const hasil = await decryptToken(String(token?.current)) const hasil = await decryptToken(String(token?.current))
const response = await apiEditGroup({ user: hasil, name: titleChoose }, idChoose) const response = await apiEditGroup({ user: hasil, name: titleChoose }, idChoose)
await queryClient.invalidateQueries({ queryKey: ['groups'] })
dispatch(setUpdateGroup(!update)) dispatch(setUpdateGroup(!update))
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@@ -71,6 +96,7 @@ export default function Index() {
try { try {
const hasil = await decryptToken(String(token?.current)) const hasil = await decryptToken(String(token?.current))
const response = await apiDeleteGroup({ user: hasil, isActive: activeChoose }, idChoose) const response = await apiDeleteGroup({ user: hasil, isActive: activeChoose }, idChoose)
await queryClient.invalidateQueries({ queryKey: ['groups'] })
dispatch(setUpdateGroup(!update)) dispatch(setUpdateGroup(!update))
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@@ -80,32 +106,9 @@ export default function Index() {
} }
} }
async function handleLoad(loading: boolean) {
try {
setLoading(loading)
const hasil = await decryptToken(String(token?.current))
const response = await apiGetGroup({ user: hasil, active: status, search: search })
setData(response.data)
} catch (error) {
console.error(error)
} finally {
setLoading(false)
}
}
useEffect(() => {
handleLoad(false)
}, [update])
useEffect(() => {
handleLoad(true)
}, [status, search])
const handleRefresh = async () => { const handleRefresh = async () => {
setRefreshing(true) setRefreshing(true)
handleLoad(false) await queryClient.invalidateQueries({ queryKey: ['groups'] })
await new Promise(resolve => setTimeout(resolve, 2000));
setRefreshing(false) setRefreshing(false)
}; };
@@ -129,6 +132,8 @@ export default function Index() {
const arrSkeleton = [0, 1, 2, 3, 4]
return ( return (
<View style={[Styles.p15, { flex: 1, backgroundColor: colors.background }]}> <View style={[Styles.p15, { flex: 1, backgroundColor: colors.background }]}>
<View style={[Styles.mb10]}> <View style={[Styles.mb10]}>
@@ -152,7 +157,7 @@ export default function Index() {
</View> </View>
<View style={[{ flex: 2 }, Styles.mt10]}> <View style={[{ flex: 2 }, Styles.mt10]}>
{ {
loading ? isLoading ?
arrSkeleton.map((item, index) => { arrSkeleton.map((item, index) => {
return ( return (
<SkeletonTwoItem key={index} /> <SkeletonTwoItem key={index} />

View File

@@ -12,10 +12,11 @@ import { apiGetProfile } from "@/lib/api";
import { setEntities } from "@/lib/entitiesSlice"; import { setEntities } from "@/lib/entitiesSlice";
import { useAuthSession } from "@/providers/AuthProvider"; import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider"; import { useTheme } from "@/providers/ThemeProvider";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Dimensions, Platform, RefreshControl, SafeAreaView, ScrollView, View } from "react-native"; import { Alert, Dimensions, Platform, RefreshControl, SafeAreaView, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
@@ -23,28 +24,66 @@ import { useDispatch, useSelector } from "react-redux";
export default function Home() { export default function Home() {
const entities = useSelector((state: any) => state.entities) const entities = useSelector((state: any) => state.entities)
const dispatch = useDispatch() const dispatch = useDispatch()
const queryClient = useQueryClient()
const { token, decryptToken, signOut } = useAuthSession() const { token, decryptToken, signOut } = useAuthSession()
const { colors } = useTheme(); const { colors } = useTheme();
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
useEffect(() => { const { data: profile, isError } = useQuery({
handleUserLogin() queryKey: ['profile'],
}, [dispatch]); queryFn: async () => {
const hasil = await decryptToken(String(token?.current))
const data = await apiGetProfile({ id: hasil })
return data.data
},
enabled: !!token?.current,
staleTime: 0, // Ensure it refetches every time the component mounts
})
async function handleUserLogin() { // Sync to Redux for global usage
const hasil = await decryptToken(String(token?.current)) useEffect(() => {
apiGetProfile({ id: hasil }) if (profile) {
.then((data) => dispatch(setEntities(data.data))) dispatch(setEntities(profile))
.catch((error) => { }
signOut() }, [profile, dispatch])
});
} // Auto Sign Out if profile fetch fails (e.g. invalid/expired token)
useEffect(() => {
if (isError) {
signOut()
}
}, [isError, signOut])
useEffect(() => {
if (profile && profile.isActive === false) {
Alert.alert(
'Akun Dinonaktifkan',
'Akun kamu telah dinonaktifkan. Silahkan hubungi admin untuk informasi lebih lanjut.',
[{ text: 'OK', onPress: signOut }]
)
}
}, [profile, signOut])
useEffect(() => {
if (profile && profile.villageIsActive === false) {
Alert.alert(
'Desa Dinonaktifkan',
'Desa kamu saat ini telah dinonaktifkan. Silahkan hubungi pengelola sistem untuk informasi lebih lanjut.',
[{ text: 'OK', onPress: signOut }]
)
}
}, [profile, signOut])
const handleRefresh = async () => { const handleRefresh = async () => {
setRefreshing(true) setRefreshing(true)
handleUserLogin() // Invalidate all queries related to the home screen
await new Promise(resolve => setTimeout(resolve, 2000)); await queryClient.invalidateQueries({ queryKey: ['profile'] })
await queryClient.invalidateQueries({ queryKey: ['banners'] })
await queryClient.invalidateQueries({ queryKey: ['homeData'] })
// Artificial delay to show refresh indicator if sync is too fast
await new Promise(resolve => setTimeout(resolve, 1000));
setRefreshing(false) setRefreshing(false)
}; };

View File

@@ -55,9 +55,11 @@ export default function MemberDetail() {
} else { } else {
Toast.show({ type: 'small', text1: response.message }) Toast.show({ type: 'small', text1: response.message })
} }
} catch (error) { } catch (error : any ) {
console.error(error) console.error(error);
Toast.show({ type: 'small', text1: 'Gagal mengambil data' }) const message = error?.response?.data?.message || "Gagal mengambil data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -185,9 +185,11 @@ export default function CreateMember() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error : any ) {
console.error(error) console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal menambahkan data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -211,9 +211,11 @@ export default function EditMember() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error : any ) {
console.error(error) console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal menambahkan data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -12,8 +12,9 @@ import { apiGetUser } from "@/lib/api";
import { useAuthSession } from "@/providers/AuthProvider"; import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider"; import { useTheme } from "@/providers/ThemeProvider";
import { AntDesign, Feather } from "@expo/vector-icons"; import { AntDesign, Feather } from "@expo/vector-icons";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { router, useLocalSearchParams } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { RefreshControl, View, VirtualizedList } from "react-native"; import { RefreshControl, View, VirtualizedList } from "react-native";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
@@ -37,73 +38,81 @@ export default function Index() {
const entityUser = useSelector((state: any) => state.user) const entityUser = useSelector((state: any) => state.user)
const { colors } = useTheme(); const { colors } = useTheme();
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [nameGroup, setNameGroup] = useState('')
const [data, setData] = useState<Props[]>([])
const update = useSelector((state: any) => state.memberUpdate) const update = useSelector((state: any) => state.memberUpdate)
const arrSkeleton = Array.from({ length: 5 }, (_, index) => index) const queryClient = useQueryClient()
const [loading, setLoading] = useState(true) const [status, setStatus] = useState<'true' | 'false'>(active == 'false' ? 'false' : 'true')
const [status, setStatus] = useState<'true' | 'false'>('true')
const [page, setPage] = useState(1)
const [waiting, setWaiting] = useState(false)
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
async function handleLoad(loading: boolean, thisPage: number) { // TanStack Query for Members with Infinite Scroll
try { const {
setWaiting(true) data,
setLoading(loading) fetchNextPage,
setPage(thisPage) hasNextPage,
isFetchingNextPage,
isLoading,
refetch
} = useInfiniteQuery({
queryKey: ['members', { status, search, group }],
queryFn: async ({ pageParam = 1 }) => {
const hasil = await decryptToken(String(token?.current)) const hasil = await decryptToken(String(token?.current))
const response = await apiGetUser({ user: hasil, active: status, search, group: String(group), page: thisPage }) const response = await apiGetUser({
if (thisPage == 1) { user: hasil,
setData(response.data) active: status,
} else if (thisPage > 1 && response.data.length > 0) { search,
setData([...data, ...response.data]) group: String(group),
} else { page: pageParam
return; })
} return response;
setNameGroup(response.filter.name) },
} catch (error) { initialPageParam: 1,
console.error(error) getNextPageParam: (lastPage, allPages) => {
} finally { return lastPage.data.length > 0 ? allPages.length + 1 : undefined;
setLoading(false) },
setWaiting(false) enabled: !!token?.current,
} staleTime: 0,
} })
const loadMoreData = () => { // Flatten pages into a single data array
if (waiting) return const flatData = useMemo(() => {
setTimeout(() => { return data?.pages.flatMap(page => page.data) || [];
handleLoad(false, page + 1) }, [data])
}, 1000);
};
// Get nameGroup from the first available page
const nameGroup = useMemo(() => {
return data?.pages[0]?.filter?.name || "";
}, [data])
// Refetch when manual update state changes
useEffect(() => { useEffect(() => {
handleLoad(false, 1) refetch()
}, [update]) }, [update, refetch])
useEffect(() => {
handleLoad(true, 1)
}, [group, search, status])
const handleRefresh = async () => { const handleRefresh = async () => {
setRefreshing(true) setRefreshing(true)
handleLoad(false, 1) await queryClient.invalidateQueries({ queryKey: ['members'] })
await new Promise(resolve => setTimeout(resolve, 2000));
setRefreshing(false) setRefreshing(false)
}; };
const loadMoreData = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
};
const arrSkeleton = [0, 1, 2, 3, 4]
const getItem = (_data: unknown, index: number): Props => ({ const getItem = (_data: unknown, index: number): Props => ({
id: data[index].id, id: flatData[index]?.id,
name: data[index].name, name: flatData[index]?.name,
nik: data[index].nik, nik: flatData[index]?.nik,
email: data[index].email, email: flatData[index]?.email,
phone: data[index].phone, phone: flatData[index]?.phone,
gender: data[index].gender, gender: flatData[index]?.gender,
position: data[index].position, position: flatData[index]?.position,
group: data[index].group, group: flatData[index]?.group,
img: data[index].img, img: flatData[index]?.img,
isActive: data[index].isActive, isActive: flatData[index]?.isActive,
role: data[index].role, role: flatData[index]?.role,
}); });
return ( return (
@@ -136,18 +145,18 @@ export default function Index() {
</View> </View>
<View style={[{ flex: 2 }, Styles.mt10]}> <View style={[{ flex: 2 }, Styles.mt10]}>
{ {
loading ? isLoading ?
arrSkeleton.map((item, index) => { arrSkeleton.map((item, index) => {
return ( return (
<SkeletonTwoItem key={index} /> <SkeletonTwoItem key={index} />
) )
}) })
: :
data.length > 0 flatData.length > 0
? ?
<VirtualizedList <VirtualizedList
data={data} data={flatData}
getItemCount={() => data.length} getItemCount={() => flatData.length}
getItem={getItem} getItem={getItem}
renderItem={({ item, index }: { item: Props, index: number }) => { renderItem={({ item, index }: { item: Props, index: number }) => {
return ( return (

View File

@@ -1,4 +1,3 @@
import BorderBottomItem from "@/components/borderBottomItem";
import BorderBottomItemVertical from "@/components/borderBottomItemVertical"; import BorderBottomItemVertical from "@/components/borderBottomItemVertical";
import SkeletonTwoItem from "@/components/skeletonTwoItem"; import SkeletonTwoItem from "@/components/skeletonTwoItem";
import Text from "@/components/Text"; import Text from "@/components/Text";
@@ -10,7 +9,8 @@ import { pushToPage } from "@/lib/pushToPage";
import { useAuthSession } from "@/providers/AuthProvider"; import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider"; import { useTheme } from "@/providers/ThemeProvider";
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { useEffect, useState } from "react"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { RefreshControl, SafeAreaView, View, VirtualizedList } from "react-native"; import { RefreshControl, SafeAreaView, View, VirtualizedList } from "react-native";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
@@ -27,64 +27,61 @@ type Props = {
export default function Notification() { export default function Notification() {
const { token, decryptToken } = useAuthSession() const { token, decryptToken } = useAuthSession()
const { colors } = useTheme(); const { colors } = useTheme();
const [loading, setLoading] = useState(false) const queryClient = useQueryClient()
const [data, setData] = useState<Props[]>([])
const [page, setPage] = useState(1)
const [waiting, setWaiting] = useState(false)
const arrSkeleton = Array.from({ length: 5 }, (_, index) => index)
const dispatch = useDispatch() const dispatch = useDispatch()
const updateNotification = useSelector((state: any) => state.notificationUpdate) const updateNotification = useSelector((state: any) => state.notificationUpdate)
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
async function handleLoad(loading: boolean, thisPage: number) { // TanStack Query for Notifications with Infinite Scroll
try { const {
setLoading(loading) data,
setPage(thisPage) fetchNextPage,
setWaiting(true) hasNextPage,
isFetchingNextPage,
isLoading,
refetch
} = useInfiniteQuery({
queryKey: ['notifications'],
queryFn: async ({ pageParam = 1 }) => {
const hasil = await decryptToken(String(token?.current)) const hasil = await decryptToken(String(token?.current))
const response = await apiGetNotification({ user: hasil, page: thisPage }) const response = await apiGetNotification({ user: hasil, page: pageParam })
if (thisPage == 1) { return response;
setData(response.data) },
} else if (thisPage > 1 && response.data.length > 0) { initialPageParam: 1,
setData([...data, ...response.data]) getNextPageParam: (lastPage, allPages) => {
} else { return lastPage.data.length > 0 ? allPages.length + 1 : undefined;
return; },
} enabled: !!token?.current,
} catch (error) { staleTime: 0,
console.error(error) })
} finally {
setLoading(false)
setWaiting(false)
}
}
// Flatten pages into a single data array
const flatData = useMemo(() => {
return data?.pages.flatMap(page => page.data) || [];
}, [data])
const loadMoreData = () => { // Refetch when manual update state changes
if (waiting) return useEffect(() => {
setTimeout(() => { refetch()
handleLoad(false, page + 1) }, [updateNotification, refetch])
}, 1000);
const handleRefresh = async () => {
setRefreshing(true)
await queryClient.invalidateQueries({ queryKey: ['notifications'] })
setRefreshing(false)
}; };
useEffect(() => { const loadMoreData = () => {
handleLoad(true, 1) if (hasNextPage && !isFetchingNextPage) {
}, []) fetchNextPage()
}
};
const getItem = (_data: unknown, index: number): Props => ({
id: data[index].id,
title: data[index].title,
desc: data[index].desc,
category: data[index].category,
idContent: data[index].idContent,
isRead: data[index].isRead,
createdAt: data[index].createdAt,
});
async function handleReadNotification(id: string, category: string, idContent: string) { async function handleReadNotification(id: string, category: string, idContent: string) {
try { try {
const hasil = await decryptToken(String(token?.current)) const hasil = await decryptToken(String(token?.current))
const response = await apiReadOneNotification({ user: hasil, id: id }) const response = await apiReadOneNotification({ user: hasil, id: id })
await queryClient.invalidateQueries({ queryKey: ['notifications'] })
pushToPage(category, idContent) pushToPage(category, idContent)
dispatch(setUpdateNotification(!updateNotification)) dispatch(setUpdateNotification(!updateNotification))
} catch (error) { } catch (error) {
@@ -92,28 +89,33 @@ export default function Notification() {
} }
} }
const handleRefresh = async () => { const arrSkeleton = [0, 1, 2, 3, 4]
setRefreshing(true)
handleLoad(false, 1) const getItem = (_data: unknown, index: number): Props => ({
await new Promise(resolve => setTimeout(resolve, 2000)); id: flatData[index]?.id,
setRefreshing(false) title: flatData[index]?.title,
}; desc: flatData[index]?.desc,
category: flatData[index]?.category,
idContent: flatData[index]?.idContent,
isRead: flatData[index]?.isRead,
createdAt: flatData[index]?.createdAt,
});
return ( return (
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}> <SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<View style={[Styles.p15]}> <View style={[Styles.p15]}>
{ {
loading ? isLoading ?
arrSkeleton.map((item, index) => { arrSkeleton.map((item, index) => {
return ( return (
<SkeletonTwoItem key={index} /> <SkeletonTwoItem key={index} />
) )
}) })
: :
data.length > 0 ? flatData.length > 0 ?
<VirtualizedList <VirtualizedList
data={data} data={flatData}
getItemCount={() => data.length} getItemCount={() => flatData.length}
getItem={getItem} getItem={getItem}
renderItem={({ item, index }: { item: Props, index: number }) => { renderItem={({ item, index }: { item: Props, index: number }) => {
return ( return (

View File

@@ -16,8 +16,9 @@ import { setUpdatePosition } from "@/lib/positionSlice";
import { useAuthSession } from "@/providers/AuthProvider"; import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider"; import { useTheme } from "@/providers/ThemeProvider";
import { AntDesign, Feather, MaterialCommunityIcons } from "@expo/vector-icons"; import { AntDesign, Feather, MaterialCommunityIcons } from "@expo/vector-icons";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { RefreshControl, View, VirtualizedList } from "react-native"; import { RefreshControl, View, VirtualizedList } from "react-native";
import Toast from "react-native-toast-message"; import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
@@ -31,51 +32,53 @@ type Props = {
} }
export default function Index() { export default function Index() {
const arrSkeleton = Array.from({ length: 5 }, (_, index) => index)
const [loading, setLoading] = useState(true)
const { token, decryptToken } = useAuthSession() const { token, decryptToken } = useAuthSession()
const { colors } = useTheme() const { colors } = useTheme()
const [status, setStatus] = useState<'true' | 'false'>('true')
const entityUser = useSelector((state: any) => state.user)
const { active, group } = useLocalSearchParams<{ active?: string, group?: string }>() const { active, group } = useLocalSearchParams<{ active?: string, group?: string }>()
const [status, setStatus] = useState<'true' | 'false'>(active == 'false' ? 'false' : 'true')
const entityUser = useSelector((state: any) => state.user)
const [isModal, setModal] = useState(false) const [isModal, setModal] = useState(false)
const [isVisibleEdit, setVisibleEdit] = useState(false) const [isVisibleEdit, setVisibleEdit] = useState(false)
const [data, setData] = useState<Props[]>([])
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [nameGroup, setNameGroup] = useState('')
const [loadingSubmit, setLoadingSubmit] = useState(false) const [loadingSubmit, setLoadingSubmit] = useState(false)
const [chooseData, setChooseData] = useState({ name: '', id: '', active: false, idGroup: '' }) const [chooseData, setChooseData] = useState({ name: '', id: '', active: false, idGroup: '' })
const [error, setError] = useState({ const [error, setError] = useState({
name: false, name: false,
}); });
const queryClient = useQueryClient()
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false)
const dispatch = useDispatch() const dispatch = useDispatch()
const update = useSelector((state: any) => state.positionUpdate) const update = useSelector((state: any) => state.positionUpdate)
async function handleLoad(loading: boolean) { // TanStack Query for Positions
try { const {
setLoading(loading) data: queryData,
isLoading,
refetch
} = useQuery({
queryKey: ['positions', { status, search, group }],
queryFn: async () => {
const hasil = await decryptToken(String(token?.current)) const hasil = await decryptToken(String(token?.current))
const response = await apiGetPosition({ user: hasil, active: status, search: search, group: String(group) }) const response = await apiGetPosition({
setData(response.data) user: hasil,
setNameGroup(response.filter.name) active: status,
} catch (error) { search: search,
console.error(error) group: String(group)
} finally { })
setLoading(false) return response;
} },
} enabled: !!token?.current,
staleTime: 0,
})
const data = useMemo(() => queryData?.data || [], [queryData])
const nameGroup = useMemo(() => queryData?.filter?.name || "", [queryData])
useEffect(() => { useEffect(() => {
handleLoad(false) refetch()
}, [update]) }, [update, refetch])
useEffect(() => {
handleLoad(true)
}, [status, search, group])
function handleChooseData(id: string, name: string, active: boolean, group: string) { function handleChooseData(id: string, name: string, active: boolean, group: string) {
@@ -88,8 +91,11 @@ export default function Index() {
const hasil = await decryptToken(String(token?.current)) const hasil = await decryptToken(String(token?.current))
const response = await apiDeletePosition({ user: hasil, isActive: chooseData.active }, chooseData.id) const response = await apiDeletePosition({ user: hasil, isActive: chooseData.active }, chooseData.id)
dispatch(setUpdatePosition(!update)) dispatch(setUpdatePosition(!update))
} catch (error) { } catch (error: any) {
console.error(error) console.error(error);
const message = error?.response?.data?.message || "Gagal menghapus data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setModal(false) setModal(false)
Toast.show({ type: 'small', text1: 'Berhasil mengupdate data', }) Toast.show({ type: 'small', text1: 'Berhasil mengupdate data', })
@@ -107,8 +113,11 @@ export default function Index() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error: any) {
console.error(error) console.error(error);
const message = error?.response?.data?.message || "Gagal mengubah data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoadingSubmit(false) setLoadingSubmit(false)
setVisibleEdit(false) setVisibleEdit(false)
@@ -132,10 +141,11 @@ export default function Index() {
handleEdit() handleEdit()
} }
const arrSkeleton = [0, 1, 2, 3, 4]
const handleRefresh = async () => { const handleRefresh = async () => {
setRefreshing(true) setRefreshing(true)
handleLoad(false) await queryClient.invalidateQueries({ queryKey: ['positions'] })
await new Promise(resolve => setTimeout(resolve, 2000));
setRefreshing(false) setRefreshing(false)
}; };
@@ -178,7 +188,7 @@ export default function Index() {
</View> </View>
<View style={[Styles.flex2, Styles.mt10]}> <View style={[Styles.flex2, Styles.mt10]}>
{ {
loading ? isLoading ?
arrSkeleton.map((item, index) => { arrSkeleton.map((item, index) => {
return ( return (
<SkeletonTwoItem key={index} /> <SkeletonTwoItem key={index} />

View File

@@ -118,9 +118,11 @@ export default function ProjectAddFile() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error : any ) {
console.error(error); console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal menambahkan data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -45,9 +45,11 @@ export default function AddMemberProject() {
setIdGroup(responseGroup.data.idGroup) setIdGroup(responseGroup.data.idGroup)
const responsemember = await apiGetUser({ user: hasil, active: "true", search: search, group: String(responseGroup.data.idGroup) }) const responsemember = await apiGetUser({ user: hasil, active: "true", search: search, group: String(responseGroup.data.idGroup) })
setData(responsemember.data.filter((i: any) => i.idUserRole != 'supadmin')) setData(responsemember.data.filter((i: any) => i.idUserRole != 'supadmin'))
} catch (error) { } catch (error : any ) {
console.error(error) console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal mengambil data"
Toast.show({ type: 'small', text1: message })
} }
} }
@@ -86,9 +88,11 @@ export default function AddMemberProject() {
dispatch(setUpdateProject({ ...update, member: !update.member })) dispatch(setUpdateProject({ ...update, member: !update.member }))
router.back() router.back()
} }
} catch (error) { } catch (error : any ) {
console.error(error) console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal menambahkan anggota"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -126,9 +126,11 @@ export default function ProjectAddTask() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error : any ) {
console.error(error); console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal menambahkan data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -58,9 +58,11 @@ export default function ProjectCancel() {
Toast.show({ type: 'small', text1: 'Berhasil membatalkan kegiatan', }) Toast.show({ type: 'small', text1: 'Berhasil membatalkan kegiatan', })
router.back(); router.back();
} }
} catch (error) { } catch (error : any ) {
console.error(error); console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal membatalkan kegiatan"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -77,9 +77,11 @@ export default function EditProject() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error : any ) {
console.error(error); console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal mengubah data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -77,9 +77,11 @@ export default function ReportProject() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error : any ) {
console.error(error); console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal mengubah data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -151,9 +151,11 @@ export default function CreateProject() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error : any ) {
console.error(error) console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal menambahkan data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -18,8 +18,9 @@ import {
Ionicons, Ionicons,
MaterialCommunityIcons, MaterialCommunityIcons,
} from "@expo/vector-icons"; } from "@expo/vector-icons";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { router, useLocalSearchParams } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Pressable, RefreshControl, ScrollView, View, VirtualizedList } from "react-native"; import { Pressable, RefreshControl, ScrollView, View, VirtualizedList } from "react-native";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
@@ -40,28 +41,29 @@ export default function ListProject() {
cat?: string; cat?: string;
year?: string; year?: string;
}>(); }>();
const [statusFix, setStatusFix] = useState<'0' | '1' | '2' | '3'>('0') const [statusFix, setStatusFix] = useState<'0' | '1' | '2' | '3'>(
(status == '1' || status == '2' || status == '3') ? status : '0'
)
const { token, decryptToken } = useAuthSession(); const { token, decryptToken } = useAuthSession();
const { colors } = useTheme(); const { colors } = useTheme();
const entityUser = useSelector((state: any) => state.user) const entityUser = useSelector((state: any) => state.user)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [nameGroup, setNameGroup] = useState("")
// ... state same ...
const [isYear, setYear] = useState("")
const [data, setData] = useState<Props[]>([])
const [isList, setList] = useState(false) const [isList, setList] = useState(false)
const update = useSelector((state: any) => state.projectUpdate) const update = useSelector((state: any) => state.projectUpdate)
const [loading, setLoading] = useState(true) const queryClient = useQueryClient()
const arrSkeleton = Array.from({ length: 3 }, (_, index) => index)
const [page, setPage] = useState(1)
const [waiting, setWaiting] = useState(false)
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
async function handleLoad(loading: boolean, thisPage: number) { // TanStack Query for Projects with Infinite Scroll
try { const {
setLoading(loading) data,
setWaiting(true) fetchNextPage,
setPage(thisPage) hasNextPage,
isFetchingNextPage,
isLoading,
refetch
} = useInfiniteQuery({
queryKey: ['projects', { statusFix, search, group, cat, year }],
queryFn: async ({ pageParam = 1 }) => {
const hasil = await decryptToken(String(token?.current)); const hasil = await decryptToken(String(token?.current));
const response = await apiGetProject({ const response = await apiGetProject({
user: hasil, user: hasil,
@@ -69,60 +71,55 @@ export default function ListProject() {
search: search, search: search,
group: String(group), group: String(group),
kategori: String(cat), kategori: String(cat),
page: thisPage, page: pageParam,
year: String(year) year: String(year)
}); });
return response;
},
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) => {
return lastPage.data.length > 0 ? allPages.length + 1 : undefined;
},
enabled: !!token?.current,
staleTime: 0,
})
if (response.success) { // Refetch when manual update state changes
setNameGroup(response.filter.name);
setYear(response.tahun)
if (thisPage == 1) {
setData(response.data);
} else if (thisPage > 1 && response.data.length > 0) {
setData([...data, ...response.data])
} else {
return;
}
}
} catch (error) {
console.error(error);
} finally {
setLoading(false)
setWaiting(false)
}
}
useEffect(() => { useEffect(() => {
handleLoad(false, 1); refetch()
}, [update.data]); }, [update.data, refetch])
// Flatten pages into a single data array
const flatData = useMemo(() => {
return data?.pages.flatMap(page => page.data) || [];
}, [data])
useEffect(() => { // Get metadata from the first available page
handleLoad(true, 1); const nameGroup = useMemo(() => data?.pages[0]?.filter?.name || "", [data])
}, [statusFix, search, group, cat, year]); const isYear = useMemo(() => data?.pages[0]?.tahun || "", [data])
const loadMoreData = () => {
if (waiting) return
setTimeout(() => {
handleLoad(false, page + 1)
}, 1000);
}
const handleRefresh = async () => { const handleRefresh = async () => {
setRefreshing(true) setRefreshing(true)
handleLoad(false, 1) await queryClient.invalidateQueries({ queryKey: ['projects'] })
await new Promise(resolve => setTimeout(resolve, 2000));
setRefreshing(false) setRefreshing(false)
} };
const loadMoreData = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
};
const arrSkeleton = [0, 1, 2]
const getItem = (_data: unknown, index: number): Props => ({ const getItem = (_data: unknown, index: number): Props => ({
id: data[index].id, id: flatData[index]?.id,
title: data[index].title, title: flatData[index]?.title,
desc: data[index].desc, desc: flatData[index]?.desc,
status: data[index].status, status: flatData[index]?.status,
member: data[index].member, member: flatData[index]?.member,
progress: data[index].progress, progress: flatData[index]?.progress,
createdAt: data[index].createdAt, createdAt: flatData[index]?.createdAt,
}) })
return ( return (
@@ -205,7 +202,6 @@ export default function ListProject() {
</View> </View>
<View style={[Styles.mt10]}> <View style={[Styles.mt10]}>
{ {
// entityUser.role != 'cosupadmin' && entityUser.role != 'admin' &&
<View style={[Styles.rowOnly]}> <View style={[Styles.rowOnly]}>
<Text style={[Styles.mr05]}>Filter :</Text> <Text style={[Styles.mr05]}>Filter :</Text>
{ {
@@ -218,18 +214,13 @@ export default function ListProject() {
: '' : ''
} }
<LabelStatus size="small" category="secondary" text={isYear} style={[Styles.mr05]} /> <LabelStatus size="small" category="secondary" text={isYear} style={[Styles.mr05]} />
{/* {
(entityUser.role == 'user' || entityUser.role == 'coadmin')
? (cat == 'null' || cat == 'undefined' || cat == undefined || cat == '' || cat == 'data-saya') ? <LabelStatus size="small" category="primary" text="Kegiatan Saya" /> : <LabelStatus size="small" category="primary" text="Semua Kegiatan" />
: ''
} */}
</View> </View>
} }
</View> </View>
</View> </View>
<View style={[Styles.flex2, Styles.mt10]}> <View style={[Styles.flex2, Styles.mt10]}>
{ {
loading ? isLoading ?
isList ? isList ?
arrSkeleton.map((item, index) => ( arrSkeleton.map((item, index) => (
<SkeletonTwoItem key={index} /> <SkeletonTwoItem key={index} />
@@ -239,13 +230,13 @@ export default function ListProject() {
<Skeleton key={index} width={100} height={180} widthType="percent" borderRadius={10} /> <Skeleton key={index} width={100} height={180} widthType="percent" borderRadius={10} />
)) ))
: :
data.length > 0 flatData.length > 0
? ?
isList ? ( isList ? (
<View style={[Styles.h100]}> <View style={[Styles.h100]}>
<VirtualizedList <VirtualizedList
data={data} data={flatData}
getItemCount={() => data.length} getItemCount={() => flatData.length}
getItem={getItem} getItem={getItem}
renderItem={({ item, index }: { item: Props, index: number }) => { renderItem={({ item, index }: { item: Props, index: number }) => {
return ( return (
@@ -279,35 +270,12 @@ export default function ListProject() {
/> />
} }
/> />
{/* {
data.map((item, index) => {
return (
<BorderBottomItem
key={index}
onPress={() => { router.push(`/project/${item.id}`); }}
borderType="bottom"
icon={
<View
style={[Styles.iconContent, ColorsStatus.lightGreen]}
>
<AntDesign
name="areachart"
size={25}
color={"#384288"}
/>
</View>
}
title={item.title}
/>
);
})
} */}
</View> </View>
) : ( ) : (
<View style={[Styles.h100]}> <View style={[Styles.h100]}>
<VirtualizedList <VirtualizedList
data={data} data={flatData}
getItemCount={() => data.length} getItemCount={() => flatData.length}
getItem={getItem} getItem={getItem}
renderItem={({ item, index }: { item: Props, index: number }) => { renderItem={({ item, index }: { item: Props, index: number }) => {
return ( return (
@@ -319,6 +287,7 @@ export default function ListProject() {
content="page" content="page"
title={item.title} title={item.title}
headerColor="primary" headerColor="primary"
titleTail={2}
> >
<ProgressBar value={item.progress} category="list" /> <ProgressBar value={item.progress} category="list" />
<View style={[Styles.rowSpaceBetween]}> <View style={[Styles.rowSpaceBetween]}>
@@ -358,43 +327,6 @@ export default function ListProject() {
/> />
} }
/> />
{/* {data.map((item, index) => {
return (
<PaperGridContent
key={index}
onPress={() => {
router.push(`/project/${item.id}`);
}}
content="page"
title={item.title}
headerColor="primary"
>
<ProgressBar value={item.progress} category="list" />
<View style={[Styles.rowSpaceBetween]}>
<Text style={[Styles.textDefault, { color: colors.dimmed }]}>
{item.createdAt}
</Text>
<LabelStatus
size="default"
category={
item.status === 0 ? 'primary' :
item.status === 1 ? 'warning' :
item.status === 2 ? 'success' :
item.status === 3 ? 'error' :
'primary'
}
text={
item.status === 0 ? 'SEGERA' :
item.status === 1 ? 'DIKERJAKAN' :
item.status === 2 ? 'SELESAI' :
item.status === 3 ? 'DIBATALKAN' :
'SEGERA'
}
/>
</View>
</PaperGridContent>
);
})} */}
</View> </View>
) )
: :

View File

@@ -118,9 +118,11 @@ export default function UpdateProjectTask() {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error : any ) {
console.error(error); console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal mengubah data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoadingSubmit(false) setLoadingSubmit(false)
} }

View File

@@ -65,9 +65,14 @@ export default function Search() {
setDataDivisi([]) setDataDivisi([])
setDataProject([]) setDataProject([])
} }
} catch (error) { } catch (error: any) {
console.error(error) console.error(error);
return Toast.show({ type: 'small', text1: 'Gagal melakukan pencarian', }) const message = error?.response?.data?.message || "Gagal melakukan pencarian"
Toast.show({
type: 'small',
text1: message
})
} }
} }

View File

@@ -3,13 +3,15 @@ import Text from "@/components/Text";
import ButtonSetting from "@/components/buttonSetting"; import ButtonSetting from "@/components/buttonSetting";
import DrawerBottom from "@/components/drawerBottom"; import DrawerBottom from "@/components/drawerBottom";
import Styles from "@/constants/Styles"; import Styles from "@/constants/Styles";
import { apiRegisteredToken, apiUnregisteredToken } from "@/lib/api"; import { apiGetCheckToken, apiRegisteredToken, apiUnregisteredToken } from "@/lib/api";
import { checkPermission, getToken, openSettings, requestPermission } from "@/lib/useNotification"; import { checkPermission, getToken, openSettings } from "@/lib/useNotification";
import { useAuthSession } from "@/providers/AuthProvider"; import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider"; import { useTheme } from "@/providers/ThemeProvider";
import { Feather, Ionicons } from "@expo/vector-icons"; import { Feather, Ionicons } from "@expo/vector-icons";
import AsyncStorage from "@react-native-async-storage/async-storage";
import Constants from "expo-constants";
import { router } from "expo-router"; import { router } from "expo-router";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { AppState, AppStateStatus, Pressable, View } from "react-native"; import { AppState, AppStateStatus, Pressable, View } from "react-native";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
@@ -28,12 +30,14 @@ export default function ListSetting() {
const [showLogoutModal, setShowLogoutModal] = useState(false) const [showLogoutModal, setShowLogoutModal] = useState(false)
const [showThemeModal, setShowThemeModal] = useState(false) const [showThemeModal, setShowThemeModal] = useState(false)
const prevOsPermission = useRef<boolean | undefined>(undefined);
const registerToken = async () => { const registerToken = async () => {
try { try {
await AsyncStorage.setItem('@notification_permission', "true");
const token = await getToken(); const token = await getToken();
if (token) { if (token) {
await apiRegisteredToken({ user: entities.id, token }); await apiRegisteredToken({ user: entities.id, token, category: "register" });
} }
} catch (error) { } catch (error) {
console.warn('Error registering token:', error); console.warn('Error registering token:', error);
@@ -42,9 +46,10 @@ export default function ListSetting() {
const unregisterToken = async () => { const unregisterToken = async () => {
try { try {
await AsyncStorage.setItem('@notification_permission', "false");
const token = await getToken(); const token = await getToken();
if (token) { if (token) {
await apiUnregisteredToken({ user: entities.id, token }); await apiUnregisteredToken({ user: entities.id, token, category: "unregister" });
} }
} catch (error) { } catch (error) {
console.warn('Error unregistering token:', error); console.warn('Error unregistering token:', error);
@@ -52,15 +57,31 @@ export default function ListSetting() {
}; };
const checkNotif = useCallback(async () => { const checkNotif = useCallback(async () => {
const status = await checkPermission(); const osPermission = await checkPermission();
setIsNotificationEnabled((prev) => {
if (prev === false && status === true) { // Jika dari tidak diijinkan sistem kemudian diijinkan (setelah balik dari pengaturan device)
registerToken(); if (prevOsPermission.current === false && osPermission === true) {
} else if (prev === true && status === false) { await registerToken();
unregisterToken(); }
prevOsPermission.current = osPermission;
if (!osPermission) {
setIsNotificationEnabled(false);
return;
}
try {
const token = await getToken();
if (token) {
const response = await apiGetCheckToken({ user: entities.id, token });
setIsNotificationEnabled(!!response.data);
} else {
setIsNotificationEnabled(false);
} }
return !!status; } catch (error) {
}); console.warn('Error checking token status:', error);
setIsNotificationEnabled(false);
}
}, [entities.id]); }, [entities.id]);
useEffect(() => { useEffect(() => {
@@ -78,10 +99,12 @@ export default function ListSetting() {
}, [checkNotif]); }, [checkNotif]);
const handleToggleNotif = async () => { const handleToggleNotif = async () => {
if (isNotificationEnabled) { const osPermission = await checkPermission();
if (!osPermission) {
setModalConfig({ setModalConfig({
title: "Matikan Notifikasi?", title: "Aktifkan Notifikasi?",
message: "Anda akan diarahkan ke pengaturan sistem untuk mematikan notifikasi.", message: "Izin notifikasi tidak diberikan. Buka pengaturan sistem untuk mengaktifkannya?",
confirmText: "Buka Pengaturan", confirmText: "Buka Pengaturan",
onConfirm: () => { onConfirm: () => {
setModalVisible(false); setModalVisible(false);
@@ -90,22 +113,15 @@ export default function ListSetting() {
}); });
setModalVisible(true); setModalVisible(true);
} else { } else {
const granted = await requestPermission(); // OS Permission is granted, perform in-app toggle
if (granted) { const targetState = !isNotificationEnabled;
setIsNotificationEnabled(true); if (targetState) {
registerToken(); await registerToken();
} else { } else {
setModalConfig({ await unregisterToken();
title: "Aktifkan Notifikasi?",
message: "Izin notifikasi tidak diberikan. Buka pengaturan sistem untuk mengaktifkannya?",
confirmText: "Buka Pengaturan",
onConfirm: () => {
setModalVisible(false);
openSettings();
}
});
setModalVisible(true);
} }
// UI will be updated by checkNotif (triggered by state change or manually here)
setIsNotificationEnabled(targetState);
} }
}; };
@@ -181,6 +197,10 @@ export default function ListSetting() {
<ThemeOption label="Gelap" value="dark" icon="moon-outline" /> <ThemeOption label="Gelap" value="dark" icon="moon-outline" />
<ThemeOption label="Sistem" value="system" icon="phone-portrait-outline" /> <ThemeOption label="Sistem" value="system" icon="phone-portrait-outline" />
</DrawerBottom> </DrawerBottom>
<Text style={{ color: colors.icon, textAlign: 'center', marginTop: 'auto', fontSize: 12 }}>
Versi {Constants.expoConfig?.version}
</Text>
</View> </View>
) )
} }

View File

@@ -1,10 +1,14 @@
import AuthProvider from '@/providers/AuthProvider'; import AuthProvider from '@/providers/AuthProvider';
import ThemeProvider, { useTheme } from '@/providers/ThemeProvider'; import ThemeProvider, { useTheme } from '@/providers/ThemeProvider';
import QueryProvider from '@/providers/QueryProvider';
import ErrorBoundary from '@/components/ErrorBoundary';
import { flushErrorQueue } from '@/lib/errorLogger';
import { useFonts } from 'expo-font'; import { useFonts } from 'expo-font';
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen'; import * as SplashScreen from 'expo-splash-screen';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { AppState } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { NotifierWrapper } from 'react-native-notifier'; import { NotifierWrapper } from 'react-native-notifier';
import 'react-native-reanimated'; import 'react-native-reanimated';
@@ -21,7 +25,6 @@ function AppStack() {
<> <>
<Stack screenOptions={{ contentStyle: { backgroundColor: colors.header } }}> <Stack screenOptions={{ contentStyle: { backgroundColor: colors.header } }}>
<Stack.Screen name="index" options={{ headerShown: false }} /> <Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="verification" options={{ headerShown: false }} />
<Stack.Screen name="(application)" options={{ headerShown: false }} /> <Stack.Screen name="(application)" options={{ headerShown: false }} />
</Stack> </Stack>
<StatusBar style="auto" /> <StatusBar style="auto" />
@@ -37,22 +40,34 @@ export default function RootLayout() {
useEffect(() => { useEffect(() => {
if (loaded) { if (loaded) {
SplashScreen.hideAsync(); SplashScreen.hideAsync();
flushErrorQueue();
} }
}, [loaded]); }, [loaded]);
useEffect(() => {
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') flushErrorQueue();
});
return () => sub.remove();
}, []);
if (!loaded) { if (!loaded) {
return null; return null;
} }
return ( return (
<GestureHandlerRootView style={Styles.flex1}> <GestureHandlerRootView style={Styles.flex1}>
<NotifierWrapper> <ErrorBoundary>
<ThemeProvider> <NotifierWrapper>
<AuthProvider> <ThemeProvider>
<AppStack /> <QueryProvider>
</AuthProvider> <AuthProvider>
</ThemeProvider> <AppStack />
</NotifierWrapper> </AuthProvider>
</QueryProvider>
</ThemeProvider>
</NotifierWrapper>
</ErrorBoundary>
</GestureHandlerRootView> </GestureHandlerRootView>
); );
} }

View File

@@ -1,77 +0,0 @@
import { ButtonForm } from "@/components/buttonForm";
import Text from '@/components/Text';
import { ConstEnv } from "@/constants/ConstEnv";
import Styles from "@/constants/Styles";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import CryptoES from "crypto-es";
import React, { useState } from "react";
import { Image, View } from "react-native";
import { CodeField, Cursor, useBlurOnFulfill, useClearByFocusCell, } from 'react-native-confirmation-code-field';
export default function Index() {
const [value, setValue] = useState('');
const ref = useBlurOnFulfill({ value, cellCount: 4 });
const [props, getCellOnLayoutHandler] = useClearByFocusCell({
value,
setValue,
});
const { colors } = useTheme();
const { signIn } = useAuthSession();
const login = (): void => {
// WARNING: This is a hardcoded bypass for development purposes.
// It should be removed or secured before production release.
if (__DEV__) {
const random: string = 'contohLoginMobileDarmasaba';
var mytexttoEncryption = "contohLoginMobileDarmasaba"
const encrypted = CryptoES.AES.encrypt(mytexttoEncryption, ConstEnv.pass_encrypt).toString();
signIn(encrypted);
} else {
console.warn("Bypass login disabled in production.");
}
}
return (
<View style={[Styles.wrapLogin, { backgroundColor: colors.background }]} >
<View style={[Styles.rowItemsCenter, Styles.mv50]}>
<Image
source={require("../assets/images/logo.png")}
style={[{ width: 300, height: 150 }]}
width={270}
height={110}
/>
{/* <Text style={[Styles.textSubtitle]}>PERBEKEL DARMASABA</Text> */}
</View>
<View style={[Styles.mb30]}>
<Text style={[Styles.textDefaultSemiBold]}>Verifikasi Nomor Telepon</Text>
<Text style={[Styles.textMediumNormal]}>Masukkan kode yang kami kirimkan melalui WhatsApp</Text>
<Text style={[Styles.textMediumSemiBold]}>+628980185458</Text>
</View>
<CodeField
ref={ref}
{...props}
value={value}
rootStyle={{ width: '80%', alignSelf: 'center' }}
onChangeText={setValue}
cellCount={4}
keyboardType="number-pad"
renderCell={({ index, symbol, isFocused }) => (
<Text
key={index}
style={[Styles.verificationCell, isFocused && Styles.verificationFocusCell, { borderColor: isFocused ? colors.tint : colors.icon, color: colors.text }]}
onLayout={getCellOnLayoutHandler(index)}>
{symbol || (isFocused ? <Cursor /> : null)}
</Text>
)}
/>
<ButtonForm
text="SUBMIT"
// onPress={() => { router.push("/home") }}
onPress={login}
/>
<Text style={[Styles.textInformation, Styles.mt05, Styles.cDefault, { textAlign: 'center', color: colors.tint }]}>
Tidak Menerima kode verifikasi? Kirim Ulang
</Text>
</View>
);
}

2718
bun.lock Normal file

File diff suppressed because it is too large Load Diff

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,6 +1,5 @@
import Styles from '@/constants/Styles'; import Styles from '@/constants/Styles';
import { useTheme } from "@/providers/ThemeProvider"; import { useTheme } from "@/providers/ThemeProvider";
import { useRouter } from 'expo-router';
import { Platform, Text, View } from 'react-native'; import { Platform, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import ButtonBackHeader from './buttonBackHeader'; import ButtonBackHeader from './buttonBackHeader';
@@ -15,13 +14,12 @@ type Props = {
export default function AppHeader({ title, right, showBack = true, onPressLeft, left }: Props) { export default function AppHeader({ title, right, showBack = true, onPressLeft, left }: Props) {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter();
const { colors } = useTheme(); const { colors } = useTheme();
return ( return (
<View style={[Styles.headerContainer, Platform.OS === 'ios' ? Styles.pb05 : Styles.pb13, { backgroundColor: colors.header, paddingTop: Platform.OS === 'ios' ? insets.top : 10 }]}> <View style={[Styles.headerContainer, Platform.OS === 'ios' ? Styles.pb05 : Styles.pb13, { backgroundColor: colors.header, paddingTop: Platform.OS === 'ios' ? insets.top : 10 }]}>
<View style={Styles.headerApp}> <View style={Styles.headerApp}>
<View style={[Styles.rowItemsCenter]}> <View style={[Styles.rowItemsCenter, Styles.flex1]}>
{showBack ? ( {showBack ? (
<ButtonBackHeader onPress={onPressLeft} /> <ButtonBackHeader onPress={onPressLeft} />
) : ) :
@@ -30,7 +28,9 @@ export default function AppHeader({ title, right, showBack = true, onPressLeft,
<View style={Styles.headerSide} /> <View style={Styles.headerSide} />
)} )}
<Text style={[Styles.headerTitle, Styles.ml05]}>{title}</Text> <Text style={[Styles.headerTitle, Styles.ml05, Styles.flex1, Styles.mr10]} numberOfLines={1} ellipsizeMode="tail">
{title ? title.charAt(0).toUpperCase() + title.slice(1) : ""}
</Text>
</View> </View>
<View style={Styles.headerSide}>{right}</View> <View style={Styles.headerSide}>{right}</View>
</View> </View>

View File

@@ -0,0 +1,79 @@
import React, { Component, ReactNode } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
type Props = {
children: ReactNode;
fallback?: ReactNode;
};
type State = {
hasError: boolean;
error: Error | null;
};
export default class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error): void {
const { logError } = require('@/lib/errorLogger');
logError(error.message, error);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback;
return (
<View style={styles.container}>
<Text style={styles.title}>Terjadi Kesalahan</Text>
<Text style={styles.message}>
Silahkan coba lagi beberapa saat lagi atau hubungi admin untuk bantuan.
</Text>
<TouchableOpacity style={styles.button} onPress={this.handleReset}>
<Text style={styles.buttonText}>Coba Lagi</Text>
</TouchableOpacity>
</View>
);
}
return this.props.children;
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 24,
backgroundColor: '#f7f7f7',
},
title: {
fontSize: 18,
fontWeight: '600',
marginBottom: 8,
color: '#11181C',
},
message: {
fontSize: 14,
color: '#707887',
textAlign: 'center',
marginBottom: 24,
},
button: {
backgroundColor: '#19345E',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
},
buttonText: {
color: '#fff',
fontWeight: '600',
},
});

42
components/ErrorView.tsx Normal file
View File

@@ -0,0 +1,42 @@
import Text from '@/components/Text';
import Styles from '@/constants/Styles';
import { useTheme } from '@/providers/ThemeProvider';
import { MaterialCommunityIcons } from "@expo/vector-icons";
import React from 'react';
import { View } from 'react-native';
interface ErrorViewProps {
title?: string;
message?: string;
icon?: keyof typeof MaterialCommunityIcons.glyphMap;
}
/**
* ErrorView component to display error or empty states.
* Used when data is not found, deleted, or an error occurs during fetching.
*/
export default function ErrorView({
title = "Terjadi Kesalahan",
message = "Data tidak ditemukan atau sudah dihapus.",
icon = "alert-circle-outline"
}: ErrorViewProps) {
const { colors } = useTheme();
return (
<View style={[Styles.flex1, Styles.contentItemCenter, Styles.ph20]}>
<View style={[Styles.mb10]}>
<MaterialCommunityIcons
name={icon}
size={40}
color={colors.dimmed}
/>
</View>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text, textAlign: 'center' }]}>
{title}
</Text>
<Text style={[Styles.textMediumNormal, { color: colors.dimmed, textAlign: 'center', marginTop: 4 }]}>
{message}
</Text>
</View>
);
}

View File

@@ -0,0 +1,121 @@
import React from 'react';
import { Modal, View, Image, TouchableOpacity, BackHandler, Platform } from 'react-native';
import { useTheme } from '@/providers/ThemeProvider';
import Text from './Text';
import * as Linking from 'expo-linking';
import Styles from '@/constants/Styles';
interface ModalUpdateMaintenanceProps {
visible: boolean;
type: 'update' | 'maintenance';
isForceUpdate?: boolean;
onDismiss?: () => void;
appName?: string;
customDescription?: string;
androidStoreUrl?: string;
iosStoreUrl?: string;
}
const ModalUpdateMaintenance: React.FC<ModalUpdateMaintenanceProps> = ({
visible,
type,
isForceUpdate = false,
onDismiss,
appName = 'Desa+',
customDescription,
androidStoreUrl = 'https://play.google.com/store/apps/details?id=mobiledarmasaba.app',
iosStoreUrl = 'https://apps.apple.com/id/app/desa-plus-desa/id6752375538'
}) => {
const { colors } = useTheme();
const handleUpdate = () => {
const storeUrl = Platform.OS === 'ios' ? iosStoreUrl : androidStoreUrl;
Linking.openURL(storeUrl);
};
const handleCloseApp = () => {
// For maintenance mode, we might want to exit the app or just keep the modal.
// React Native doesn't have a built-in "exit" for iOS, but for Android:
if (Platform.OS === 'android') {
BackHandler.exitApp();
}
};
return (
<Modal
visible={visible}
animationType="fade"
transparent={false}
onRequestClose={() => {
if (!isForceUpdate && type === 'update') {
onDismiss?.();
}
}}
>
<View style={[Styles.modalUpdateContainer, { backgroundColor: colors.primary }]}>
{/* Background decorative circles could be added here if we had SVGs or images */}
<View style={Styles.modalUpdateDecorativeCircle1} />
<View style={Styles.modalUpdateDecorativeCircle2} />
<View style={Styles.modalUpdateContent}>
<Image
source={require('@/assets/images/logo-dark.png')}
style={Styles.modalUpdateLogo}
resizeMode="contain"
/>
<View style={Styles.modalUpdateTextContainer}>
<Text style={Styles.modalUpdateTitle}>
{type === 'update' ? 'Update Tersedia' : 'Perbaikan'}
</Text>
<Text style={[Styles.modalUpdateDescription, { opacity: 0.8 }]}>
{customDescription ? customDescription :
(type === 'update'
? `Versi terbaru dari ${appName} tersedia di Store. Silakan buka Store untuk menginstalnya.`
: 'Aplikasi saat ini sedang dalam pemeliharaan untuk peningkatan sistem. Silakan coba kembali beberapa saat lagi.')}
</Text>
</View>
<View style={Styles.modalUpdateButtonContainer}>
{type === 'update' ? (
<>
<TouchableOpacity
style={[Styles.modalUpdatePrimaryButton, { backgroundColor: 'white' }]}
onPress={handleUpdate}
activeOpacity={0.8}
>
<Text style={[Styles.modalUpdatePrimaryButtonText, { color: colors.primary }]}>
Update
</Text>
</TouchableOpacity>
{!isForceUpdate && (
<TouchableOpacity
style={Styles.modalUpdateSecondaryButton}
onPress={onDismiss}
activeOpacity={0.7}
>
<Text style={Styles.modalUpdateSecondaryButtonText}>Nanti</Text>
</TouchableOpacity>
)}
</>
) : (
<></>
// <TouchableOpacity
// style={[Styles.modalUpdatePrimaryButton, { backgroundColor: 'white' }]}
// onPress={handleCloseApp}
// activeOpacity={0.8}
// >
// <Text style={[Styles.modalUpdatePrimaryButtonText, { color: colors.primary }]}>
// {Platform.OS === 'android' ? 'Close App' : 'Please check back later'}
// </Text>
// </TouchableOpacity>
)}
</View>
</View>
</View>
</Modal>
);
};
export default ModalUpdateMaintenance;

View File

@@ -22,7 +22,7 @@ export default function ViewLogin({ onValidate }: Props) {
const [disableLogin, setDisableLogin] = useState(true) const [disableLogin, setDisableLogin] = useState(true)
const [phone, setPhone] = useState('') const [phone, setPhone] = useState('')
const { signIn, encryptToken } = useAuthSession(); const { signIn, encryptToken } = useAuthSession();
const { colors, theme } = useTheme(); const { colors, activeTheme } = useTheme();
const handleCheckPhone = async () => { const handleCheckPhone = async () => {
try { try {
@@ -38,13 +38,18 @@ export default function ViewLogin({ onValidate }: Props) {
if (responseOtp == 200) { if (responseOtp == 200) {
await AsyncStorage.setItem('user', response.id) await AsyncStorage.setItem('user', response.id)
return onValidate({ phone: `62${phone}`, otp }) return onValidate({ phone: `62${phone}`, otp })
} else {
return Toast.show({ type: 'small', text1: 'Gagal mengirim kode verifikasi', position: 'bottom' })
} }
} }
} else { } else {
return Toast.show({ type: 'small', text1: response.message, position: 'bottom' }) return Toast.show({ type: 'small', text1: response.message, position: 'bottom' })
} }
} catch (error) { } catch (error: any) {
return Toast.show({ type: 'small', text1: `Terjadi kesalahan, coba lagi`, position: 'bottom' }) console.error(error);
const message = error?.response?.data?.message || "Gagal login"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoadingLogin(false) setLoadingLogin(false)
} }
@@ -52,11 +57,11 @@ export default function ViewLogin({ onValidate }: Props) {
return ( return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}> <SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
<StatusBar style={theme === 'dark' ? 'light' : 'dark'} translucent={false} backgroundColor={colors.background} /> <StatusBar style={activeTheme === 'dark' ? 'light' : 'dark'} translucent={false} backgroundColor={colors.background} />
<View style={[Styles.p20, Styles.h100]}> <View style={[Styles.p20, Styles.h100]}>
<View style={{ alignItems: "center", marginTop: 70, marginBottom: 50 }}> <View style={{ alignItems: "center", marginTop: 70, marginBottom: 50 }}>
<Image <Image
source={theme === 'dark' ? require("../../assets/images/logo-dark.png") : require("../../assets/images/logo.png")} source={activeTheme === 'dark' ? require("../../assets/images/logo-dark.png") : require("../../assets/images/logo.png")}
style={[{ width: 300, height: 150 }]} style={[{ width: 300, height: 150 }]}
width={270} width={270}
height={110} height={110}
@@ -82,7 +87,7 @@ export default function ViewLogin({ onValidate }: Props) {
<View style={{ flex: 1 }} /> <View style={{ flex: 1 }} />
<View style={{ alignItems: 'center' }}> <View style={{ alignItems: 'center' }}>
<Image <Image
source={theme === 'dark' ? require("../../assets/images/cogniti-dark.png") : require("../../assets/images/cogniti-light.png")} source={activeTheme === 'dark' ? require("../../assets/images/cogniti-dark.png") : require("../../assets/images/cogniti-light.png")}
style={{ width: 86, height: 27 }} style={{ width: 86, height: 27 }}
resizeMode="contain" resizeMode="contain"
/> />

View File

@@ -21,7 +21,7 @@ export default function ViewVerification({ phone, otp }: Props) {
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const [otpFix, setOtpFix] = useState(otp) const [otpFix, setOtpFix] = useState(otp)
const { signIn, encryptToken } = useAuthSession(); const { signIn, encryptToken } = useAuthSession();
const { colors, theme } = useTheme(); const { colors, activeTheme } = useTheme();
const login = async () => { const login = async () => {
const valueUser = await AsyncStorage.getItem('user'); const valueUser = await AsyncStorage.getItem('user');
@@ -59,11 +59,11 @@ export default function ViewVerification({ phone, otp }: Props) {
return ( return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}> <SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
<StatusBar style={theme === 'dark' ? 'light' : 'dark'} translucent={false} backgroundColor={colors.background} /> <StatusBar style={activeTheme === 'dark' ? 'light' : 'dark'} translucent={false} backgroundColor={colors.background} />
<View style={[Styles.p20, Styles.h100]} > <View style={[Styles.p20, Styles.h100]} >
<View style={{ alignItems: "center", marginTop: 70, marginBottom: 50 }}> <View style={{ alignItems: "center", marginTop: 70, marginBottom: 50 }}>
<Image <Image
source={theme === 'dark' ? require("../../assets/images/logo-dark.png") : require("../../assets/images/logo.png")} source={activeTheme === 'dark' ? require("../../assets/images/logo-dark.png") : require("../../assets/images/logo.png")}
style={[{ width: 300, height: 150 }]} style={[{ width: 300, height: 150 }]}
width={270} width={270}
height={110} height={110}
@@ -101,7 +101,7 @@ export default function ViewVerification({ phone, otp }: Props) {
<View style={{ flex: 1 }} /> <View style={{ flex: 1 }} />
<View style={[{ alignItems: 'center' }]}> <View style={[{ alignItems: 'center' }]}>
<Image <Image
source={theme === 'dark' ? require("../../assets/images/cogniti-dark.png") : require("../../assets/images/cogniti-light.png")} source={activeTheme === 'dark' ? require("../../assets/images/cogniti-dark.png") : require("../../assets/images/cogniti-light.png")}
style={{ width: 86, height: 27 }} style={{ width: 86, height: 27 }}
resizeMode="contain" resizeMode="contain"
/> />

View File

@@ -48,7 +48,7 @@ export default function BorderBottomItem({ title, subtitle, icon, desc, onPress,
{icon} {icon}
<View style={[Styles.rowSpaceBetween, Styles.flex1]}> <View style={[Styles.rowSpaceBetween, Styles.flex1]}>
<View style={[Styles.ml10, Styles.flex1, Styles.mr10]}> <View style={[Styles.ml10, Styles.flex1, Styles.mr10]}>
<Text style={[titleWeight == 'normal' ? Styles.textDefault : Styles.textDefaultSemiBold, { color: textColorFix }]} numberOfLines={titleShowAll ? 0 : 1} ellipsizeMode='tail'>{title}</Text> <Text style={[titleWeight == 'normal' ? Styles.textDefault : Styles.textDefaultSemiBold, { color: textColorFix }]} numberOfLines={titleShowAll ? 0 : 1} ellipsizeMode='tail'>{title ? title.charAt(0).toUpperCase() + title.slice(1) : ""}</Text>
{ {
subtitle && subtitle &&
typeof subtitle == "string" typeof subtitle == "string"

View File

@@ -38,9 +38,11 @@ export default function HeaderRightCalendarDetail({ id, idReminder }: Props) {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error : any ) {
console.error(error); console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal menghapus data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setVisible(false) setVisible(false)
} }

View File

@@ -40,9 +40,11 @@ export default function HeaderRightDiscussionDetail({ id, status, isActive }: Pr
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error : any ) {
console.error(error) console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal mengubah data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setVisible(false) setVisible(false)
} }
@@ -59,9 +61,11 @@ export default function HeaderRightDiscussionDetail({ id, status, isActive }: Pr
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error : any ) {
console.error(error) console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal mengubah data"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setVisible(false) setVisible(false)
} }

View File

@@ -18,7 +18,7 @@ export default function DiscussionItem({ title, user, date, onPress }: Props) {
<View style={[Styles.rowItemsCenter, Styles.mb10]}> <View style={[Styles.rowItemsCenter, Styles.mb10]}>
<Ionicons name="chatbox-ellipses-outline" size={22} color={colors.text} style={Styles.mr10} /> <Ionicons name="chatbox-ellipses-outline" size={22} color={colors.text} style={Styles.mr10} />
<View style={[{ flex: 1 }]}> <View style={[{ flex: 1 }]}>
<Text style={{ fontWeight: 'bold' }} numberOfLines={1} ellipsizeMode="tail">{title}</Text> <Text style={{ fontWeight: 'bold' }} numberOfLines={1} ellipsizeMode="tail">{title?.charAt(0).toUpperCase() + title?.slice(1)}</Text>
</View> </View>
</View> </View>
<View style={[Styles.rowSpaceBetween]}> <View style={[Styles.rowSpaceBetween]}>

View File

@@ -37,9 +37,11 @@ export default function HeaderRightDivisionInfo({ id, active }: Props) {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error) { } catch (error : any ) {
console.error(error) console.error(error);
Toast.show({ type: 'small', text1: 'Terjadi kesalahan', }) const message = error?.response?.data?.message || "Gagal mengubah status"
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setVisible(false) setVisible(false)
} }

View File

@@ -33,7 +33,7 @@ export default function HeaderRightDivisionList() {
}} }}
/> />
{ {
(entityUser.role == "userRole" || entityUser.role == "developer") && (entityUser.role == "supadmin" || entityUser.role == "developer") &&
<MenuItemRow <MenuItemRow
icon={<AntDesign name="filter" color={colors.text} size={25} />} icon={<AntDesign name="filter" color={colors.text} size={25} />}
title="Filter" title="Filter"

Some files were not shown because too many files have changed in this diff Show More