Compare commits

...

17 Commits

Author SHA1 Message Date
ddfee00410 upd: panduan 2026-02-06 17:47:22 +08:00
1aa51adab0 Merge pull request 'amalia/04-feb-26' (#17) from amalia/04-feb-26 into join
Reviewed-on: http://wibugit.wibudev.com/wibu/mobile-darmasaba/pulls/17
2026-02-04 17:32:49 +08:00
ebe8380829 upd: panduan penggunaan by QWEN AI 2026-02-04 13:58:15 +08:00
c421d267b9 upd: warna select pada filter 2026-02-04 11:41:18 +08:00
bbacd40ae9 upd: view file
Deskripsi:
- view file pada pengumuman, diskusi divisi dan diskusi umum

No Issues
2026-02-04 11:37:57 +08:00
9bab420f91 Merge pull request 'amalia/03-feb-26' (#16) from amalia/03-feb-26 into join
Reviewed-on: http://wibugit.wibudev.com/wibu/mobile-darmasaba/pulls/16
2026-02-03 17:37:07 +08:00
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
10b74ccde9 Merge pull request 'revisi: tahun' (#15) from amalia/03-feb-26 into join
Reviewed-on: http://wibugit.wibudev.com/wibu/mobile-darmasaba/pulls/15
2026-02-03 12:26:12 +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
22 changed files with 1232 additions and 228 deletions

View File

@@ -0,0 +1,249 @@
# Panduan Aplikasi Desa+
## Daftar Isi
1. [Gambaran Umum Aplikasi](#gambaran-umum-aplikasi)
2. [User Roles dan Hak Akses](#user-roles-dan-hak-akses)
3. [Fitur-fitur Aplikasi](#fitur-fitur-aplikasi)
4. [Troubleshooting](#troubleshooting)
## Gambaran Umum Aplikasi
Aplikasi Desa+ adalah platform digital berbasis mobile yang dirancang untuk khusus untuk pegawai desa dalam mengelola data dan memantau progres kegiatan internal. Aplikasi ini menyediakan berbagai fitur seperti pengelolaan data per divisi, pemantauan kegiatan umum, forum diskusi, pengumuman, hingga manajemen folder dokumen, aplikasi ini membantu meningkatkan efisiensi kerja, koordinasi, serta transparansi di lingkungan desa.
### Teknologi yang Digunakan
- React Native dengan Expo
- Firebase (Authentication, Realtime Database, Cloud Messaging)
- Redux Toolkit untuk manajemen state
- TypeScript untuk type safety
## User Roles dan Hak Akses
Aplikasi Desa+ memiliki sistem hierarki peran pengguna sebagai berikut:
### 1. Super Admin
- **Hak akses:**
- Semua fitur dan fungsi dalam aplikasi
- Manajemen pengguna dengan role Wakil Super Admin, Admin, Wakil Admin, dan User
- Akses ke semua data dan fungsi administratif
### 2. Wakil Super Admin
- **Hak akses:**
- Manajemen pengguna dengan role Admin, Wakil Admin, dan User
- Akses ke sebagian besar fitur administratif
- Dapat mengelola banner
### 3. Admin
- **Hak akses:**
- Manajemen pengguna dengan role Wakil Admin dan User
- Akses ke fitur-fitur administratif dasar
- Tidak dapat mengelola Wakil Super Admin dan Super Admin
### 4. Wakil Admin
- **Hak akses:**
- Manajemen pengguna dengan role User
- Akses terbatas ke fitur-fitur administratif
- Tidak dapat mengelola Admin ke atas
### 5. User
- **Hak akses:**
- Akses ke fitur-fitur umum
- Tidak dapat mengelola pengguna lain
- Tidak dapat mengakses fungsi administratif (kecuali dalam divisi dimana pengguna tersebut adalah anggota)
## Fitur-fitur Aplikasi
### 1. Otentikasi (Login & Verifikasi)
**Deskripsi:** Sistem login menggunakan nomor telepon dan verifikasi OTP (One Time Password)
- **Fungsi:** Memverifikasi identitas pengguna sebelum mengakses aplikasi
- **Siapa yang bisa mengakses:** Semua pengguna yang terdaftar
### 2. Dashboard/Home Screen
**Deskripsi:** Tampilan utama aplikasi yang menampilkan informasi dan akses cepat ke berbagai fitur
- **Fungsi:** Menyediakan ringkasan informasi desa dan akses cepat ke fitur-fitur utama
- **Siapa yang bisa mengakses:** Semua pengguna yang telah login
- **Komponen:**
- Carousel banner untuk promosi atau informasi penting
- Fitur untuk mengakses semua fitur aplikasi
- Grafik progres kegiatan
- Grafik jumlah dokumen
- Daftar kegiatan terupdate
- Daftar divisi aktif
- Daftar acara mendatang
- Diskusi terbaru
### 3. Pencarian
**Deskripsi:** Fitur untuk mencari anggota, kegiatan dan divisi
- **Fungsi:** Mencari anggota, kegiatan dan divisi
- **Siapa yang bisa mengakses:** Semua pengguna
### 4. Notifikasi
**Deskripsi:** Sistem notifikasi untuk memberitahu pengguna tentang aktivitas penting
- **Fungsi:** Memberitahu pengguna tentang pengumuman, komentar, atau aktivitas lainnya
- **Siapa yang bisa mengakses:** Semua pengguna
### 5. Profil
**Deskripsi:** Fitur untuk melihat dan mengedit informasi pribadi pengguna
- **Fungsi:** Menampilkan dan mengelola informasi akun pengguna
- **Siapa yang bisa mengakses:** Pengguna yang bersangkutan
### 6. Banner
**Deskripsi:** Fitur untuk mengelola banner promosi atau informasi penting di halaman utama
- **Fungsi:** Menampilkan informasi atau promosi penting di tampilan awal
- **Siapa yang bisa mengakses:** Super Admin, Wakil Super Admin
### 7. Lembaga Desa
**Deskripsi:** Fitur untuk mengelola berbagai lembaga dalam desa
- **Fungsi:** Mengorganisir struktur organisasi desa berdasarkan lembaga
- **Siapa yang bisa mengakses:**
- Pembuatan/Edit/Hapus: Super Admin
- Melihat: Super Admin
### 8. Jabatan
**Deskripsi:** Fitur untuk mengelola posisi atau jabatan
- **Fungsi:** Mengelola data jabatan
- **Siapa yang bisa mengakses:**
- Pembuatan/Edit/Hapus: Super Admin, Wakil Super Admin, Admin, Wakil Admin
- Melihat: Semua pengguna
### 9. Anggota
**Deskripsi:** Fitur untuk mengelola data pengguna
- **Fungsi:** Menyimpan dan mengelola informasi tentang pengguna
- **Siapa yang bisa mengakses:**
- Pembuatan/Edit/Hapus: Super Admin, Wakil Super Admin, Admin, Wakil Admin
- Melihat: Semua pengguna
### 10. Diskusi Umum
**Deskripsi:** Forum diskusi untuk komunikasi anggota terpilih
- **Fungsi:** Tempat berdiskusi mengenai berbagai topik yang berkaitan dengan desa
- **Siapa yang bisa mengakses:**
- Pembuatan/Edit/Hapus: Super Admin, Wakil Super Admin, Admin
- Melihat: Semua pengguna
- Berkomentar: Pengguna terpilih
### 11. Kegiatan/Proyek
**Deskripsi:** Fitur untuk mengelola dan melacak proyek atau kegiatan desa
- **Fungsi:** Mengelola dan memonitor kemajuan proyek-proyek desa
- **Siapa yang bisa mengakses:**
- Pembuatan/Edit/Hapus/Membatalkan/Mengelola anggota: Super Admin, Wakil Super Admin, Admin
- Mengelola detail (file, task, link, laporan) : Super Admin, Wakil Super Admin, Admin, Anggota dari kegiatan
- Melihat: Semua pengguna
- **Status Kegiatan:**
- Segera: Proyek yang akan segera dimulai
- Dikerjakan: Proyek yang sedang dalam proses pengerjaan
- Selesai: Proyek yang telah selesai
- Batal: Proyek yang dibatalkan
### 12. Pengumuman
**Deskripsi:** Fitur untuk membuat, melihat, dan mengelola pengumuman desa
- **Fungsi:** Menyebarkan informasi penting kepada anggota divisi terpilih
- **Siapa yang bisa mengakses:**
- Pembuatan/Edit/Hapus: Super Admin, Wakil Super Admin, Admin
- Melihat:
- Super admin: Semua pengumuman
- Wakil super admin & admin : Pengumuman sesuai lembaga desa
- Lainnya: Pengumuman yang ditujukan ke divisi mereka
### 13. Divisi
**Deskripsi:** Fitur untuk mengelola data desa berdasarkan divisi
- **Fungsi:** Mengorganisir tugas-tugas berdasarkan divisi-divisi tertentu
- **Catatan:** Anggota divisi (role : Wakil Admin dan User) yg diangkat menjadi "Admin Divisi", mendapat akses khusus untuk mengelola divisi tersebut
- **Siapa yang bisa mengakses:**
- Pembuatan/Edit/Hapus: Super Admin, Wakil Super Admin, Admin
- Edit Divisi / Non aktifkan Divisi tertentu / Mengelola Anggota divisi tertentu : Super Admin, Wakil Super Admin, Admin, Admin Divisi
- Laporan semua divisi : Super Admin, Wakil Super Admin
- Laporan divisi tertentu : semua pengguna
- Melihat: Semua pengguna
### 14. Diskusi Divisi
**Deskripsi:** Forum diskusi khusus untuk masing-masing divisi
- **Fungsi:** Tempat berdiskusi secara internal dalam divisi
- **Siapa yang bisa mengakses:**
- Pembuatan/Edit/Hapus: Super Admin, Wakil Super Admin, Admin, Admin Divisi
- Memberi komentar : Super Admin, Wakil Super Admin, Admin, Anggota divisi
- Melihat: Semua pengguna
### 15. Tugas Divisi
**Deskripsi:** Fitur untuk mengelola tugas-tugas dalam masing-masing divisi
- **Fungsi:** Menetapkan dan melacak tugas-tugas yang harus diselesaikan oleh anggota divisi
- **Siapa yang bisa mengakses:**
- Pembuatan/Edit/Hapus: Super Admin, Wakil Super Admin, Admin, Admin Divisi
- Mengelola detail (file, task, link, laporan) : Super Admin, Wakil Super Admin, Admin, Anggota divisi
- Melihat: Semua pengguna
### 16. Dokumen Divisi
**Deskripsi:** Sistem manajemen dokumen untuk menyimpan dan mengelola file-file disetiap divisi
- **Fungsi:** Menyimpan dokumen penting dalam struktur folder disetiap divisi
- **Siapa yang bisa mengakses:**
- Pembuatan/Edit/Hapus: Super Admin, Wakil Super Admin, Admin, Anggota divisi
- Melihat: Semua pengguna
### 17. Kalender/Acara Divisi
**Deskripsi:** Fitur untuk menjadwalkan dan mengelola acara-acara desa disetiap divisi
- **Fungsi:** Menjadwalkan kegiatan dan acara penting desa disetiap divisi
- **Siapa yang bisa mengakses:**
- Pembuatan/Edit/Hapus: Super Admin, Wakil Super Admin, Admin, Anggota divisi
- Melihat: Semua pengguna
- Riwayat: Semua pengguna
## Troubleshooting
### Masalah Login
- Pastikan nomor telepon yang dimasukkan sudah benar dan terdaftar
- Pastikan koneksi internet stabil saat menerima OTP
- Jika tidak menerima OTP, coba kirim ulang setelah beberapa menit
### Tidak Bisa Mengakses Fitur Tertentu
- Pastikan peran Anda memiliki hak akses ke fitur tersebut
- Beberapa fitur hanya tersedia untuk peran tertentu (misalnya Admin ke atas)
### Lupa Password
- Aplikasi ini menggunakan sistem login OTP, jadi tidak ada password yang disimpan
- Cukup gunakan nomor telepon dan minta OTP kembali
## Dukungan dan Bantuan
Jika Anda mengalami masalah atau memiliki pertanyaan tentang penggunaan aplikasi, silakan hubungi tim pengembang aplikasi.

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 { 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,25 @@ 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 +103,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 +152,26 @@ 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);
Toast.show({
type: 'error',
text1: 'Tidak ada aplikasi yang dapat membuka file ini'
});
}
});
} catch (error) {
console.error('Error downloading or opening file:', error);
Toast.show({
type: 'error',
text1: 'Gagal membuka file',
text2: 'Silakan coba lagi nanti'
});
} finally {
setLoadingOpen(false);
}
};
return (
@@ -139,7 +201,7 @@ export default function DetailAnnouncement() {
/>
}
>
<View style={[Styles.p15]}>
<View style={[Styles.p15, Styles.mb50]}>
<View style={[Styles.wrapPaper]}>
{
loading ?
@@ -184,12 +246,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 +300,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

@@ -80,8 +80,6 @@ export default function DetailDiscussionGeneral() {
})
const [viewEdit, setViewEdit] = useState(false)
useEffect(() => {
const onValueChange = reference.on('value', snapshot => {
if (snapshot.val() == null) {

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,14 +1,17 @@
import { ColorsStatus } from "@/constants/ColorsStatus";
import { ConstEnv } from "@/constants/ConstEnv";
import { isImageFile } from "@/constants/FileExtensions";
import Styles from "@/constants/Styles";
import { Ionicons } from "@expo/vector-icons";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import * as FileSystem from 'expo-file-system';
import { startActivityAsync } from 'expo-intent-launcher';
import * as Sharing from 'expo-sharing';
import React, { useState } from "react";
import { Alert, Dimensions, Platform, Pressable, View } from "react-native";
import { Dimensions, Platform, Pressable, View } from "react-native";
import { ScrollView } from "react-native-gesture-handler";
import ImageViewing from "react-native-image-viewing";
import * as mime from 'react-native-mime-types';
import Toast from "react-native-toast-message";
import Text from "./Text";
@@ -33,26 +36,47 @@ type Props = {
dataFile: { id: string; idStorage: string; name: string; extension: string }[]
}
type PropsFile = {
id: string;
idStorage: string;
name: string;
extension: string
}
export default function BorderBottomItem2({ title, subtitle, icon, desc, onPress, onLongPress, rightTopInfo, borderType, leftBottomInfo, rightBottomInfo, titleWeight, bgColor, width, descEllipsize, textColor, colorPress, titleShowAll, dataFile }: Props) {
const lebarDim = Dimensions.get("window").width;
const lebar = width ? lebarDim * width / 100 : 'auto';
const textColorFix = textColor ? textColor : 'black';
const [isTap, setIsTap] = useState(false);
const [loadingOpen, setLoadingOpen] = useState(false)
const [chooseFile, setChooseFile] = useState<PropsFile>()
const [preview, setPreview] = useState(false)
function handleChooseFile(item: PropsFile) {
setChooseFile(item)
setPreview(true)
}
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: PropsFile) => {
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',
{
@@ -61,89 +85,148 @@ export default function BorderBottomItem2({ title, subtitle, icon, desc, onPress
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);
Toast.show({
type: 'error',
text1: 'Tidak ada aplikasi yang dapat membuka file ini'
});
}
});
} catch (error) {
console.error('Error downloading or opening file:', error);
Toast.show({
type: 'error',
text1: 'Gagal membuka file',
text2: 'Silakan coba lagi nanti'
});
} finally {
setLoadingOpen(false);
}
};
return (
<Pressable onLongPress={onLongPress} onPress={onPress}
onPressIn={() => setIsTap(true)}
onPressOut={() => setIsTap(false)}
style={({ pressed }) => [
borderType == 'bottom'
? Styles.wrapItemBorderBottom
: borderType == 'all'
? Styles.wrapItemBorderAll
: Styles.wrapItemBorderNone,
bgColor && bgColor == 'white' && ColorsStatus.white,
// efek warna saat ditekan (sementara)
isTap && colorPress && ColorsStatus.pressedGray,
]}
>
<View style={[Styles.rowItemsCenter]}>
{icon}
<View style={[Styles.rowSpaceBetween, width ? { width: lebar } : { width: '88%' }]}>
<View style={[Styles.ml10, rightTopInfo ? { width: '70%' } : { width: '90%' }]}>
<Text style={[titleWeight == 'normal' ? Styles.textDefault : Styles.textDefaultSemiBold, { color: textColorFix }]} numberOfLines={titleShowAll ? 0 : 1} ellipsizeMode='tail'>{title}</Text>
<>
<Pressable onLongPress={onLongPress} onPress={onPress}
onPressIn={() => setIsTap(true)}
onPressOut={() => setIsTap(false)}
style={({ pressed }) => [
borderType == 'bottom'
? Styles.wrapItemBorderBottom
: borderType == 'all'
? Styles.wrapItemBorderAll
: Styles.wrapItemBorderNone,
bgColor && bgColor == 'white' && ColorsStatus.white,
// efek warna saat ditekan (sementara)
isTap && colorPress && ColorsStatus.pressedGray,
]}
>
<View style={[Styles.rowItemsCenter]}>
{icon}
<View style={[Styles.rowSpaceBetween, width ? { width: lebar } : { width: '88%' }]}>
<View style={[Styles.ml10, rightTopInfo ? { width: '70%' } : { width: '90%' }]}>
<Text style={[titleWeight == 'normal' ? Styles.textDefault : Styles.textDefaultSemiBold, { color: textColorFix }]} numberOfLines={titleShowAll ? 0 : 1} ellipsizeMode='tail'>{title}</Text>
{
subtitle &&
typeof subtitle == "string"
? <Text style={[Styles.textMediumNormal, { lineHeight: 15, color: textColorFix }]}>{subtitle}</Text>
: <View style={{ alignItems: 'flex-start' }}>
{subtitle}
</View>
}
</View>
{
subtitle &&
typeof subtitle == "string"
? <Text style={[Styles.textMediumNormal, { lineHeight: 15, color: textColorFix }]}>{subtitle}</Text>
: <View style={{ alignItems: 'flex-start' }}>
{subtitle}
</View>
rightTopInfo && <Text style={[Styles.textInformation, Styles.mt05, { color: textColorFix }]}>{rightTopInfo}</Text>
}
</View>
{
rightTopInfo && <Text style={[Styles.textInformation, Styles.mt05, { color: textColorFix }]}>{rightTopInfo}</Text>
}
</View>
</View>
{desc && <Text style={[Styles.textDefault, Styles.mt05, { textAlign: 'left', color: textColorFix }]} numberOfLines={descEllipsize == false ? 0 : 2} ellipsizeMode='tail'>{desc}</Text>}
{
dataFile.length > 0 && (
<ScrollView horizontal style={[Styles.mv05]} showsHorizontalScrollIndicator={false}>
{dataFile.map((item, index) => (
<Pressable
key={index}
style={[Styles.rowItemsCenter, Styles.borderAll, Styles.round10, Styles.ph05, Styles.pv03, Styles.mr05]}
onPress={() => { openFile({ idStorage: item.idStorage, name: item.name, extension: item.extension }) }}
>
<Ionicons name="document-text" size={18} color="grey" style={Styles.mr05} />
<Text style={[Styles.textInformation, Styles.cGray, Styles.mb05]}>{item.name}.{item.extension}</Text>
</Pressable>
))}
</ScrollView>
)
}
{
(leftBottomInfo || rightBottomInfo) &&
(
<View style={[rightBottomInfo && !leftBottomInfo ? Styles.rowSpaceBetweenReverse : Styles.rowSpaceBetween, Styles.mt05]}>
{
typeof leftBottomInfo == 'string' ?
<Text style={[Styles.textInformation, Styles.cGray]}>{leftBottomInfo}</Text>
:
leftBottomInfo
}
{
typeof rightBottomInfo == 'string' ?
<Text style={[Styles.textInformation, Styles.cGray]}>{rightBottomInfo}</Text>
:
rightBottomInfo
}
</View>
{desc && <Text style={[Styles.textDefault, Styles.mt05, { textAlign: 'left', color: textColorFix }]} numberOfLines={descEllipsize == false ? 0 : 2} ellipsizeMode='tail'>{desc}</Text>}
{
dataFile.length > 0 && (
<ScrollView horizontal style={[Styles.mv05]} showsHorizontalScrollIndicator={false}>
{dataFile.map((item, index) => (
<Pressable
key={index}
style={[Styles.rowItemsCenter, Styles.borderAll, Styles.round10, Styles.ph05, Styles.pv03, Styles.mr05]}
onPress={() => {
isImageFile(item.extension) ?
handleChooseFile(item)
: openFile(item)
}}
>
<MaterialCommunityIcons
name={isImageFile(item.extension) ? "file-image-outline" : "file-document-outline"}
size={18}
color="grey" />
<Text style={[Styles.textInformation, Styles.cGray]}>{item.name}.{item.extension}</Text>
</Pressable>
))}
</ScrollView>
)
}
{
(leftBottomInfo || rightBottomInfo) &&
(
<View style={[rightBottomInfo && !leftBottomInfo ? Styles.rowSpaceBetweenReverse : Styles.rowSpaceBetween, Styles.mt05]}>
{
typeof leftBottomInfo == 'string' ?
<Text style={[Styles.textInformation, Styles.cGray]}>{leftBottomInfo}</Text>
:
leftBottomInfo
}
{
typeof rightBottomInfo == 'string' ?
<Text style={[Styles.textInformation, Styles.cGray]}>{rightBottomInfo}</Text>
:
rightBottomInfo
}
</View>
)
}
</Pressable>
<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>
)
}
</Pressable>
)}
FooterComponent={({ imageIndex }) => (
<View style={{
paddingBottom: 20,
paddingHorizontal: 16,
alignItems: 'center',
}}>
<Text style={{ color: 'white', fontSize: 16 }}>{chooseFile?.name}.{chooseFile?.extension}</Text>
</View>
)}
/>
</>
)
}

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: "#f2f6ffff",
borderColor: "#384288",
},
chipText: {
fontSize: 16,
color: "#222",
},
chipTextSelected: {
color: "#384288",
},
checkIcon: {
position: "absolute",
top: -6,
left: -6,
backgroundColor: "#384288",
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;
};