Compare commits

..

10 Commits

Author SHA1 Message Date
7d8b72fdfa upd: modal view pengumuman
Deskripsi:
- modal view image pada detail pengumuman

NO Issues
2026-02-03 17:34:14 +08:00
e9c11a889d fix: tampilan pengumuman
Deskripsi:
- jarak bawah pada detail pengumuman

No Issues
2026-02-03 16:46:06 +08:00
73c6a19880 revisi: tahun
Deskripsi:
- pengaplikasian api filter tahun pada fitur tugas divisi
No Issues
2026-02-03 12:21:34 +08:00
225ed63027 Merge pull request 'rev: filter tahun' (#14) from amalia/02-feb-26 into join
Reviewed-on: http://wibugit.wibudev.com/wibu/mobile-darmasaba/pulls/14
2026-02-02 17:43:21 +08:00
f34df12b2f rev: filter tahun
Deskripsi:
- tampilan modal filter
- tampilan filter disemua fitur yg ada filter nya
- pengaplikasian api

No Issues
2026-02-02 17:29:24 +08:00
a24a698f86 Merge pull request 'amalia/29-jan-26' (#13) from amalia/29-jan-26 into join
Reviewed-on: http://wibugit.wibudev.com/wibu/mobile-darmasaba/pulls/13
2026-01-29 17:23:13 +08:00
6b55433c02 upd: header 2026-01-29 17:21:50 +08:00
13cf7ef9c5 upd: header
Deskripsi:
- update padding pada header

No Issues
2026-01-29 14:15:17 +08:00
febb56f6e9 fix: document division
Deskripsi:
- update menu bottom pada saat select file atau dokumen

No Issues
2026-01-29 11:57:58 +08:00
8da7c598a7 Merge pull request 'fix : button header' (#12) from amalia/28-jan-26 into join
Reviewed-on: http://wibugit.wibudev.com/wibu/mobile-darmasaba/pulls/12
2026-01-28 17:43:54 +08:00
19 changed files with 812 additions and 141 deletions

153
QWEN.md Normal file
View File

@@ -0,0 +1,153 @@
# Desa+ Mobile Application - Project Overview
## Project Summary
Desa+ is a comprehensive village/desa management mobile application built with React Native and Expo. The application serves as a digital platform for village administration, community communication, and information management. It provides various features to facilitate village governance, resident communication, and administrative tasks.
## Architecture & Technology Stack
- **Framework**: React Native with Expo (SDK 53)
- **State Management**: Redux Toolkit with React-Redux
- **Navigation**: Expo Router with React Navigation
- **Backend Services**: Firebase (Authentication, Realtime Database, Cloud Messaging)
- **UI Components**: Custom-built components with React Native elements
- **Language**: TypeScript for type safety
- **Build System**: EAS (Expo Application Service) for builds and deployments
## Key Features
- Announcement and village information system
- Community discussion forums
- Village event calendar
- Document management and archiving
- Project and task management
- Member and organizational structure management
- Push notifications for important updates
- Verification and authentication features
## Project Structure
```
├── app/ # Application routes and pages (Expo Router)
│ ├── (application)/ # Main application screens
│ │ ├── announcement/
│ │ ├── banner/
│ │ ├── discussion/
│ │ ├── division/
│ │ ├── group/
│ │ ├── member/
│ │ ├── position/
│ │ ├── project/
│ │ ├── _layout.tsx
│ │ ├── edit-profile.tsx
│ │ ├── feature.tsx
│ │ ├── home.tsx
│ │ ├── notification.tsx
│ │ ├── profile.tsx
│ │ └── search.tsx
│ ├── _layout.tsx # Root layout
│ ├── index.tsx # Splash/login screen
│ ├── verification.tsx # OTP verification screen
├── components/ # Reusable UI components
│ ├── announcement/
│ ├── auth/
│ ├── banner/
│ ├── calendar/
│ ├── discussion/
│ ├── division/
│ ├── document/
│ ├── group/
│ ├── home/
│ ├── member/
│ ├── position/
│ ├── project/
│ ├── task/
│ ├── alertKonfirmasi.ts
│ ├── AppHeader.tsx
│ ├── Text.tsx
│ └── ... (many more components)
├── constants/ # Constants and styles
│ ├── Colors.ts
│ ├── ColorsStatus.ts
│ ├── ConstEnv.ts
│ ├── Headers.ts
│ ├── RoleUser.ts
│ ├── Styles.ts
│ └── ... (other constants)
├── assets/ # Static assets (images, fonts)
├── lib/ # Business logic and API utilities
├── providers/ # Context providers (AuthProvider)
├── android/ # Android native code
├── ios/ # iOS native code
├── scripts/ # Build and utility scripts
├── index.js # Entry point
├── app.config.js # Expo configuration
├── package.json # Dependencies and scripts
└── eas.json # EAS build configuration
```
## Environment Configuration
The application uses environment variables defined in a `.env` file:
- `URL_API` - API endpoint
- `URL_OTP` - OTP service endpoint
- `URL_STORAGE` - Storage endpoint
- `URL_FIREBASE_DB` - Firebase database URL
- `PASS_ENC` - Encryption password
- `WA_SERVER_TOKEN` - WhatsApp server token
- `IOS_GOOGLE_SERVICES_FILE` - Path to iOS Google services file
## Building and Running
### Development
1. Install dependencies:
```bash
npm install
```
2. Run the development server:
```bash
npx expo start
```
3. For platform-specific builds:
- Android: `npm run android`
- iOS: `npm run ios`
- Web: `npm run web`
### Production Builds
- Android: `npm run build:android` (uses EAS to build an app bundle)
### Testing
- Run tests: `npm run test`
### Linting
- Check code quality: `npm run lint`
## Key Dependencies
- `@react-native-firebase/*` - Firebase integration
- `@react-navigation/*` - Navigation solutions
- `@reduxjs/toolkit` - State management
- `expo-router` - File-based routing
- `react-native-gesture-handler` - Touch gesture support
- `react-native-reanimated` - Advanced animations
- `react-native-svg` - SVG rendering support
## Development Conventions
- Uses TypeScript for type safety
- Implements custom styling through the Styles.ts constant file
- Follows Expo's file-based routing convention
- Uses Redux Toolkit for centralized state management
- Implements custom components in the components directory
- Uses absolute imports with @/ alias (e.g., "@/components/...")
- Implements Firebase for authentication and real-time data
## Deployment
- Uses EAS for building and submitting to app stores
- Supports both Android (APK and App Bundle) and iOS (TestFlight/App Store)
- Configured for internal testing, preview, and production distributions
## Special Features
- Background message handling for push notifications
- Biometric authentication support
- Image picking and media library access
- Document picker functionality
- Date/time pickers with localization
- Custom toast notifications
- Carousel components for featured content
- Data visualization with charts

124
README.md
View File

@@ -1,50 +1,114 @@
# Welcome to your Expo app 👋
# Desa+
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
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.
## Get started
## Fitur Utama
1. Install dependencies
- 📢 Pengumuman dan informasi desa
- 💬 Forum diskusi komunitas
- 📅 Kalender kegiatan desa
- 📄 Dokumentasi dan arsip desa
- 📊 Pengelolaan proyek dan tugas desa
- 👥 Manajemen anggota dan struktur organisasi
- 📱 Notifikasi push untuk informasi penting
- 🎯 Fitur verifikasi dan otentikasi
## Teknologi yang Digunakan
- [React Native](https://reactnative.dev/) - Framework mobile cross-platform
- [Expo](https://expo.dev/) - Platform pengembangan aplikasi React Native
- [Firebase](https://firebase.google.com/) - Backend services (Authentication, Realtime Database, Cloud Messaging)
- [Redux Toolkit](https://redux-toolkit.js.org/) - State management
- [React Navigation](https://reactnavigation.org/) - Navigasi aplikasi
- [TypeScript](https://www.typescriptlang.org/) - Type safety
## Instalasi
1. Clone repository ini
```bash
git clone <repository-url>
cd mobile-darmasaba
```
2. Install dependencies
```bash
npm install
```
2. Start the app
```bash
npx expo start
3. Konfigurasi environment variables
Buat file `.env` di root direktori dan tambahkan variabel berikut:
```
URL_API=<api-endpoint>
URL_OTP=<otp-service-endpoint>
URL_STORAGE=<storage-endpoint>
URL_FIREBASE_DB=<firebase-database-url>
PASS_ENC=<encryption-password>
WA_SERVER_TOKEN=<whatsapp-server-token>
IOS_GOOGLE_SERVICES_FILE=<path-to-ios-google-services>
```
In the output, you'll find options to open the app in a
4. Jalankan aplikasi
```bash
npx expo start
```
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
## Struktur Proyek
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
## Get a fresh project
When you're ready, run:
```bash
npm run reset-project
```
├── app/ # File-file halaman utama
├── components/ # Komponen reusable
│ ├── announcement/ # Komponen pengumuman
│ ├── auth/ # Komponen otentikasi
│ ├── discussion/ # Komponen forum diskusi
│ ├── document/ # Komponen dokumentasi
│ ├── project/ # Komponen pengelolaan proyek
│ └── ...
├── assets/ # Gambar dan aset statis
├── constants/ # Konstanta global
├── lib/ # Library dan utilitas
└── ...
```
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
## Platform Support
## Learn more
Aplikasi ini didukung untuk:
- ✅ Android
- ✅ iOS
- ❌ Web (belum dioptimalkan)
To learn more about developing your project with Expo, look at the following resources:
## Development
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
Untuk menjalankan aplikasi di masing-masing platform:
## Join the community
### Android
```bash
npm run android
```
Join our community of developers creating universal apps.
### iOS
```bash
npm run ios
```
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
### Build Production
Untuk membuat build production Android:
```bash
npm run build:android
```
## Kontribusi
1. Fork repository ini
2. Buat branch fitur baru (`git checkout -b fitur/NamaFitur`)
3. Commit perubahan Anda (`git commit -m 'Tambahkan fitur NamaFitur'`)
4. Push ke branch (`git push origin fitur/NamaFitur`)
5. Buat pull request
## Lisensi
Proyek ini dilisensikan di bawah lisensi MIT - lihat file [LICENSE](LICENSE) untuk detail selengkapnya.
## Dukungan
Jika Anda menemukan masalah atau memiliki pertanyaan, silakan buka issue di repository ini.

View File

@@ -8,7 +8,6 @@ import HeaderRightPositionList from "@/components/position/headerRightPositionLi
import HeaderRightProjectList from "@/components/project/headerProjectList";
import Text from "@/components/Text";
import ToastCustom from "@/components/toastCustom";
import { Headers } from "@/constants/Headers";
import { apiReadOneNotification } from "@/lib/api";
import { pushToPage } from "@/lib/pushToPage";
import store from "@/lib/store";
@@ -91,7 +90,15 @@ export default function RootLayout() {
return (
<Provider store={store}>
<Stack screenOptions={Headers.shadow} >
<Stack screenOptions={{
headerShown: true,
animation: "slide_from_right",
// ⬇️ PENTING BANGET
animationTypeForReplace: "pop",
fullScreenGestureEnabled: true,
gestureEnabled: true,
}} >
<Stack.Screen name="home" options={{ title: 'Home' }} />
<Stack.Screen name="feature" options={{ title: 'Fitur' }} />
<Stack.Screen name="search" options={{ title: 'Pencarian' }} />

View File

@@ -1,10 +1,10 @@
import HeaderRightAnnouncementDetail from "@/components/announcement/headerAnnouncementDetail";
import AppHeader from "@/components/AppHeader";
import BorderBottomItem from "@/components/borderBottomItem";
import ButtonBackHeader from "@/components/buttonBackHeader";
import Skeleton from "@/components/skeleton";
import Text from '@/components/Text';
import { ConstEnv } from "@/constants/ConstEnv";
import { isImageFile } from "@/constants/FileExtensions";
import Styles from "@/constants/Styles";
import { apiGetAnnouncementOne } from "@/lib/api";
import { useAuthSession } from "@/providers/AuthProvider";
@@ -14,24 +14,46 @@ import { startActivityAsync } from 'expo-intent-launcher';
import { router, Stack, useLocalSearchParams } from "expo-router";
import * as Sharing from 'expo-sharing';
import React, { useEffect, useState } from "react";
import { Alert, Dimensions, Platform, RefreshControl, SafeAreaView, ScrollView, View } from "react-native";
import { Alert, Dimensions, Platform, Pressable, RefreshControl, SafeAreaView, ScrollView, View } from "react-native";
import ImageViewing from 'react-native-image-viewing';
import * as mime from 'react-native-mime-types';
import RenderHTML from 'react-native-render-html';
import Toast from "react-native-toast-message";
import { useSelector } from "react-redux";
type Props = {
id: string
title: string
desc: string
// Define TypeScript interfaces for better type safety
interface AnnouncementData {
id: string;
title: string;
desc: string;
}
interface FileData {
id: string;
idStorage: string;
name: string;
extension: string;
}
interface MemberData {
group: string;
division: string;
}
interface ApiResponse {
success: boolean;
data: AnnouncementData;
member: Record<string, MemberData[]>;
file: FileData[];
message: string;
}
export default function DetailAnnouncement() {
const { id } = useLocalSearchParams<{ id: string }>();
const { token, decryptToken } = useAuthSession()
const [data, setData] = useState<Props>({ id: '', title: '', desc: '' })
const [dataMember, setDataMember] = useState<any>({})
const [dataFile, setDataFile] = useState<{ id: string; idStorage: string; name: string; extension: string }[]>([])
const [data, setData] = useState<AnnouncementData>({ id: '', title: '', desc: '' })
const [dataMember, setDataMember] = useState<Record<string, MemberData[]>>({})
const [dataFile, setDataFile] = useState<FileData[]>([])
const update = useSelector((state: any) => state.announcementUpdate)
const entityUser = useSelector((state: any) => state.user)
const contentWidth = Dimensions.get('window').width
@@ -39,13 +61,24 @@ export default function DetailAnnouncement() {
const arrSkeleton = Array.from({ length: 2 }, (_, index) => index)
const [refreshing, setRefreshing] = useState(false)
const [loadingOpen, setLoadingOpen] = useState(false)
const [preview, setPreview] = useState(false)
const [chooseFile, setChooseFile] = useState<FileData>()
/**
* Opens the image preview modal for the selected image file
* @param item The file data object containing image information
*/
function handleChooseFile(item: FileData) {
setChooseFile(item)
setPreview(true)
}
async function handleLoad(loading: boolean) {
try {
setLoading(loading)
const hasil = await decryptToken(String(token?.current))
const response = await apiGetAnnouncementOne({ id: id, user: hasil })
const response: ApiResponse = await apiGetAnnouncementOne({ id: id, user: hasil })
if (response.success) {
setData(response.data)
setDataMember(response.member)
@@ -69,30 +102,47 @@ export default function DetailAnnouncement() {
handleLoad(true)
}, [])
/**
* Checks if a string contains HTML tags
* @param text The text to check for HTML tags
* @returns True if the text contains HTML tags, false otherwise
*/
function hasHtmlTags(text: string) {
const htmlRegex = /<[a-z][\s\S]*>/i;
return htmlRegex.test(text);
};
}
/**
* Handles pull-to-refresh functionality
* Reloads the announcement data without showing loading indicators
*/
const handleRefresh = async () => {
setRefreshing(true)
handleLoad(false)
// Simulate network request delay for better UX
await new Promise(resolve => setTimeout(resolve, 2000));
setRefreshing(false)
};
const openFile = (item: { idStorage: string; name: string; extension: string }) => {
if (Platform.OS == 'android') setLoadingOpen(true)
let remoteUrl = ConstEnv.url_storage + '/files/' + item.idStorage;
const fileName = item.name + '.' + item.extension;
let localPath = `${FileSystem.documentDirectory}/${fileName}`;
const mimeType = mime.lookup(fileName)
const openFile = async (item: FileData) => {
try {
setLoadingOpen(true);
const remoteUrl = ConstEnv.url_storage + '/files/' + item.idStorage;
const fileName = item.name + '.' + item.extension;
const localPath = `${FileSystem.documentDirectory}/${fileName}`;
const mimeType = mime.lookup(fileName);
// Download the file
const downloadResult = await FileSystem.downloadAsync(remoteUrl, localPath);
if (downloadResult.status !== 200) {
throw new Error(`Download failed with status ${downloadResult.status}`);
}
const contentURL = await FileSystem.getContentUriAsync(downloadResult.uri);
FileSystem.downloadAsync(remoteUrl, localPath).then(async ({ uri }) => {
const contentURL = await FileSystem.getContentUriAsync(uri);
setLoadingOpen(false)
try {
if (Platform.OS == 'android') {
if (Platform.OS === 'android') {
await startActivityAsync(
'android.intent.action.VIEW',
{
@@ -101,15 +151,24 @@ export default function DetailAnnouncement() {
type: mimeType as string,
}
);
} else if (Platform.OS == 'ios') {
Sharing.shareAsync(localPath);
} else if (Platform.OS === 'ios') {
await Sharing.shareAsync(localPath);
}
} catch (error) {
Alert.alert('INFO', 'Gagal membuka file, tidak ada aplikasi yang dapat membuka file ini');
} finally {
if (Platform.OS == 'android') setLoadingOpen(false)
} catch (openError) {
console.error('Error opening file:', openError);
Alert.alert('INFO', 'Tidak ada aplikasi yang dapat membuka file ini');
}
});
} catch (error) {
console.error('Error downloading or opening file:', error);
Alert.alert('INFO', 'Gagal mengunduh atau membuka file');
Toast.show({
type: 'error',
text1: 'Gagal membuka file',
text2: 'Silakan coba lagi nanti'
});
} finally {
setLoadingOpen(false);
}
};
return (
@@ -139,7 +198,7 @@ export default function DetailAnnouncement() {
/>
}
>
<View style={[Styles.p15]}>
<View style={[Styles.p15, Styles.mb50]}>
<View style={[Styles.wrapPaper]}>
{
loading ?
@@ -184,12 +243,20 @@ export default function DetailAnnouncement() {
</View>
{dataFile.map((item, index) => (
<BorderBottomItem
key={index}
key={`${item.id}-${index}`}
borderType="bottom"
icon={<MaterialCommunityIcons name="file-outline" size={25} color="black" />}
title={item.name}
icon={<MaterialCommunityIcons
name={isImageFile(item.extension) ? "file-image-outline" : "file-document-outline"}
size={25}
color="black"
/>}
title={item.name + '.' + item.extension}
titleWeight="normal"
onPress={() => { openFile({ idStorage: item.idStorage, name: item.name, extension: item.extension }) }}
onPress={() => {
isImageFile(item.extension) ?
handleChooseFile(item)
: openFile(item)
}}
/>
))}
</View>
@@ -230,6 +297,45 @@ export default function DetailAnnouncement() {
</View>
</View>
</ScrollView>
<ImageViewing
images={[{ uri: `${ConstEnv.url_storage}/files/${chooseFile?.idStorage}` }]}
imageIndex={0}
visible={preview}
onRequestClose={() => setPreview(false)}
doubleTapToZoomEnabled
HeaderComponent={({ imageIndex }) => (
<View style={[Styles.headerModalViewImg]}>
{/* CLOSE */}
<Pressable
onPress={() => setPreview(false)}
accessibilityRole="button"
accessibilityLabel="Close image viewer"
>
<Text style={{ color: 'white', fontSize: 26 }}></Text>
</Pressable>
{/* MENU */}
<Pressable
onPress={() => chooseFile && openFile(chooseFile)}
accessibilityRole="button"
accessibilityLabel="Download or share image"
disabled={loadingOpen}
>
<Text style={{ color: loadingOpen ? 'gray' : 'white', fontSize: 22 }}></Text>
</Pressable>
</View>
)}
FooterComponent={({ imageIndex }) => (
<View style={{
paddingBottom: 20,
paddingHorizontal: 16,
alignItems: 'center',
}}>
<Text style={{ color: 'white', fontSize: 16 }}>{chooseFile?.name}.{chooseFile?.extension}</Text>
</View>
)}
/>
</SafeAreaView>
)
}

View File

@@ -121,8 +121,9 @@ export default function Discussion() {
<InputSearch onChange={setSearch} />
{
(entityUser.role == "supadmin" || entityUser.role == "developer") &&
<View style={[Styles.mv05]}>
<Text>Filter : {nameGroup}</Text>
<View style={[Styles.mv05, Styles.rowOnly]}>
<Text>Filter :</Text>
<LabelStatus size="small" category="secondary" text={nameGroup} style={[Styles.mh05]} />
</View>
}
</View>

View File

@@ -334,7 +334,7 @@ export default function DocumentDivision() {
}, [path]);
return (
<SafeAreaView>
<SafeAreaView style={{ flex: 1 }}>
<Stack.Screen
options={{
// headerLeft: () =>
@@ -409,7 +409,6 @@ export default function DocumentDivision() {
/>
<ModalLoading isVisible={loadingOpen} setVisible={setLoadingOpen} />
<ScrollView
style={[Styles.h100]}
refreshControl={
<RefreshControl
refreshing={refreshing}

View File

@@ -31,7 +31,7 @@ type Props = {
};
export default function ListTask() {
const { id, status } = useLocalSearchParams<{ id: string; status: string }>()
const { id, status, year } = useLocalSearchParams<{ id: string; status: string; year: string }>()
const [isList, setList] = useState(false)
const { token, decryptToken } = useAuthSession()
const [data, setData] = useState<Props[]>([])
@@ -43,6 +43,8 @@ export default function ListTask() {
const [page, setPage] = useState(1)
const [waiting, setWaiting] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [isYear, setYear] = useState("")
async function handleLoad(loading: boolean, thisPage: number) {
try {
@@ -55,8 +57,12 @@ export default function ListTask() {
division: id,
status: statusFix,
search,
page: thisPage
page: thisPage,
year
});
setYear(response.tahun)
if (thisPage == 1) {
setData(response.data);
} else if (thisPage > 1 && response.data.length > 0) {
@@ -179,7 +185,13 @@ export default function ListTask() {
</Pressable>
</View>
</View>
<View style={[{ flex: 2 }, Styles.mt05]}>
<View style={[Styles.mv05]}>
<View style={[Styles.rowOnly]}>
<Text style={[Styles.mr05]}>Filter :</Text>
<LabelStatus size="small" category="secondary" text={isYear} style={{ marginRight: 5 }} />
</View>
</View>
<View style={[{ flex: 2 }]}>
{
loading ?
isList ?

View File

@@ -1,6 +1,7 @@
import BorderBottomItem from "@/components/borderBottomItem";
import ButtonTab from "@/components/buttonTab";
import InputSearch from "@/components/inputSearch";
import LabelStatus from "@/components/labelStatus";
import PaperGridContent from "@/components/paperGridContent";
import Skeleton from "@/components/skeleton";
import SkeletonTwoItem from "@/components/skeletonTwoItem";
@@ -195,8 +196,9 @@ export default function ListDivision() {
</Pressable>
</View>
{(entityUser.role == "supadmin" || entityUser.role == "developer") && (
<View style={[Styles.mv05]}>
<Text>Filter : {nameGroup}</Text>
<View style={[Styles.mv05, Styles.rowOnly]}>
<Text>Filter :</Text>
<LabelStatus size="small" category="secondary" text={nameGroup} style={[Styles.mh05]} />
</View>
)}
</View>

View File

@@ -2,6 +2,7 @@ import BorderBottomItem from "@/components/borderBottomItem";
import ButtonTab from "@/components/buttonTab";
import ImageUser from "@/components/imageNew";
import InputSearch from "@/components/inputSearch";
import LabelStatus from "@/components/labelStatus";
import SkeletonTwoItem from "@/components/skeletonTwoItem";
import Text from "@/components/Text";
import { ConstEnv } from "@/constants/ConstEnv";
@@ -124,8 +125,9 @@ export default function Index() {
<InputSearch onChange={setSearch} />
{
(entityUser.role == "supadmin" || entityUser.role == "developer") &&
<View style={[Styles.mv05]}>
<Text>Filter : {nameGroup}</Text>
<View style={[Styles.mv05, Styles.rowOnly]}>
<Text>Filter :</Text>
<LabelStatus size="small" category="secondary" text={nameGroup} style={[Styles.mh05]} />
</View>
}
</View>

View File

@@ -5,6 +5,7 @@ import ButtonTab from "@/components/buttonTab";
import DrawerBottom from "@/components/drawerBottom";
import { InputForm } from "@/components/inputForm";
import InputSearch from "@/components/inputSearch";
import LabelStatus from "@/components/labelStatus";
import MenuItemRow from "@/components/menuItemRow";
import SkeletonTwoItem from "@/components/skeletonTwoItem";
import Text from "@/components/Text";
@@ -166,8 +167,9 @@ export default function Index() {
<InputSearch onChange={setSearch} />
{
(entityUser.role == "supadmin" || entityUser.role == "developer") &&
<View style={[Styles.mv05]}>
<Text>Filter : {nameGroup}</Text>
<View style={[Styles.mv05, Styles.rowOnly]}>
<Text>Filter :</Text>
<LabelStatus size="small" category="secondary" text={nameGroup} style={[Styles.mh05]} />
</View>
}
</View>

View File

@@ -32,16 +32,18 @@ type Props = {
};
export default function ListProject() {
const { status, group, cat } = useLocalSearchParams<{
const { status, group, cat, year } = useLocalSearchParams<{
status?: string;
group?: string;
cat?: string;
year?: string;
}>();
const [statusFix, setStatusFix] = useState<'0' | '1' | '2' | '3'>('0')
const { token, decryptToken } = useAuthSession();
const entityUser = useSelector((state: any) => state.user)
const [search, setSearch] = useState("")
const [nameGroup, setNameGroup] = useState("")
const [isYear, setYear] = useState("")
const [data, setData] = useState<Props[]>([])
const [isList, setList] = useState(false)
const update = useSelector((state: any) => state.projectUpdate)
@@ -63,11 +65,13 @@ export default function ListProject() {
search: search,
group: String(group),
kategori: String(cat),
page: thisPage
page: thisPage,
year: String(year)
});
if (response.success) {
setNameGroup(response.filter.name);
setYear(response.tahun)
if (thisPage == 1) {
setData(response.data);
} else if (thisPage > 1 && response.data.length > 0) {
@@ -91,7 +95,7 @@ export default function ListProject() {
useEffect(() => {
handleLoad(true, 1);
}, [statusFix, search, group, cat]);
}, [statusFix, search, group, cat, year]);
const loadMoreData = () => {
if (waiting) return
@@ -194,17 +198,25 @@ export default function ListProject() {
</View>
<View style={[Styles.mv05]}>
{
entityUser.role != 'cosupadmin' && entityUser.role != 'admin' &&
<Text>Filter :
// entityUser.role != 'cosupadmin' && entityUser.role != 'admin' &&
<View style={[Styles.rowOnly]}>
<Text style={[Styles.mr05]}>Filter :</Text>
{
(entityUser.role == "supadmin" || entityUser.role == "developer") && nameGroup
(entityUser.role == "supadmin" || entityUser.role == "developer") &&
<LabelStatus size="small" category="secondary" text={nameGroup} style={{ marginRight: 5 }} />
}
{
(entityUser.role == 'user' || entityUser.role == 'coadmin')
? (cat == 'null' || cat == 'undefined' || cat == undefined || cat == '' || cat == 'data-saya') ? 'Kegiatan Saya' : 'Semua Kegiatan'
? (cat == 'null' || cat == 'undefined' || cat == undefined || cat == '' || cat == 'data-saya') ? <LabelStatus size="small" category="secondary" text="Kegiatan Saya" style={{ marginRight: 5 }} /> : <LabelStatus size="small" category="secondary" text="Semua Kegiatan" style={{ marginRight: 5 }} />
: ''
}
</Text>
<LabelStatus size="small" category="secondary" text={isYear} style={{ marginRight: 5 }} />
{/* {
(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 File

@@ -1,6 +1,6 @@
import Styles from '@/constants/Styles';
import { useRouter } from 'expo-router';
import { Text, View } from 'react-native';
import { Platform, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import ButtonBackHeader from './buttonBackHeader';
@@ -17,7 +17,7 @@ export default function AppHeader({ title, right, showBack = true, onPressLeft,
const router = useRouter();
return (
<View style={[Styles.headerContainer, { paddingTop: insets.top }]}>
<View style={[Styles.headerContainer, Platform.OS === 'ios' ? Styles.pb05 : Styles.pb13, { paddingTop: Platform.OS === 'ios' ? insets.top : 10 }]}>
<View style={Styles.headerApp}>
{showBack ? (
<ButtonBackHeader onPress={onPressLeft} />

View File

@@ -1,16 +1,23 @@
import { ColorsStatus } from "@/constants/ColorsStatus";
import Styles from "@/constants/Styles";
import { View } from "react-native";
import { View, StyleProp, ViewStyle } from "react-native";
import Text from "./Text";
type Props = {
category: 'error' | 'success' | 'warning' | 'primary' | 'secondary'
text: string
size: 'small' | 'default'
style?: StyleProp<ViewStyle>
}
export default function LabelStatus({ category, text, size }: Props) {
export default function LabelStatus({ category, text, size, style }: Props) {
return (
<View style={[size == "small" ? Styles.labelStatusSmall : Styles.labelStatus, ColorsStatus[category], Styles.round10, Styles.contentItemCenter]}>
<View style={[
size == "small" ? Styles.labelStatusSmall : Styles.labelStatus,
ColorsStatus[category],
Styles.round10,
Styles.contentItemCenter,
style
]}>
<Text style={[size == "small" ? Styles.textSmallSemiBold : Styles.textMediumSemiBold, Styles.cWhite, { textAlign: 'center' }]}>{text}</Text>
</View>
)

View File

@@ -1,25 +1,27 @@
import Styles from "@/constants/Styles"
import { apiGetGroup } from "@/lib/api"
import { apiGetGroup, apiGetTahunProject, apiGetTahunTask } from "@/lib/api"
import { setEntityFilterGroup } from "@/lib/filterSlice"
import { useAuthSession } from "@/providers/AuthProvider"
import { AntDesign } from "@expo/vector-icons"
import { router } from "expo-router"
import { useEffect, useState } from "react"
import { Pressable, ScrollView, View } from "react-native"
import Text from './Text';
import { ScrollView, TouchableOpacity, View } from "react-native"
import { useDispatch, useSelector } from "react-redux"
import { ButtonForm } from "./buttonForm"
import DrawerBottom from "./drawerBottom"
import Text from './Text'
type Props = {
open: boolean,
close: (value: boolean) => void
page: 'position' | 'member' | 'discussion' | 'project' | 'division'
category?: 'filter-group' | 'filter-data'
page: 'position' | 'member' | 'discussion' | 'project' | 'division' | 'division/task'
category?: 'filter-group' | 'filter-data' | 'year-only',
valueGroup?: string,
valueYear?: string,
dataPassing?: any
}
export default function ModalFilter({ open, close, page, category }: Props) {
export default function ModalFilter({ open, close, page, category, valueGroup, valueYear, dataPassing }: Props) {
const data = [
{
id: "data-saya",
@@ -34,60 +36,163 @@ export default function ModalFilter({ open, close, page, category }: Props) {
const dispatch = useDispatch()
const entities = useSelector((state: any) => state.filterGroup)
const update = useSelector((state: any) => state.groupUpdate)
const [chooseGroup, setChooseGroup] = useState('')
const [chooseGroup, setChooseGroup] = useState(valueGroup || '')
const [chooseYear, setChooseYear] = useState(valueYear || '')
const [dataTahun, setDataTahun] = useState<{ id: string, name: string }[]>([])
const [passingData, setPassingData] = useState(dataPassing)
async function handleLoad() {
const hasil = await decryptToken(String(token?.current))
const response = await apiGetGroup({ active: 'true', user: hasil, search: '' })
dispatch(setEntityFilterGroup(response.data))
if (page === 'project') {
const responseTahun = await apiGetTahunProject({ user: hasil })
setDataTahun(responseTahun.data)
} else if (page === 'division/task') {
const responseTahun = await apiGetTahunTask({ user: hasil, division: passingData })
setDataTahun(responseTahun.data)
}
}
useEffect(() => {
handleLoad()
handleLoad()
}, [dispatch, update]);
return (
<DrawerBottom animation="slide" isVisible={open} setVisible={close} title="Filter" height={75}>
<DrawerBottom animation="slide" isVisible={open} setVisible={close} title="Pilih Preferensi" height={75}>
<View style={{ justifyContent: 'space-between', flex: 1 }}>
<ScrollView>
<View>
{
category == "filter-data"
?
data.map((item: any, index: any) => (
<Pressable key={index} style={[Styles.itemSelectModal]} onPress={() => { setChooseGroup(item.id) }}>
<Text style={[chooseGroup == item.id ? Styles.textDefaultSemiBold : Styles.textDefault]}>{item.name}</Text>
{
chooseGroup == item.id && <AntDesign name="check" size={20} color={'black'}/>
}
</Pressable>
))
:
entities.map((item: any, index: any) => (
<Pressable key={index} style={[Styles.itemSelectModal]} onPress={() => { setChooseGroup(item.id) }}>
<Text style={[chooseGroup == item.id ? Styles.textDefaultSemiBold : Styles.textDefault]}>{item.name}</Text>
{
chooseGroup == item.id && <AntDesign name="check" size={20} color={'black'}/>
}
</Pressable>
))
}
</View>
{
category != "year-only" &&
<View>
<Text style={[Styles.textDefaultSemiBold, Styles.mb05]}>{category == "filter-data" ? "Filter Data" : "Lembaga Desa"}</Text>
<View style={[Styles.rowOnly, { flexWrap: 'wrap' }]}>
{
category == "filter-data"
?
data.map((item: any, index: any) => (
<TouchableOpacity
key={index}
style={[
Styles.chip,
chooseGroup == item.id && Styles.chipSelected,
]}
activeOpacity={0.8}
onPress={() => { setChooseGroup(item.id) }}
>
{/* {chooseGroup == item.id && (
<View style={Styles.checkIcon}>
<Ionicons name="checkmark" size={14} color="white" />
</View>
)} */}
<Text
style={[
Styles.chipText,
chooseGroup == item.id && Styles.chipTextSelected,
]}
>
{item.name}
</Text>
</TouchableOpacity>
// <Pressable key={index} style={[Styles.itemSelectModal]} onPress={() => { setChooseGroup(item.id) }}>
// <Text style={[chooseGroup == item.id ? Styles.textDefaultSemiBold : Styles.textDefault]}>{item.name}</Text>
// {
// chooseGroup == item.id && <AntDesign name="check" size={20} color={'black'} />
// }
// </Pressable>
))
:
entities.map((item: any, index: any) => (
<TouchableOpacity
key={index}
style={[
Styles.chip,
chooseGroup == item.id && Styles.chipSelected,
]}
activeOpacity={0.8}
onPress={() => { setChooseGroup(item.id) }}
>
{/* {chooseGroup == item.id && (
<View style={Styles.checkIcon}>
<Ionicons name="checkmark" size={14} color="white" />
</View>
)} */}
<Text
style={[
Styles.chipText,
chooseGroup == item.id && Styles.chipTextSelected,
]}
>
{item.name}
</Text>
</TouchableOpacity>
// <Pressable key={index} style={[Styles.itemSelectModal]} onPress={() => { setChooseGroup(item.id) }}>
// <Text style={[chooseGroup == item.id ? Styles.textDefaultSemiBold : Styles.textDefault]}>{item.name}</Text>
// {
// chooseGroup == item.id && <AntDesign name="check" size={20} color={'black'}/>
// }
// </Pressable>
))
}
</View>
</View>
}
{(page == "project" || page == "division/task") && (
<View>
<Text style={[Styles.textDefaultSemiBold, Styles.mb05]}>Tahun</Text>
<View style={[Styles.rowOnly, { flexWrap: 'wrap' }]}>
{
dataTahun.map((item: { id: string, name: string }, index: number) => (
<TouchableOpacity
key={index}
style={[
Styles.chip,
chooseYear == item.id && Styles.chipSelected,
]}
activeOpacity={0.8}
onPress={() => { setChooseYear(item.id) }}
>
<Text
style={[
Styles.chipText,
chooseYear == item.id && Styles.chipTextSelected,
]}
>
{item.name}
</Text>
</TouchableOpacity>
))
}
</View>
</View>
)}
</ScrollView>
<View>
<ButtonForm text="Terapkan" onPress={() => {
close(false)
page == 'project' ?
category == "filter-data"
category == "year-only"
?
router.replace(`/${page}?status=0&cat=${chooseGroup}`)
router.replace(`/${page}?status=0&year=${chooseYear}`)
:
router.replace(`/${page}?status=0&group=${chooseGroup}`)
category == "filter-data"
?
router.replace(`/${page}?status=0&cat=${chooseGroup}&year=${chooseYear}`)
:
router.replace(`/${page}?status=0&group=${chooseGroup}&year=${chooseYear}`)
:
router.replace(`/${page}?active=true&group=${chooseGroup}`)
page == "division/task" ?
router.replace(`/division/${dataPassing}/task?status=0&year=${chooseYear}`)
:
router.replace(`/${page}?active=true&group=${chooseGroup}`)
}} />
</View>
</View>

View File

@@ -31,7 +31,7 @@ export default function HeaderRightProjectList() {
/>
}
{
(entityUser.role == "user" || entityUser.role == "coadmin" || entityUser.role == "supadmin" || entityUser.role == "developer") &&
// (entityUser.role == "user" || entityUser.role == "coadmin" || entityUser.role == "supadmin" || entityUser.role == "developer") &&
<MenuItemRow
icon={<AntDesign name="filter" color="black" size={25} />}
title="Filter"
@@ -50,7 +50,7 @@ export default function HeaderRightProjectList() {
close={() => { setFilter(false) }}
open={isFilter}
page="project"
category={entityUser.role == "supadmin" || entityUser.role == "developer" ? "filter-group" : "filter-data"}
category={entityUser.role == "admin" || entityUser.role == "cosupadmin" ? 'year-only' : entityUser.role == "supadmin" || entityUser.role == "developer" ? "filter-group" : "filter-data"}
/>
</>
)

View File

@@ -9,6 +9,7 @@ import { useSelector } from "react-redux"
import ButtonMenuHeader from "../buttonMenuHeader"
import DrawerBottom from "../drawerBottom"
import MenuItemRow from "../menuItemRow"
import ModalFilter from "../modalFilter"
export default function HeaderRightTaskList() {
const [isVisible, setVisible] = useState(false)
@@ -16,6 +17,8 @@ export default function HeaderRightTaskList() {
const { token, decryptToken } = useAuthSession()
const { id } = useLocalSearchParams<{ id: string }>();
const entityUser = useSelector((state: any) => state.user);
const [isFilter, setFilter] = useState(false)
async function handleCheckAdmin() {
try {
@@ -38,22 +41,44 @@ export default function HeaderRightTaskList() {
return (
<>
{
{/* {
(entityUser.role != "user" && entityUser.role != "coadmin") || isAdminDivision
? <ButtonMenuHeader onPress={() => { setVisible(true) }} /> : <></>
}
} */}
<ButtonMenuHeader onPress={() => { setVisible(true) }} />
<DrawerBottom animation="slide" isVisible={isVisible} setVisible={setVisible} title="Menu">
<View style={Styles.rowItemsCenter}>
{
(entityUser.role != "user" && entityUser.role != "coadmin") || isAdminDivision
&&
<MenuItemRow
icon={<AntDesign name="pluscircle" color="black" size={25} />}
title="Tambah Tugas Divisi"
onPress={() => {
setVisible(false)
router.push('./task/create')
}}
/>
}
<MenuItemRow
icon={<AntDesign name="pluscircle" color="black" size={25} />}
title="Tambah Tugas Divisi"
icon={<AntDesign name="filter" color="black" size={25} />}
title="Filter"
onPress={() => {
setVisible(false)
router.push('./task/create')
setTimeout(() => {
setFilter(true)
}, 600)
}}
/>
</View>
</DrawerBottom>
<ModalFilter
close={() => { setFilter(false) }}
open={isFilter}
page="division/task"
category={"year-only"}
dataPassing={id}
/>
</>
)
}

130
constants/FileExtensions.ts Normal file
View File

@@ -0,0 +1,130 @@
/**
* File Extensions Constants
* Categorizes common file extensions for use throughout the application
*/
// Image file extensions
export const IMAGE_EXTENSIONS = [
'jpg',
'jpeg',
'png',
'gif',
'bmp',
'webp',
'svg',
'tiff',
'ico'
];
// Document file extensions
export const DOCUMENT_EXTENSIONS = [
'pdf',
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
'txt',
'rtf',
'odt',
'ods',
'odp',
'csv',
'xml',
'html',
'htm'
];
// Video file extensions
export const VIDEO_EXTENSIONS = [
'mp4',
'avi',
'mov',
'wmv',
'flv',
'webm',
'mkv',
'm4v',
'3gp',
'mpeg',
'mpg'
];
// Audio file extensions
export const AUDIO_EXTENSIONS = [
'mp3',
'wav',
'flac',
'aac',
'ogg',
'wma',
'm4a',
'opus'
];
// Archive file extensions
export const ARCHIVE_EXTENSIONS = [
'zip',
'rar',
'7z',
'tar',
'gz',
'bz2',
'xz',
'iso',
'dmg'
];
// Combined list of all extensions
export const ALL_EXTENSIONS = [
...IMAGE_EXTENSIONS,
...DOCUMENT_EXTENSIONS,
...VIDEO_EXTENSIONS,
...AUDIO_EXTENSIONS,
...ARCHIVE_EXTENSIONS
];
// Helper function to get file type category based on extension
export const getFileTypeCategory = (extension: string): string => {
const ext = extension.toLowerCase();
if (IMAGE_EXTENSIONS.includes(ext)) {
return 'image';
} else if (DOCUMENT_EXTENSIONS.includes(ext)) {
return 'document';
} else if (VIDEO_EXTENSIONS.includes(ext)) {
return 'video';
} else if (AUDIO_EXTENSIONS.includes(ext)) {
return 'audio';
} else if (ARCHIVE_EXTENSIONS.includes(ext)) {
return 'archive';
}
return 'unknown';
};
// Helper function to check if a file is an image
export const isImageFile = (extension: string): boolean => {
return IMAGE_EXTENSIONS.includes(extension.toLowerCase());
};
// Helper function to check if a file is a document
export const isDocumentFile = (extension: string): boolean => {
return DOCUMENT_EXTENSIONS.includes(extension.toLowerCase());
};
// Helper function to check if a file is a video
export const isVideoFile = (extension: string): boolean => {
return VIDEO_EXTENSIONS.includes(extension.toLowerCase());
};
// Helper function to check if a file is audio
export const isAudioFile = (extension: string): boolean => {
return AUDIO_EXTENSIONS.includes(extension.toLowerCase());
};
// Helper function to check if a file is an archive
export const isArchiveFile = (extension: string): boolean => {
return ARCHIVE_EXTENSIONS.includes(extension.toLowerCase());
};

View File

@@ -571,7 +571,6 @@ const Styles = StyleSheet.create({
position: 'absolute',
width: '100%',
bottom: 0,
backgroundColor:'black'
},
animatedView: {
width: '100%',
@@ -642,7 +641,6 @@ const Styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 3
},
headerTitle: {
color: '#fff',
@@ -653,6 +651,42 @@ const Styles = StyleSheet.create({
width: 40,
alignItems: 'center',
},
chip: {
paddingVertical: 5,
paddingHorizontal: 15,
borderRadius: 5,
backgroundColor: "#F3F4F6",
borderWidth: 1,
borderColor: "transparent",
marginRight: 10,
marginBottom: 10,
},
chipSelected: {
backgroundColor: "#FFF5F2",
borderColor: "#FF5A3C",
},
chipText: {
fontSize: 16,
color: "#222",
},
chipTextSelected: {
color: "#FF5A3C",
},
checkIcon: {
position: "absolute",
top: -6,
left: -6,
backgroundColor: "#FF5A3C",
borderRadius: 10,
padding: 2,
},
headerModalViewImg: {
paddingTop: 50,
paddingHorizontal: 16,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
}
})
export default Styles;

View File

@@ -315,8 +315,13 @@ export const apiDeleteAnnouncement = async (data: { user: string }, id: string)
return response.data
};
export const apiGetProject = async ({ user, status, search, group, kategori, page }: { user: string, status: string, search: string, group?: string, kategori?: string, page?: number }) => {
const response = await api.get(`mobile/project?user=${user}&status=${status}&group=${group}&search=${search}&cat=${kategori}&page=${page}`);
export const apiGetTahunProject = async ({ user }: { user: string }) => {
const response = await api.get(`mobile/project/tahun?user=${user}`);
return response.data;
};
export const apiGetProject = async ({ user, status, search, group, kategori, page, year }: { user: string, status: string, search: string, group?: string, kategori?: string, page?: number, year?: string }) => {
const response = await api.get(`mobile/project?user=${user}&status=${status}&group=${group}&search=${search}&cat=${kategori}&page=${page}&year=${year}`);
return response.data;
};
@@ -600,8 +605,13 @@ export const apiUpdateCalendar = async ({ data, id }: { data: { title: string, d
return response.data;
};
export const apiGetTask = async ({ user, status, search, division, page }: { user: string, status: string, search: string, division: string, page?: number }) => {
const response = await api.get(`mobile/task?user=${user}&status=${status}&division=${division}&search=${search}&page=${page}`);
export const apiGetTask = async ({ user, status, search, division, page, year }: { user: string, status: string, search: string, division: string, page?: number, year?: string }) => {
const response = await api.get(`mobile/task?user=${user}&status=${status}&division=${division}&search=${search}&page=${page}&year=${year}`);
return response.data;
};
export const apiGetTahunTask = async ({ user, division }: { user: string, division: string }) => {
const response = await api.get(`mobile/task/tahun?user=${user}&division=${division}`);
return response.data;
};