26 Commits

Author SHA1 Message Date
c21d928701 feat: redesign halaman detail acara kalender divisi dengan pola sectionCard 2026-05-20 17:08:21 +08:00
d8a50cbc75 feat: redesign halaman anggota diskusi dan info divisi dengan pola sectionCard
- redesign discussion/member/[id]: flat list airy, bubble avatar row, header jumlah anggota, button tambah ikuti pola create
- redesign division/[id]/info: sectionCard deskripsi, list anggota dengan role label, drawer menu konsisten
- fix division/[id]/add-member: border warna mengikuti tema
2026-05-20 17:01:08 +08:00
59459e2c22 Merge pull request 'amalia/19-mei-26' (#50) from amalia/19-mei-26 into join
Reviewed-on: #50
2026-05-19 17:18:34 +08:00
a61c194ece feat: redesign halaman tambah dan edit diskusi divisi dengan pola sectionCard dan fileGrid 2026-05-19 15:17:07 +08:00
2be59b5ac6 feat: redesign halaman tambah dan edit diskusi
- Ganti ButtonSelect dan BorderBottomItem dengan pola sectionCard + fileGrid
- Tambah getFileIcon/getFileColor helper dan ikon berwarna per tipe file
- Bagian anggota pada create menggunakan listItemCard dengan avatar ImageUser
- Terapkan deduplication file berdasarkan nama dengan toast notifikasi
- Bersihkan komentar lama dan sederhanakan logic validasi
2026-05-19 15:10:18 +08:00
d272b96e53 fix: ganti warna desc diskusi dan pindahkan inline styles ke styles.ts
- Ganti warna teks deskripsi dari dimmed ke text pada list diskusi umum dan divisi
- Tambah class discussionHeaderPadding, discussionListPadding, discussionTitleCol,
  discussionDescMargin, discussionEmptyText ke component.styles.ts
- Ganti semua inline style dengan themed object (warna dinamis) dan Styles.* (statis)
2026-05-19 14:47:37 +08:00
6d0203cc7d fix: hindari refetch saat mount pada halaman list pengumuman
Gunakan useRef untuk skip efek di render pertama sehingga refetch
hanya dipanggil saat state update berubah (setelah CRUD), bukan
setiap kali halaman dibuka.
2026-05-19 14:41:39 +08:00
165f423798 fix: cegah upload file duplikat pada form tambah dan edit pengumuman
Cek nama file sebelum menambahkan ke list, skip jika sudah ada.
Gunakan nama file (bukan URI) karena Android dapat menghasilkan URI berbeda
untuk file yang sama di setiap sesi picker.
2026-05-19 14:39:23 +08:00
0cb085caa8 feat: redesign halaman pengumuman dan pindahkan styles ke announcement.styles.ts
- Redesign list, detail, create, dan edit pengumuman menggunakan pola sectionCard
- Buat constants/styles/announcement.styles.ts untuk class announcementList* dan announcementDetail*
- Hapus local StyleSheet S dari index.tsx dan [id].tsx, ganti dengan Styles global
- Tambah getFileIcon/getFileColor helper dan fileGrid berwarna per tipe file
- Sesuaikan edit/[id].tsx dengan pola design create.tsx
2026-05-19 14:27:29 +08:00
2bacc47d75 fix: sesuaikan warna border dengan tema pada halaman pilih anggota dan admin divisi 2026-05-19 12:06:12 +08:00
fcd3dc7537 fix: samakan jarak dan style label filter di discussion, division, member, position 2026-05-19 11:55:58 +08:00
0cbf12eea7 Merge pull request 'amalia/18-mei-26' (#49) from amalia/18-mei-26 into join
Reviewed-on: #49
2026-05-18 17:28:18 +08:00
85aca330e5 feat: filter approval berdasarkan group dan perbaikan tampilan riwayat
- Simpan idGroup user ke Redux saat login agar perbandingan group bisa dilakukan
- Filter button persetujuan project: isApprover hanya tampil jika group sama
- Filter button persetujuan division/task: isApprover hanya tampil jika group sama
- Pass idGroup ke SectionTanggalTugasProject dan SectionTanggalTugasTask dari parent
- Samakan warna icon, label, dan nama pada riwayat persetujuan
- Ubah bg alasan penolakan dari merah ke netral, label tetap merah
- Ekstrak inline styles ModalRiwayatApproval ke approval.styles.ts
2026-05-18 14:52:30 +08:00
3f113a4049 fix: urutkan grup tanggal notifikasi dari terbaru ke terlama 2026-05-18 11:43:16 +08:00
f873921325 fix: ubah label tombol "Pilih dari File Proyek" menjadi "Pilih dari File Kegiatan ini" 2026-05-18 11:37:04 +08:00
90419b5d15 feat: tambah fitur tandai terbaca per notifikasi dan ekstrak styles
- Tambah fungsi handleMarkOneRead untuk tandai satu notifikasi terbaca tanpa navigasi
- Tambah tombol "Tandai dibaca" pada tiap notifikasi yang belum terbaca
- Buat notification.styles.ts dengan 8 class styles untuk notification screen
- Daftarkan NotificationStyles ke constants/styles/index.ts
2026-05-18 11:27:49 +08:00
ecb3d3953b fix: rapikan import dan sesuaikan ukuran icon di notification
- Gabungkan import useState yang duplikat
- Kecilkan ukuran icon check-square dari 22 ke 20
2026-05-18 11:18:59 +08:00
9ca128a5ed Merge pull request 'refactor: pecah lib/api.ts dan constants/Styles.ts per domain' (#48) from amalia/12-mei-26 into join
Reviewed-on: #48
2026-05-12 17:25:26 +08:00
d299484a98 refactor: pecah lib/api.ts dan constants/Styles.ts per domain
- lib/api.ts (879 baris) → 13 file di lib/api/ per domain
- constants/Styles.ts (1.275 baris) → 10 file di constants/styles/ per domain
- tambah docs/FILE-HEALTH.md dan referensinya ke CLAUDE.md
- kedua file lama tetap sebagai re-export — zero breaking changes
2026-05-12 10:34:31 +08:00
003d92e4e3 Merge pull request 'amalia/11-mei-26' (#47) from amalia/11-mei-26 into join
Reviewed-on: #47
2026-05-11 17:34:52 +08:00
18f548ed5c chore: update Podfile.lock 2026-05-11 17:18:47 +08:00
906a619593 revert: kembalikan penyimpanan token ke AsyncStorage 2026-05-11 17:15:52 +08:00
af2048b4cd fix: ganti penyimpanan token dari AsyncStorage ke SecureStore
Token autentikasi kini disimpan di Keychain (iOS) / Keystore (Android)
sehingga otomatis terhapus saat uninstall dan tidak bisa dipulihkan
lewat Google Backup. @notification_permission tetap di AsyncStorage
karena bukan data sensitif.
2026-05-11 17:11:05 +08:00
7341f378dd feat: tambah sistem guide onboarding per fitur dengan GuideOverlay
- Buat komponen GuideOverlay dengan animasi fade+slide, arrow tooltip, dan dot indicator
- Buat hook useGuide untuk menyimpan state guide per fitur via AsyncStorage
- Sentralisasi semua step guide di lib/guideSteps.ts
- Pasang guide pada 12 halaman: village-calendar, project detail, banner, group, position, member, announcement, discussion, division calendar/document/discussion, dan division task detail
- Posisi card menggunakan cardTopRatio (rasio layar) untuk kompatibilitas berbagai ukuran device
- Tambah styles guide dan village calendar di constants/Styles.ts
2026-05-11 16:34:46 +08:00
84935e8188 feat: tambah fitur kalender umum village dengan indikator per jenis event
- Tambah halaman /village-calendar dengan monthly grid dan agenda view
- Tampilkan acara divisi (DivisionCalendarReminder) dan kegiatan (ProjectTask) se-village
- Indikator dot dua warna pada kalender: ungu untuk acara divisi, biru-abu untuk kegiatan
- Tambah endpoint apiGetVillageCalendarByDate dan apiGetVillageCalendarIndicator
- Tambah menu Kalender di halaman /feature dengan grid layout flexWrap
- Sesuaikan warna EventItem dengan TYPE_COLORS village-calendar
- Pindahkan inline style ke Styles.ts sebagai class baru
2026-05-11 15:19:21 +08:00
74d8b8ef31 Merge pull request 'amalia/08-mei-26' (#46) from amalia/08-mei-26 into join
Reviewed-on: #46
2026-05-11 10:17:25 +08:00
77 changed files with 4123 additions and 3594 deletions

View File

@@ -31,3 +31,7 @@ See @docs/ARCHITECTURE.md
## Key Conventions
See @docs/CONVENTIONS.md
## File Health
See @docs/FILE-HEALTH.md

View File

@@ -1,6 +1,5 @@
import HeaderRightAnnouncementDetail from "@/components/announcement/headerAnnouncementDetail";
import AppHeader from "@/components/AppHeader";
import BorderBottomItem from "@/components/borderBottomItem";
import Skeleton from "@/components/skeleton";
import Text from '@/components/Text';
import ErrorView from "@/components/ErrorView";
@@ -10,7 +9,7 @@ import Styles from "@/constants/Styles";
import { apiGetAnnouncementOne } from "@/lib/api";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Entypo, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import * as FileSystem from 'expo-file-system';
import { startActivityAsync } from 'expo-intent-launcher';
import { router, Stack, useLocalSearchParams } from "expo-router";
@@ -23,7 +22,6 @@ import RenderHTML from 'react-native-render-html';
import Toast from "react-native-toast-message";
import { useSelector } from "react-redux";
// Define TypeScript interfaces for better type safety
interface AnnouncementData {
id: string;
title: string;
@@ -59,26 +57,31 @@ export default function DetailAnnouncement() {
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
const contentWidth = Dimensions.get('window').width - 62
const [loading, setLoading] = useState(true)
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>()
const [isError, setIsError] = useState(false)
/**
* Opens the image preview modal for the selected image file
* @param item The file data object containing image information
*/
const themed = {
background: { backgroundColor: colors.background },
card: { backgroundColor: colors.card, borderColor: colors.icon + '18' },
iconBox: { backgroundColor: colors.icon + '18' },
sectionLabel: { color: colors.dimmed },
titleText: { color: colors.text },
fileChipBorder: { borderColor: colors.icon + '30' },
fileChipPressed: { backgroundColor: colors.icon + '10' },
groupSeparator: { borderTopColor: colors.icon + '18' },
divisionIconBg: { backgroundColor: colors.icon + '15' },
}
function handleChooseFile(item: FileData) {
setChooseFile(item)
setPreview(true)
}
async function handleLoad(loading: boolean) {
try {
setIsError(false)
@@ -97,39 +100,22 @@ export default function DetailAnnouncement() {
console.error(error);
setIsError(true)
const message = error?.response?.data?.message || "Gagal mengambil data"
Toast.show({ type: 'small', text1: message })
} finally {
setLoading(false)
}
}
useEffect(() => {
handleLoad(false)
}, [update])
useEffect(() => { handleLoad(false) }, [update])
useEffect(() => { handleLoad(true) }, [])
useEffect(() => {
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);
return /<[a-z][\s\S]*>/i.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)
};
@@ -141,178 +127,171 @@ export default function DetailAnnouncement() {
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);
try {
if (Platform.OS === 'android') {
await startActivityAsync(
'android.intent.action.VIEW',
{
data: contentURL,
flags: 1,
type: mimeType as string,
}
);
await startActivityAsync('android.intent.action.VIEW', {
data: contentURL,
flags: 1,
type: mimeType as string,
});
} else if (Platform.OS === 'ios') {
await Sharing.shareAsync(localPath);
}
} catch (openError) {
console.error('Error opening file:', openError);
Toast.show({
type: 'error',
text1: 'Tidak ada aplikasi yang dapat membuka file ini'
});
} catch {
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'
});
} catch {
Toast.show({ type: 'error', text1: 'Gagal membuka file', text2: 'Silakan coba lagi nanti' });
} finally {
setLoadingOpen(false);
}
};
return (
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<SafeAreaView style={[Styles.flex1, themed.background]}>
<Stack.Screen
options={{
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
headerTitle: 'Pengumuman',
headerTitleAlign: 'center',
// headerRight: () => entityUser.role != 'user' && entityUser.role != 'coadmin' ? <HeaderRightAnnouncementDetail id={id} /> : <></>,
header: () => (
<AppHeader title="Pengumuman"
<AppHeader
title="Pengumuman"
showBack={true}
onPressLeft={() => router.back()}
right={entityUser.role != 'user' && entityUser.role != 'coadmin' ? <HeaderRightAnnouncementDetail id={id} /> : <></>}
right={entityUser.role != 'user' && entityUser.role != 'coadmin'
? <HeaderRightAnnouncementDetail id={id} />
: <></>
}
/>
)
}}
/>
<ScrollView
showsVerticalScrollIndicator={false}
style={[Styles.h100, { backgroundColor: colors.background }]}
style={[Styles.flex1, themed.background]}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => handleRefresh()}
tintColor={colors.icon}
/>
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} tintColor={colors.icon} />
}
>
{isError && !loading ? (
<View style={[Styles.mv50]}>
<View style={Styles.mv50}>
<ErrorView />
</View>
) : (
<View style={[Styles.p15, Styles.mb50]}>
<View style={[Styles.wrapPaper, Styles.borderAll, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
loading ?
<View>
<View style={[Styles.rowOnly]}>
<Skeleton width={30} height={30} borderRadius={10} />
<View style={[Styles.flex1, Styles.ph05]}>
<Skeleton width={100} widthType="percent" height={30} borderRadius={10} />
</View>
</View>
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
</View>
:
<>
<View style={[Styles.rowOnly, Styles.alignStart]}>
<MaterialIcons name="campaign" size={25} color={colors.text} style={[Styles.mr05]} />
<Text style={[Styles.textDefaultSemiBold, Styles.w90, Styles.mt02]}>{data?.title}</Text>
</View>
<View style={[Styles.mt10]}>
{
hasHtmlTags(data?.desc) ?
<RenderHTML
contentWidth={contentWidth}
source={{ html: data?.desc }}
baseStyle={{ color: colors.text }}
/>
:
<Text>{data?.desc}</Text>
}
</View>
</>
}
<View style={Styles.announcementDetailContainer}>
</View>
{
dataFile.length > 0 && (
<View style={[Styles.wrapPaper, Styles.borderAll, Styles.mt10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
<View style={[Styles.mb05]}>
<Text style={[Styles.textDefaultSemiBold]}>File</Text>
{/* Title + Description */}
<View style={[Styles.wrapPaper, Styles.noShadow, Styles.sectionCard, Styles.announcementDetailCard, themed.card]}>
{loading ? (
<View style={Styles.announcementDetailSkeletonGap}>
<View style={[Styles.rowItemsCenter, Styles.announcementDetailSkeletonIconRow]}>
<Skeleton width={38} height={38} borderRadius={10} />
<Skeleton width={60} widthType="percent" height={16} borderRadius={6} />
</View>
{dataFile.map((item, index) => (
<BorderBottomItem
key={`${item.id}-${index}`}
borderType={index === dataFile.length - 1 ? 'none' : 'bottom'}
icon={<MaterialCommunityIcons
name={isImageFile(item.extension) ? "file-image-outline" : "file-document-outline"}
size={25}
color={colors.text}
/>}
title={item.name + '.' + item.extension}
titleWeight="normal"
onPress={() => {
isImageFile(item.extension) ?
handleChooseFile(item)
: openFile(item)
}}
/>
))}
<Skeleton width={100} widthType="percent" height={10} borderRadius={6} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={6} />
<Skeleton width={80} widthType="percent" height={10} borderRadius={6} />
</View>
)
}
<View style={[Styles.wrapPaper, Styles.borderAll, Styles.mt10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
loading ?
arrSkeleton.map((item, index) => {
return (
<View key={index}>
<Skeleton width={30} widthType="percent" height={10} borderRadius={10} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
</View>
)
})
:
Object.keys(dataMember).map((v: any, i: any) => {
return (
<View key={i} style={[Styles.mb05]}>
<Text style={[Styles.textDefaultSemiBold]}>{dataMember[v]?.[0].group}</Text>
{
dataMember[v].map((item: any, x: any) => {
return (
<View key={x} style={[Styles.rowItemsCenter, Styles.w90]}>
<Entypo name="dot-single" size={24} color={colors.text} />
<Text style={[Styles.textDefault]} numberOfLines={1} ellipsizeMode='tail'>{item.division}</Text>
</View>
)
})
}
</View>
)
})
}
) : (
<>
<View style={[Styles.rowItemsCenter, Styles.announcementDetailTitleRow]}>
<View style={[Styles.sectionIconBox, Styles.announcementDetailIconBox, themed.iconBox]}>
<MaterialIcons name="campaign" size={22} color={colors.icon} />
</View>
<Text style={[Styles.textDefaultSemiBold, Styles.announcementDetailTitleText, themed.titleText]} numberOfLines={2}>
{data.title}
</Text>
</View>
{hasHtmlTags(data.desc)
? <RenderHTML
contentWidth={contentWidth}
source={{ html: data.desc }}
baseStyle={{ color: colors.text }}
/>
: <Text style={Styles.textDefault}>{data.desc}</Text>
}
</>
)}
</View>
{/* Files */}
{dataFile.length > 0 && (
<View>
<View style={[Styles.rowItemsCenter, Styles.announcementDetailSectionLabelRow]}>
<MaterialCommunityIcons name="paperclip" size={14} color={colors.dimmed} />
<Text style={[Styles.textInformation, themed.sectionLabel]}>
Lampiran ({dataFile.length})
</Text>
</View>
<View style={[Styles.wrapPaper, Styles.noShadow, Styles.sectionCard, Styles.announcementDetailCard, Styles.announcementDetailFileCardPadding, themed.card]}>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={[Styles.rowItemsCenter, Styles.announcementDetailFileChipList]}>
{dataFile.map((item, index) => (
<Pressable
key={`${item.id}-${index}`}
onPress={() => isImageFile(item.extension) ? handleChooseFile(item) : openFile(item)}
style={({ pressed }) => [Styles.announcementDetailFileChip, themed.fileChipBorder,
pressed ? themed.fileChipPressed : themed.background]}
>
<MaterialCommunityIcons
name={isImageFile(item.extension) ? "file-image-outline" : "file-document-outline"}
size={16}
color={colors.icon}
/>
<Text style={[Styles.textInformation, Styles.announcementDetailFileChipText, themed.titleText]} numberOfLines={1}>
{item.name}.{item.extension}
</Text>
</Pressable>
))}
</View>
</ScrollView>
</View>
</View>
)}
{/* Recipients */}
<View>
<View style={[Styles.rowItemsCenter, Styles.announcementDetailSectionLabelRow]}>
<MaterialIcons name="groups" size={14} color={colors.dimmed} />
<Text style={[Styles.textInformation, themed.sectionLabel]}>
Ditujukan Kepada
</Text>
</View>
<View style={[Styles.wrapPaper, Styles.noShadow, Styles.sectionCard, Styles.announcementDetailCard, themed.card]}>
{loading ? (
<View style={Styles.announcementDetailRecipientGap}>
<Skeleton width={40} widthType="percent" height={10} borderRadius={6} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={6} />
<Skeleton width={60} widthType="percent" height={10} borderRadius={6} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={6} />
</View>
) : (
Object.keys(dataMember).map((v, i) => (
<View key={i} style={i > 0 ? [Styles.announcementDetailGroupSeparator, themed.groupSeparator] : undefined}>
<Text style={[Styles.textInformation, Styles.announcementDetailGroupLabel, themed.sectionLabel]}>
{dataMember[v]?.[0].group}
</Text>
<View>
{dataMember[v].map((item, x) => (
<View key={x} style={[Styles.rowItemsCenter, Styles.announcementDetailDivisionRow]}>
<View style={[Styles.announcementDetailDivisionIconCircle, themed.divisionIconBg]}>
<MaterialIcons name="group" size={14} color={colors.icon} />
</View>
<Text style={[Styles.textDefault, Styles.flex1, themed.titleText]}>
{item.division}
</Text>
</View>
))}
</View>
</View>
))
)}
</View>
</View>
</View>
)}
</ScrollView>
@@ -323,38 +302,26 @@ export default function DetailAnnouncement() {
visible={preview}
onRequestClose={() => setPreview(false)}
doubleTapToZoomEnabled
HeaderComponent={({ imageIndex }) => (
<View style={[Styles.headerModalViewImg]}>
{/* CLOSE */}
<Pressable
onPress={() => setPreview(false)}
accessibilityRole="button"
accessibilityLabel="Close image viewer"
>
HeaderComponent={() => (
<View style={Styles.headerModalViewImg}>
<Pressable onPress={() => setPreview(false)} accessibilityRole="button">
<Text style={[Styles.textWhite, Styles.font26]}></Text>
</Pressable>
{/* MENU */}
<Pressable
onPress={() => chooseFile && openFile(chooseFile)}
accessibilityRole="button"
accessibilityLabel="Download or share image"
disabled={loadingOpen}
>
<Text style={[{ color: loadingOpen ? 'gray' : 'white' }, Styles.font26]}></Text>
<Text style={[Styles.font26, { color: loadingOpen ? 'gray' : 'white' }]}></Text>
</Pressable>
</View>
)}
FooterComponent={({ imageIndex }) => (
<View style={[
Styles.pb20,
Styles.ph16,
Styles.alignCenter,
]}>
FooterComponent={() => (
<View style={[Styles.pb20, Styles.ph16, Styles.alignCenter]}>
<Text style={[Styles.textWhite, Styles.font16]}>{chooseFile?.name}.{chooseFile?.extension}</Text>
</View>
)}
/>
</SafeAreaView>
)
}
}

View File

@@ -1,7 +1,5 @@
import AppHeader from "@/components/AppHeader";
import BorderBottomItem from "@/components/borderBottomItem";
import ButtonSaveHeader from "@/components/buttonSaveHeader";
import ButtonSelect from "@/components/buttonSelect";
import DrawerBottom from "@/components/drawerBottom";
import { InputForm } from "@/components/inputForm";
import LoadingCenter from "@/components/loadingCenter";
@@ -13,14 +11,34 @@ import { setUpdateAnnouncement } from "@/lib/announcementUpdate";
import { apiCreateAnnouncement } from "@/lib/api";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Entypo, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { Ionicons, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import * as DocumentPicker from "expo-document-picker";
import { router, Stack } from "expo-router";
import React, { useEffect, useState } from "react";
import { SafeAreaView, ScrollView, View } from "react-native";
import { Pressable, SafeAreaView, ScrollView, View } from "react-native";
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
function getFileIcon(ext: string): keyof typeof MaterialCommunityIcons.glyphMap {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return 'image-outline'
if (ext === 'pdf') return 'file-pdf-box'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline'
if (['doc', 'docx'].includes(ext)) return 'file-word-outline'
if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline'
if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline'
return 'file-outline'
}
function getFileColor(ext: string): string {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return '#339AF0'
if (ext === 'pdf') return '#F03E3E'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return '#AE3EC9'
if (['doc', 'docx'].includes(ext)) return '#1C7ED6'
if (['xls', 'xlsx'].includes(ext)) return '#2F9E44'
if (['zip', 'rar', '7z'].includes(ext)) return '#E8590C'
return '#868E96'
}
export default function CreateAnnouncement() {
const dispatch = useDispatch()
const update = useSelector((state: any) => state.announcementUpdate)
@@ -28,109 +46,77 @@ export default function CreateAnnouncement() {
const { colors } = useTheme();
const [disableBtn, setDisableBtn] = useState(true);
const [modalDivisi, setModalDivisi] = useState(false);
const [divisionMember, setDivisionMember] = useState<any>([])
const [divisionMember, setDivisionMember] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const [fileForm, setFileForm] = useState<any[]>([])
const [isModalFile, setModalFile] = useState(false)
const [indexDelFile, setIndexDelFile] = useState<number>(0)
const [dataForm, setDataForm] = useState({
title: "",
desc: "",
});
const [error, setError] = useState({
title: false,
desc: false,
});
const [dataForm, setDataForm] = useState({ title: "", desc: "" });
const [error, setError] = useState({ title: false, desc: false });
const totalDivisi = divisionMember.reduce((acc: number, g: any) => acc + g.Division.length, 0)
function validationForm(cat: string, val: any) {
if (cat == "title") {
if (cat === "title") {
setDataForm({ ...dataForm, title: val });
if (val == "" || val == "null") {
setError({ ...error, title: true });
} else {
setError({ ...error, title: false });
}
} else if (cat == "desc") {
setError({ ...error, title: val === "" || val === "null" });
} else if (cat === "desc") {
setDataForm({ ...dataForm, desc: val });
if (val == "" || val == "null") {
setError({ ...error, desc: true });
} else {
setError({ ...error, desc: false });
}
setError({ ...error, desc: val === "" || val === "null" });
}
}
function checkForm() {
if (
Object.values(error).some((v) => v == true) ||
Object.values(dataForm).some((v) => v == "")
) {
setDisableBtn(true);
} else {
setDisableBtn(false);
}
const hasError = Object.values(error).some(v => v)
const hasEmpty = Object.values(dataForm).some(v => v === "")
setDisableBtn(hasError || hasEmpty);
}
useEffect(() => {
checkForm();
}, [error, dataForm]);
useEffect(() => { checkForm() }, [error, dataForm]);
async function handleCreate() {
try {
setLoading(true)
const hasil = await decryptToken(String(token?.current))
const fd = new FormData()
for (let i = 0; i < fileForm.length; i++) {
fd.append(`file${i}`, {
uri: fileForm[i].uri,
type: 'application/octet-stream',
name: fileForm[i].name,
} as any);
fd.append(`file${i}`, { uri: fileForm[i].uri, type: 'application/octet-stream', name: fileForm[i].name } as any);
}
fd.append("data", JSON.stringify(
{ user: hasil, groups: divisionMember, ...dataForm }
))
fd.append("data", JSON.stringify({ user: hasil, groups: divisionMember, ...dataForm }))
const response = await apiCreateAnnouncement(fd)
if (response.success) {
dispatch(setUpdateAnnouncement(!update))
Toast.show({ type: 'small', text1: 'Berhasil menambahkan data', })
Toast.show({ type: 'small', text1: 'Berhasil menambahkan data' })
router.back();
} else {
Toast.show({ type: 'small', text1: response.message, })
Toast.show({ type: 'small', text1: response.message })
}
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Tidak dapat terhubung ke server"
Toast.show({
type: 'small',
text1: message
})
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Tidak dapat terhubung ke server" })
} finally {
setLoading(false)
}
}
const pickDocumentAsync = async () => {
let result = await DocumentPicker.getDocumentAsync({
type: ["*/*"],
multiple: true
});
const result = await DocumentPicker.getDocumentAsync({ type: ["*/*"], multiple: true });
if (!result.canceled) {
for (let i = 0; i < result.assets?.length; i++) {
if (result.assets[i].uri) {
setFileForm((prev) => [...prev, result.assets[i]])
let skipped = 0
for (const asset of result.assets) {
if (!asset.uri) continue
if (fileForm.some(f => f.name === asset.name)) {
skipped++
} else {
setFileForm(prev => [...prev, asset])
}
}
if (skipped > 0) Toast.show({ type: 'small', text1: 'Beberapa file sudah ditambahkan' })
}
};
function deleteFile(index: number) {
setFileForm([...fileForm.filter((val, i) => i !== index)])
setFileForm(fileForm.filter((_, i) => i !== index))
setModalFile(false)
}
@@ -138,26 +124,6 @@ export default function CreateAnnouncement() {
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
// headerLeft: () => (
// <ButtonBackHeader
// onPress={() => {
// router.back();
// }}
// />
// ),
headerTitle: "Tambah Pengumuman",
headerTitleAlign: "center",
// headerRight: () => (
// <ButtonSaveHeader
// disable={disableBtn || divisionMember.length == 0 || loading ? true : false}
// category="create"
// onPress={() => {
// divisionMember.length == 0
// ? Toast.show({ type: 'small', text1: "Anda belum memilih divisi", })
// : handleCreate();
// }}
// />
// ),
header: () => (
<AppHeader
title="Tambah Pengumuman"
@@ -165,12 +131,12 @@ export default function CreateAnnouncement() {
onPressLeft={() => router.back()}
right={
<ButtonSaveHeader
disable={disableBtn || divisionMember.length == 0 || loading ? true : false}
disable={disableBtn || divisionMember.length === 0 || loading}
category="create"
onPress={() => {
divisionMember.length == 0
? Toast.show({ type: 'small', text1: "Anda belum memilih divisi", })
: handleCreate();
divisionMember.length === 0
? Toast.show({ type: 'small', text1: "Anda belum memilih divisi" })
: handleCreate()
}}
/>
}
@@ -179,11 +145,9 @@ export default function CreateAnnouncement() {
}}
/>
{loading && <LoadingCenter />}
<ScrollView
showsVerticalScrollIndicator={false}
style={[Styles.h100, { backgroundColor: colors.background }]}
>
<View style={[Styles.p15]}>
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100, { backgroundColor: colors.background }]}>
<View style={Styles.p15}>
<InputForm
label="Judul"
type="default"
@@ -194,6 +158,7 @@ export default function CreateAnnouncement() {
errorText="Judul harus diisi"
onChange={(val) => validationForm("title", val)}
/>
<InputForm
label="Pengumuman"
type="default"
@@ -205,68 +170,105 @@ export default function CreateAnnouncement() {
onChange={(val) => validationForm("desc", val)}
multiline
/>
<ButtonSelect value="Upload File" onPress={pickDocumentAsync} />
{
fileForm.length > 0
&&
<>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>File</Text>
<Text style={[Styles.textDefault]}>{fileForm.length} file</Text>
{/* File */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={pickDocumentAsync}
style={[Styles.sectionActionRow, { marginBottom: fileForm.length > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.icon + '15' }]}>
<MaterialCommunityIcons name="paperclip" size={18} color={colors.dimmed} />
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, Styles.mb10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
fileForm.map((item, index) => (
<BorderBottomItem
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>File</Text>
{fileForm.length === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Opsional ketuk untuk upload</Text>
)}
</View>
{fileForm.length > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.dimmed + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{fileForm.length} file</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{fileForm.length > 0 && (
<View style={Styles.fileGrid}>
{fileForm.map((item, index) => {
const ext = item.name.split('.').pop()?.toLowerCase() ?? ''
const baseName = item.name.includes('.') ? item.name.split('.').slice(0, -1).join('.') : item.name
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={index}
borderType={fileForm.length - 1 == index ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name}
bgColor="transparent"
titleWeight="normal"
onPress={() => { setIndexDelFile(index); setModalFile(true) }}
/>
))
}
</View>
</>
}
<ButtonSelect
value="Pilih divisi penerima pengumuman"
onPress={() => {
setModalDivisi(true)
}}
/>
{
divisionMember.length > 0
&&
<>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>Divisi</Text>
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
divisionMember.map((item: { name: any; Division: any }, index: any) => {
return (
<View key={index}>
<Text style={[Styles.textDefaultSemiBold]}>{item.name}</Text>
{
item.Division.map((division: any, i: any) => (
<View key={i} style={[Styles.rowItemsCenter, Styles.w90]}>
<Entypo name="dot-single" size={24} color={colors.text} />
<Text style={[Styles.textDefault]} numberOfLines={1} ellipsizeMode='tail'>{division.name}</Text>
</View>
))
}
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
)
})
}
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{baseName}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
</View>
</>
}
)}
</View>
{/* Divisi Penerima */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={() => setModalDivisi(true)}
style={[Styles.sectionActionRow, { marginBottom: divisionMember.length > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.tabActive + '18' }]}>
<MaterialIcons name="groups" size={18} color={colors.tabActive} />
</View>
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>Divisi Penerima</Text>
{divisionMember.length === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Belum ada divisi dipilih</Text>
)}
</View>
{divisionMember.length > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.tabActive + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.tabActive }]}>{totalDivisi} divisi</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{divisionMember.length > 0 && (
<View style={{ gap: 10 }}>
{divisionMember.map((item: any, index: number) => (
<View key={index}>
<Text style={[Styles.textMediumNormal, { color: colors.dimmed, marginBottom: 4 }]}>
{item.name}
</Text>
<View style={{ gap: 6 }}>
{item.Division.map((division: any, i: number) => (
<View key={i} style={[Styles.listItemCard, { borderColor: colors.icon + '18' }]}>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.tabActive + '18', width: 28, height: 28, borderRadius: 8 }]}>
<MaterialIcons name="group" size={14} color={colors.tabActive} />
</View>
<Text style={[Styles.textDefault, Styles.flex1, { color: colors.text }]} numberOfLines={1}>
{division.name}
</Text>
</View>
))}
</View>
</View>
))}
</View>
)}
</View>
</View>
</ScrollView>
@@ -287,12 +289,10 @@ export default function CreateAnnouncement() {
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus"
onPress={() => { deleteFile(indexDelFile) }}
onPress={() => deleteFile(indexDelFile)}
/>
</View>
</DrawerBottom>
</SafeAreaView>
);
}

View File

@@ -1,7 +1,5 @@
import AppHeader from "@/components/AppHeader";
import BorderBottomItem from "@/components/borderBottomItem";
import ButtonSaveHeader from "@/components/buttonSaveHeader";
import ButtonSelect from "@/components/buttonSelect";
import DrawerBottom from "@/components/drawerBottom";
import { InputForm } from "@/components/inputForm";
import LoadingCenter from "@/components/loadingCenter";
@@ -13,22 +11,38 @@ import { setUpdateAnnouncement } from "@/lib/announcementUpdate";
import { apiEditAnnouncement, apiGetAnnouncementOne } from "@/lib/api";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Entypo, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { Ionicons, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import * as DocumentPicker from "expo-document-picker";
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { SafeAreaView, ScrollView, View } from "react-native";
import { Pressable, SafeAreaView, ScrollView, View } from "react-native";
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
function getFileIcon(ext: string): keyof typeof MaterialCommunityIcons.glyphMap {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return 'image-outline'
if (ext === 'pdf') return 'file-pdf-box'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline'
if (['doc', 'docx'].includes(ext)) return 'file-word-outline'
if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline'
if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline'
return 'file-outline'
}
function getFileColor(ext: string): string {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return '#339AF0'
if (ext === 'pdf') return '#F03E3E'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return '#AE3EC9'
if (['doc', 'docx'].includes(ext)) return '#1C7ED6'
if (['xls', 'xlsx'].includes(ext)) return '#2F9E44'
if (['zip', 'rar', '7z'].includes(ext)) return '#E8590C'
return '#868E96'
}
type GroupDivision = {
id: string;
name: string;
Division: {
id: string;
name: string;
}[];
Division: { id: string; name: string }[];
}
export default function EditAnnouncement() {
@@ -39,43 +53,29 @@ export default function EditAnnouncement() {
const { colors } = useTheme();
const [modalDivisi, setModalDivisi] = useState(false);
const [disableBtn, setDisableBtn] = useState(true);
const [dataMember, setDataMember] = useState<any>([]);
const [dataMember, setDataMember] = useState<GroupDivision[]>([]);
const [fileForm, setFileForm] = useState<any[]>([])
const [dataFile, setDataFile] = useState<{ id: string; idStorage: string; name: string; extension: string; delete?: boolean }[]>([])
const [indexDelFile, setIndexDelFile] = useState<{ id: string | number; cat: "newFile" | "oldFile" }>({ id: "", cat: "newFile" })
const [isModalFile, setModalFile] = useState(false)
const [loading, setLoading] = useState(false)
const [dataForm, setDataForm] = useState({
title: "",
desc: "",
});
const [error, setError] = useState({
title: false,
desc: false,
});
const [dataForm, setDataForm] = useState({ title: "", desc: "" });
const [error, setError] = useState({ title: false, desc: false });
const visibleOldFiles = dataFile.filter(v => !v.delete)
const totalFiles = fileForm.length + visibleOldFiles.length
const totalDivisi = dataMember.reduce((acc: number, g: any) => acc + g.Division.length, 0)
async function handleLoad() {
try {
const hasil = await decryptToken(String(token?.current));
const response = await apiGetAnnouncementOne({ id: id, user: hasil });
setDataForm(response.data);
const arrNew: GroupDivision[] = []
const coba = Object.keys(response.member).map((v: any, i: any) => {
const newObject = {
"id": response.member[v][0].idGroup,
"name": v,
"Division": response.member[v]
}
response.member[v].map((v: any, i: any) => {
newObject["Division"][i] = {
"id": v.idDivision,
"name": v.division
}
})
arrNew.push(newObject)
})
const arrNew: GroupDivision[] = Object.keys(response.member).map((v) => ({
id: response.member[v][0].idGroup,
name: v,
Division: response.member[v].map((m: any) => ({ id: m.idDivision, name: m.division }))
}))
setDataMember(arrNew);
setDataFile(response.file);
} catch (error) {
@@ -83,42 +83,25 @@ export default function EditAnnouncement() {
}
}
useEffect(() => {
handleLoad();
}, []);
useEffect(() => { handleLoad() }, []);
function validationForm(cat: string, val: any) {
if (cat == "title") {
if (cat === "title") {
setDataForm({ ...dataForm, title: val });
if (val == "" || val == "null") {
setError({ ...error, title: true });
} else {
setError({ ...error, title: false });
}
} else if (cat == "desc") {
setError({ ...error, title: val === "" || val === "null" });
} else if (cat === "desc") {
setDataForm({ ...dataForm, desc: val });
if (val == "" || val == "null") {
setError({ ...error, desc: true });
} else {
setError({ ...error, desc: false });
}
setError({ ...error, desc: val === "" || val === "null" });
}
}
function checkForm() {
if (
Object.values(error).some((v) => v == true) ||
Object.values(dataForm).some((v) => v == "")
) {
setDisableBtn(true);
} else {
setDisableBtn(false);
}
const hasError = Object.values(error).some(v => v)
const hasEmpty = Object.values(dataForm).some(v => v === "")
setDisableBtn(hasError || hasEmpty);
}
useEffect(() => {
checkForm();
}, [error, dataForm]);
useEffect(() => { checkForm() }, [error, dataForm]);
async function handleEdit() {
try {
@@ -126,90 +109,56 @@ export default function EditAnnouncement() {
const hasil = await decryptToken(String(token?.current))
const fd = new FormData()
for (let i = 0; i < fileForm.length; i++) {
fd.append(`file${i}`, {
uri: fileForm[i].uri,
type: 'application/octet-stream',
name: fileForm[i].name,
} as any);
fd.append(`file${i}`, { uri: fileForm[i].uri, type: 'application/octet-stream', name: fileForm[i].name } as any);
}
fd.append("data", JSON.stringify(
{
...dataForm, user: hasil, groups: dataMember, oldFile: dataFile
}
))
fd.append("data", JSON.stringify({ ...dataForm, user: hasil, groups: dataMember, oldFile: dataFile }))
const response = await apiEditAnnouncement(fd, id);
if (response.success) {
dispatch(setUpdateAnnouncement(!update))
Toast.show({ type: 'small', text1: 'Berhasil mengubah data', })
Toast.show({ type: 'small', text1: 'Berhasil mengubah data' })
router.back();
} else {
Toast.show({ type: 'small', text1: 'Gagal mengubah data', })
Toast.show({ type: 'small', text1: 'Gagal mengubah data' })
}
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal mengubah data"
Toast.show({ type: 'small', text1: message })
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal mengubah data" })
} finally {
setLoading(false)
}
}
const pickDocumentAsync = async () => {
let result = await DocumentPicker.getDocumentAsync({
type: ["*/*"],
multiple: true
});
const result = await DocumentPicker.getDocumentAsync({ type: ["*/*"], multiple: true });
if (!result.canceled) {
for (let i = 0; i < result.assets?.length; i++) {
if (result.assets[i].uri) {
setFileForm((prev) => [...prev, result.assets[i]])
let skipped = 0
for (const asset of result.assets) {
if (!asset.uri) continue
const isDup = fileForm.some(f => f.name === asset.name) ||
visibleOldFiles.some(f => `${f.name}.${f.extension}` === asset.name)
if (isDup) {
skipped++
} else {
setFileForm(prev => [...prev, asset])
}
}
if (skipped > 0) Toast.show({ type: 'small', text1: 'Beberapa file sudah ditambahkan' })
}
};
function deleteFile(index: number | string, cat: "newFile" | "oldFile" | null) {
if (cat == "newFile") {
setFileForm([...fileForm.filter((val, i) => i !== index)])
if (cat === "newFile") {
setFileForm(fileForm.filter((_, i) => i !== index))
} else {
setDataFile(prev =>
prev.map(item =>
item.id === index
? { ...item, delete: true }
: item
)
);
setDataFile(prev => prev.map(item => item.id === index ? { ...item, delete: true } : item))
}
setModalFile(false)
}
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
// headerLeft: () => (
// <ButtonBackHeader
// onPress={() => {
// router.back();
// }}
// />
// ),
headerTitle: "Edit Pengumuman",
headerTitleAlign: "center",
// headerRight: () => (
// <ButtonSaveHeader
// disable={disableBtn || loading ? true : false}
// category="update"
// onPress={() => {
// dataMember.length == 0
// ? Toast.show({ type: 'small', text1: "Anda belum memilih divisi", })
// : handleEdit();
// }}
// />
// ),
header: () => (
<AppHeader
title="Edit Pengumuman"
@@ -217,12 +166,12 @@ export default function EditAnnouncement() {
onPressLeft={() => router.back()}
right={
<ButtonSaveHeader
disable={disableBtn || loading ? true : false}
disable={disableBtn || dataMember.length === 0 || loading}
category="update"
onPress={() => {
dataMember.length == 0
? Toast.show({ type: 'small', text1: "Anda belum memilih divisi", })
: handleEdit();
dataMember.length === 0
? Toast.show({ type: 'small', text1: "Anda belum memilih divisi" })
: handleEdit()
}}
/>
}
@@ -231,11 +180,9 @@ export default function EditAnnouncement() {
}}
/>
{loading && <LoadingCenter />}
<ScrollView
showsVerticalScrollIndicator={false}
style={[Styles.h100, { backgroundColor: colors.background }]}
>
<View style={[Styles.p15]}>
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100, { backgroundColor: colors.background }]}>
<View style={Styles.p15}>
<InputForm
label="Judul"
type="default"
@@ -247,6 +194,7 @@ export default function EditAnnouncement() {
onChange={(val) => validationForm("title", val)}
value={dataForm.title}
/>
<InputForm
label="Pengumuman"
type="default"
@@ -259,79 +207,125 @@ export default function EditAnnouncement() {
value={dataForm.desc}
multiline
/>
<ButtonSelect value="Upload File" onPress={pickDocumentAsync} />
{
(fileForm.length > 0 || dataFile.filter((val) => !val.delete).length > 0)
&&
<>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>File</Text>
<Text style={[Styles.textDefault]}>{fileForm.length + dataFile.filter((val) => !val.delete).length} file</Text>
{/* File */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={pickDocumentAsync}
style={[Styles.sectionActionRow, { marginBottom: totalFiles > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.icon + '15' }]}>
<MaterialCommunityIcons name="paperclip" size={18} color={colors.dimmed} />
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, Styles.mb10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
dataFile.filter((val) => !val.delete).map((item, index) => (
<BorderBottomItem
key={index}
borderType={dataFile.filter((val) => !val.delete).length - 1 == index && fileForm.length == 0 ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name + '.' + item.extension}
titleWeight="normal"
bgColor="transparent"
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>File</Text>
{totalFiles === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Opsional ketuk untuk upload</Text>
)}
</View>
{totalFiles > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.dimmed + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{totalFiles} file</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{totalFiles > 0 && (
<View style={Styles.fileGrid}>
{visibleOldFiles.map((item, index) => {
const ext = item.extension.toLowerCase()
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={`old-${index}`}
onPress={() => { setIndexDelFile({ id: item.id, cat: "oldFile" }); setModalFile(true) }}
/>
))
}
{
fileForm.map((item, index) => (
<BorderBottomItem
key={index}
borderType={fileForm.length - 1 == index ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name}
titleWeight="normal"
bgColor="transparent"
onPress={() => { setIndexDelFile({ id: index, cat: "newFile" }); setModalFile(true) }}
/>
))
}
</View>
</>
}
<ButtonSelect
value="Pilih divisi penerima pengumuman"
onPress={() => {
setModalDivisi(true)
}}
/>
{
dataMember.length > 0
&&
<>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>Divisi</Text>
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
dataMember.map((item: { name: any; Division: any }, index: any) => {
return (
<View key={index}>
<Text style={[Styles.textDefaultSemiBold]}>{item.name}</Text>
{
item.Division.map((division: any, i: any) => (
<View key={i} style={[Styles.rowItemsCenter, Styles.w90]}>
<Entypo name="dot-single" size={24} color={colors.text} />
<Text style={[Styles.textDefault]} numberOfLines={1} ellipsizeMode='tail'>{division.name}</Text>
</View>
))
}
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
)
})
}
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{item.name}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
{fileForm.map((item, index) => {
const ext = item.name.split('.').pop()?.toLowerCase() ?? ''
const baseName = item.name.includes('.') ? item.name.split('.').slice(0, -1).join('.') : item.name
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={`new-${index}`}
onPress={() => { setIndexDelFile({ id: index, cat: "newFile" }); setModalFile(true) }}
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{baseName}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
</View>
</>
}
)}
</View>
{/* Divisi Penerima */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={() => setModalDivisi(true)}
style={[Styles.sectionActionRow, { marginBottom: dataMember.length > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.tabActive + '18' }]}>
<MaterialIcons name="groups" size={18} color={colors.tabActive} />
</View>
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>Divisi Penerima</Text>
{dataMember.length === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Belum ada divisi dipilih</Text>
)}
</View>
{dataMember.length > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.tabActive + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.tabActive }]}>{totalDivisi} divisi</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{dataMember.length > 0 && (
<View style={{ gap: 10 }}>
{dataMember.map((item, index) => (
<View key={index}>
<Text style={[Styles.textMediumNormal, { color: colors.dimmed, marginBottom: 4 }]}>
{item.name}
</Text>
<View style={{ gap: 6 }}>
{item.Division.map((division, i) => (
<View key={i} style={[Styles.listItemCard, { borderColor: colors.icon + '18' }]}>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.tabActive + '18', width: 28, height: 28, borderRadius: 8 }]}>
<MaterialIcons name="group" size={14} color={colors.tabActive} />
</View>
<Text style={[Styles.textDefault, Styles.flex1, { color: colors.text }]} numberOfLines={1}>
{division.name}
</Text>
</View>
))}
</View>
</View>
))}
</View>
)}
</View>
</View>
</ScrollView>
@@ -353,7 +347,7 @@ export default function EditAnnouncement() {
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus"
onPress={() => { deleteFile(indexDelFile.id, indexDelFile.cat) }}
onPress={() => deleteFile(indexDelFile.id, indexDelFile.cat)}
/>
</View>
</DrawerBottom>

View File

@@ -1,22 +1,24 @@
import BorderBottomItem from "@/components/borderBottomItem";
import GuideOverlay from "@/components/GuideOverlay";
import InputSearch from "@/components/inputSearch";
import SkeletonContent from "@/components/skeletonContent";
import Skeleton from "@/components/skeleton";
import Text from '@/components/Text';
import Styles from "@/constants/Styles";
import { apiGetAnnouncement } from "@/lib/api";
import { GUIDE_ANNOUNCEMENT } from "@/lib/guideSteps";
import { useGuide } from "@/lib/useGuide";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { MaterialIcons } from "@expo/vector-icons";
import { useInfiniteQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import { RefreshControl, View, VirtualizedList } from "react-native";
import { useEffect, useMemo, useRef, useState } from "react";
import { Pressable, RefreshControl, View, VirtualizedList } from "react-native";
import { useSelector } from "react-redux";
type Props = {
id: string,
title: string,
desc: string,
id: string
title: string
desc: string
createdAt: string
}
@@ -25,9 +27,20 @@ export default function Announcement() {
const { colors } = useTheme();
const [search, setSearch] = useState('')
const update = useSelector((state: any) => state.announcementUpdate)
const arrSkeleton = Array.from({ length: 5 }, (_, index) => index)
const isFirstRender = useRef(true)
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('announcement')
const arrSkeleton = Array.from({ length: 5 }, (_, i) => i)
const themed = {
background: { backgroundColor: colors.background },
card: { backgroundColor: colors.card, borderColor: colors.icon + '18' },
iconBox: { backgroundColor: colors.icon + '18' },
title: { color: colors.text },
desc: { color: colors.dimmed },
date: { color: colors.dimmed },
cardPressed: { backgroundColor: colors.icon + '08' },
}
// TanStack Query Infinite Query
const {
data,
fetchNextPage,
@@ -40,11 +53,7 @@ export default function Announcement() {
queryKey: ['announcements', search],
queryFn: async ({ pageParam = 1 }) => {
const hasil = await decryptToken(String(token?.current))
const response = await apiGetAnnouncement({
user: hasil,
search: search,
page: pageParam
})
const response = await apiGetAnnouncement({ user: hasil, search, page: pageParam })
return response.data
},
initialPageParam: 1,
@@ -53,21 +62,12 @@ export default function Announcement() {
},
})
// Trigger refetch when Redux state 'update' changes
useEffect(() => {
if (isFirstRender.current) { isFirstRender.current = false; return }
refetch()
}, [update, refetch])
}, [update])
// Flatten data from pages
const flattenedData = useMemo(() => {
return data?.pages.flat() || []
}, [data])
const loadMoreData = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
};
const flattenedData = useMemo(() => data?.pages.flat() || [], [data])
const getItem = (_data: unknown, index: number): Props => ({
id: flattenedData[index].id,
@@ -76,58 +76,80 @@ export default function Announcement() {
createdAt: flattenedData[index].createdAt,
})
return (
<View style={[Styles.p15, Styles.flex1, { backgroundColor: colors.background }]}>
<View>
<InputSearch onChange={setSearch} />
const renderSkeleton = () => (
<View style={Styles.announcementListSkeletonCard}>
<View style={[Styles.rowSpaceBetween, Styles.rowItemsCenter, Styles.announcementListSkeletonHeader]}>
<View style={[Styles.rowItemsCenter, Styles.announcementListSkeletonTitleRow]}>
<Skeleton width={28} height={28} borderRadius={8} />
<Skeleton width={50} widthType="percent" height={12} borderRadius={6} />
</View>
<Skeleton width={15} widthType="percent" height={10} borderRadius={6} />
</View>
<View style={[Styles.flex2, Styles.mt05]}>
{
isLoading && !flattenedData.length ?
arrSkeleton.map((item, index) => {
return (
<SkeletonContent key={index} />
)
})
:
flattenedData.length > 0
?
<VirtualizedList
data={flattenedData}
getItemCount={() => flattenedData.length}
getItem={getItem}
renderItem={({ item, index }: { item: Props, index: number }) => {
return (
<BorderBottomItem
key={index}
onPress={() => { router.push(`/announcement/${item.id}`) }}
borderType="bottom"
bgColor="transparent"
icon={
<MaterialIcons name="campaign" size={25} color={colors.text} />
}
title={item.title}
desc={item.desc.replace(/<[^>]*>?/gm, '').replace(/\r?\n|\r/g, ' ')}
rightTopInfo={item.createdAt}
/>
)
}}
keyExtractor={(item, index) => String(item.id || index)}
onEndReached={loadMoreData}
onEndReachedThreshold={0.5}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefetching && !isFetchingNextPage}
onRefresh={refetch}
tintColor={colors.icon}
/>
}
<Skeleton width={100} widthType="percent" height={10} borderRadius={6} />
<Skeleton width={80} widthType="percent" height={10} borderRadius={6} />
</View>
)
const renderItem = ({ item }: { item: Props }) => (
<Pressable
onPress={() => router.push(`/announcement/${item.id}`)}
style={({ pressed }) => [Styles.announcementListCard, themed.card, pressed && themed.cardPressed]}
>
<View style={[Styles.rowSpaceBetween, Styles.rowItemsCenter, Styles.announcementListCardHeader]}>
<View style={[Styles.rowItemsCenter, Styles.announcementListTitleRow]}>
<View style={[Styles.announcementListIconBox, themed.iconBox]}>
<MaterialIcons name="campaign" size={16} color={colors.icon} />
</View>
<Text style={[Styles.textDefaultSemiBold, Styles.announcementListTitleText, themed.title]} numberOfLines={1}>
{item.title}
</Text>
</View>
<Text style={[Styles.textInformation, Styles.announcementListDateText, themed.date]}>
{item.createdAt}
</Text>
</View>
<Text style={[Styles.textMediumNormal, Styles.announcementListDescText, themed.title]} numberOfLines={2}>
{item.desc.replace(/<[^>]*>?/gm, '').replace(/\r?\n|\r/g, ' ')}
</Text>
</Pressable>
)
return (
<View style={[Styles.flex1, Styles.announcementListContainer, themed.background]}>
<GuideOverlay visible={guideVisible} steps={GUIDE_ANNOUNCEMENT} onDismiss={dismissGuide} />
<InputSearch onChange={setSearch} />
<View style={[Styles.flex1, Styles.announcementListInner]}>
{isLoading && !flattenedData.length ? (
arrSkeleton.map((_, i) => (
<View key={i} style={[Styles.announcementListCard, themed.card]}>
{renderSkeleton()}
</View>
))
) : flattenedData.length > 0 ? (
<VirtualizedList
data={flattenedData}
getItemCount={() => flattenedData.length}
getItem={getItem}
renderItem={renderItem}
keyExtractor={(item, index) => String(item.id || index)}
onEndReached={() => { if (hasNextPage && !isFetchingNextPage) fetchNextPage() }}
onEndReachedThreshold={0.5}
showsVerticalScrollIndicator={false}
ItemSeparatorComponent={() => <View style={Styles.announcementListSeparator} />}
refreshControl={
<RefreshControl
refreshing={isRefetching && !isFetchingNextPage}
onRefresh={refetch}
tintColor={colors.icon}
/>
:
<Text style={[Styles.textDefault, Styles.textCenter, { color: colors.dimmed }]}>Tidak ada pengumuman</Text>
}
}
/>
) : (
<Text style={[Styles.textDefault, Styles.textCenter, Styles.mt30, themed.desc]}>
Tidak ada pengumuman
</Text>
)}
</View>
</View>
)
}
}

View File

@@ -1,5 +1,5 @@
import styles from "@/components/AppHeader"
import AppHeader from "@/components/AppHeader"
import GuideOverlay from "@/components/GuideOverlay"
import HeaderRightBannerList from "@/components/banner/headerBannerList"
import BorderBottomItem from "@/components/borderBottomItem"
import DrawerBottom from "@/components/drawerBottom"
@@ -12,6 +12,8 @@ import { ConstEnv } from "@/constants/ConstEnv"
import Styles from "@/constants/Styles"
import { apiDeleteBanner, apiGetBanner } from "@/lib/api"
import { setEntities } from "@/lib/bannerSlice"
import { GUIDE_BANNER } from "@/lib/guideSteps"
import { useGuide } from "@/lib/useGuide"
import { useAuthSession } from "@/providers/AuthProvider"
import { useTheme } from "@/providers/ThemeProvider"
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"
@@ -44,6 +46,7 @@ export default function BannerList() {
const dispatch = useDispatch()
const [refreshing, setRefreshing] = useState(false)
const [loadingOpen, setLoadingOpen] = useState(false)
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('banner')
const [viewImg, setViewImg] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const queryClient = useQueryClient()
@@ -147,6 +150,7 @@ export default function BannerList() {
)
}}
/>
<GuideOverlay visible={guideVisible} steps={GUIDE_BANNER} onDismiss={dismissGuide} />
<ModalLoading isVisible={loadingOpen} setVisible={setLoadingOpen} />
<ScrollView
refreshControl={

View File

@@ -1,7 +1,5 @@
import AppHeader from "@/components/AppHeader";
import BorderBottomItem from "@/components/borderBottomItem";
import ButtonSaveHeader from "@/components/buttonSaveHeader";
import ButtonSelect from "@/components/buttonSelect";
import DrawerBottom from "@/components/drawerBottom";
import ImageUser from "@/components/imageNew";
import { InputForm } from "@/components/inputForm";
@@ -17,14 +15,33 @@ import { setUpdateDiscussionGeneralDetail } from "@/lib/discussionGeneralDetail"
import { setMemberChoose } from "@/lib/memberChoose";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { Ionicons, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import * as DocumentPicker from "expo-document-picker";
import { router, Stack } from "expo-router";
import { useEffect, useState } from "react";
import { SafeAreaView, ScrollView, View } from "react-native";
import { Pressable, SafeAreaView, ScrollView, View } from "react-native";
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
function getFileIcon(ext: string): keyof typeof MaterialCommunityIcons.glyphMap {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return 'image-outline'
if (ext === 'pdf') return 'file-pdf-box'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline'
if (['doc', 'docx'].includes(ext)) return 'file-word-outline'
if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline'
if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline'
return 'file-outline'
}
function getFileColor(ext: string): string {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return '#339AF0'
if (ext === 'pdf') return '#F03E3E'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return '#AE3EC9'
if (['doc', 'docx'].includes(ext)) return '#1C7ED6'
if (['xls', 'xlsx'].includes(ext)) return '#2F9E44'
if (['zip', 'rar', '7z'].includes(ext)) return '#E8590C'
return '#868E96'
}
export default function CreateDiscussionGeneral() {
const { token, decryptToken } = useAuthSession()
@@ -43,84 +60,67 @@ export default function CreateDiscussionGeneral() {
const [fileForm, setFileForm] = useState<any[]>([])
const [isModalFile, setModalFile] = useState(false)
const [indexDelFile, setIndexDelFile] = useState<number>(0)
const [dataForm, setDataForm] = useState({
idGroup: "",
title: "",
desc: "",
});
const [error, setError] = useState({
group: false,
title: false,
desc: false,
});
const [dataForm, setDataForm] = useState({ idGroup: "", title: "", desc: "" });
const [error, setError] = useState({ group: false, title: false, desc: false });
function validationForm(cat: string, val: any, label?: string) {
if (cat == "group") {
if (cat === "group") {
setChooseGroup({ val, label: String(label) });
dispatch(setMemberChoose([]))
setDataForm({ ...dataForm, idGroup: val });
if (val == "" || val == "null") {
setError({ ...error, group: true });
} else {
setError({ ...error, group: false });
}
} else if (cat == "title") {
setError({ ...error, group: val === "" || val === "null" });
} else if (cat === "title") {
setDataForm({ ...dataForm, title: val });
if (val == "" || val == "null") {
setError({ ...error, title: true });
} else {
setError({ ...error, title: false });
}
} else if (cat == "desc") {
setError({ ...error, title: val === "" || val === "null" });
} else if (cat === "desc") {
setDataForm({ ...dataForm, desc: val });
if (val == "" || val == "null") {
setError({ ...error, desc: true });
} else {
setError({ ...error, desc: false });
}
setError({ ...error, desc: val === "" || val === "null" });
}
}
function checkForm() {
if (
Object.values(error).some((v) => v == true) ||
Object.values(dataForm).some((v) => v == "")
) {
setDisableBtn(true);
const hasError = Object.values(error).some(v => v)
const hasEmpty = Object.values(dataForm).some(v => v === "")
setDisableBtn(hasError || hasEmpty);
}
useEffect(() => { checkForm() }, [error, dataForm]);
useEffect(() => { dispatch(setMemberChoose([])) }, [])
function handleOpenMemberPicker() {
if (entityUser.role === "supadmin" || entityUser.role === "developer") {
if (chooseGroup.val !== "") {
setSelect(true);
setValSelect("member");
} else {
Toast.show({ type: 'small', text1: 'Pilih Lembaga Desa terlebih dahulu' })
}
} else {
setDisableBtn(false);
validationForm('group', userLogin.idGroup, userLogin.group);
setValChoose(userLogin.idGroup)
setSelect(true);
setValSelect("member");
}
}
useEffect(() => {
checkForm();
}, [error, dataForm]);
useEffect(() => {
dispatch(setMemberChoose([]))
}, [])
function handleBack() {
dispatch(setMemberChoose([]))
router.back()
}
const pickDocumentAsync = async () => {
let result = await DocumentPicker.getDocumentAsync({
type: ["*/*"],
multiple: true
});
const result = await DocumentPicker.getDocumentAsync({ type: ["*/*"], multiple: true });
if (!result.canceled) {
for (let i = 0; i < result.assets?.length; i++) {
if (result.assets[i].uri) {
setFileForm((prev) => [...prev, result.assets[i]])
let skipped = 0
for (const asset of result.assets) {
if (!asset.uri) continue
if (fileForm.some(f => f.name === asset.name)) {
skipped++
} else {
setFileForm(prev => [...prev, asset])
}
}
if (skipped > 0) Toast.show({ type: 'small', text1: 'Beberapa file sudah ditambahkan' })
}
};
function deleteFile(index: number) {
setFileForm([...fileForm.filter((val, i) => i !== index)])
setFileForm(fileForm.filter((_, i) => i !== index))
setModalFile(false)
}
@@ -129,38 +129,22 @@ export default function CreateDiscussionGeneral() {
setLoading(true)
const hasil = await decryptToken(String(token?.current))
const fd = new FormData()
for (let i = 0; i < fileForm.length; i++) {
fd.append(`file${i}`, {
uri: fileForm[i].uri,
type: 'application/octet-stream',
name: fileForm[i].name,
} as any);
fd.append(`file${i}`, { uri: fileForm[i].uri, type: 'application/octet-stream', name: fileForm[i].name } as any);
}
fd.append("data", JSON.stringify(
{ ...dataForm, user: hasil, member: entitiesMember }
))
fd.append("data", JSON.stringify({ ...dataForm, user: hasil, member: entitiesMember }))
const response = await apiCreateDiscussionGeneral(fd)
// const response = await apiCreateDiscussionGeneral({
// data: { ...dataForm, user: hasil, member: entitiesMember },
// })
if (response.success) {
dispatch(setMemberChoose([]))
dispatch(setUpdateDiscussionGeneralDetail(!update))
Toast.show({ type: 'small', text1: 'Berhasil menambahkan data', })
Toast.show({ type: 'small', text1: 'Berhasil menambahkan data' })
router.back()
} else {
Toast.show({ type: 'small', text1: response.message, })
Toast.show({ type: 'small', text1: response.message })
}
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal menambahkan data"
Toast.show({ type: 'small', text1: message })
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menambahkan data" })
} finally {
setLoading(false)
}
@@ -170,36 +154,18 @@ export default function CreateDiscussionGeneral() {
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
// headerLeft: () => (
// <ButtonBackHeader
// onPress={() => { handleBack() }}
// />
// ),
headerTitle: "Tambah Diskusi",
headerTitleAlign: "center",
// headerRight: () => (
// <ButtonSaveHeader
// category="create"
// disable={disableBtn || loading ? true : false}
// onPress={() => {
// entitiesMember.length == 0
// ? Toast.show({ type: 'small', text1: 'Anda belum memilih anggota', })
// : handleCreate()
// }}
// />
// ),
header: () => (
<AppHeader
title="Tambah Diskusi"
showBack={true}
onPressLeft={() => router.back()}
onPressLeft={() => { dispatch(setMemberChoose([])); router.back() }}
right={
<ButtonSaveHeader
category="create"
disable={disableBtn || loading ? true : false}
disable={disableBtn || entitiesMember.length === 0 || loading}
onPress={() => {
entitiesMember.length == 0
? Toast.show({ type: 'small', text1: 'Anda belum memilih anggota', })
entitiesMember.length === 0
? Toast.show({ type: 'small', text1: 'Anda belum memilih anggota' })
: handleCreate()
}}
/>
@@ -211,25 +177,20 @@ export default function CreateDiscussionGeneral() {
{loading && <LoadingCenter />}
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100, Styles.flex1, { backgroundColor: colors.background }]}>
<View style={[Styles.p15, Styles.mb100]}>
{
(entityUser.role == "supadmin" ||
entityUser.role == "developer") && (
<SelectForm
label="Lembaga Desa"
placeholder="Pilih Lembaga Desa"
value={chooseGroup.label}
required
bg={colors.card}
onPress={() => {
setValChoose(chooseGroup.val);
setValSelect("group");
setSelect(true);
}}
error={error.group}
errorText="Lembaga Desa tidak boleh kosong"
/>
)
}
{(entityUser.role === "supadmin" || entityUser.role === "developer") && (
<SelectForm
label="Lembaga Desa"
placeholder="Pilih Lembaga Desa"
value={chooseGroup.label}
required
bg={colors.card}
onPress={() => { setValChoose(chooseGroup.val); setValSelect("group"); setSelect(true) }}
error={error.group}
errorText="Lembaga Desa tidak boleh kosong"
/>
)}
<InputForm
label="Judul"
type="default"
@@ -238,8 +199,9 @@ export default function CreateDiscussionGeneral() {
error={error.title}
bg={colors.card}
errorText="Judul tidak boleh kosong"
onChange={(val) => { validationForm("title", val) }}
onChange={(val) => validationForm("title", val)}
/>
<InputForm
label="Diskusi"
type="default"
@@ -248,93 +210,107 @@ export default function CreateDiscussionGeneral() {
error={error.desc}
bg={colors.card}
errorText="Diskusi tidak boleh kosong"
onChange={(val) => { validationForm("desc", val) }}
onChange={(val) => validationForm("desc", val)}
multiline
/>
<ButtonSelect value="Upload File" onPress={pickDocumentAsync} />
{
fileForm.length > 0
&&
<>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>File</Text>
<Text style={[Styles.textDefault]}>{fileForm.length} file</Text>
{/* File */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={pickDocumentAsync}
style={[Styles.sectionActionRow, { marginBottom: fileForm.length > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.icon + '15' }]}>
<MaterialCommunityIcons name="paperclip" size={18} color={colors.dimmed} />
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, Styles.mb10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
fileForm.map((item, index) => (
<BorderBottomItem
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>File</Text>
{fileForm.length === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Opsional ketuk untuk upload</Text>
)}
</View>
{fileForm.length > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.dimmed + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{fileForm.length} file</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{fileForm.length > 0 && (
<View style={Styles.fileGrid}>
{fileForm.map((item, index) => {
const ext = item.name.split('.').pop()?.toLowerCase() ?? ''
const baseName = item.name.includes('.') ? item.name.split('.').slice(0, -1).join('.') : item.name
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={index}
borderType={fileForm.length - 1 == index ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name}
bgColor="transparent"
titleWeight="normal"
onPress={() => { setIndexDelFile(index); setModalFile(true) }}
/>
))
}
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{baseName}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
</View>
</>
}
<ButtonSelect
value="Pilih Anggota"
onPress={() => {
if (entityUser.role == "supadmin" || entityUser.role == "developer") {
if (chooseGroup.val != "") {
setSelect(true);
setValSelect("member");
} else {
Toast.show({ type: 'small', text1: 'Pilih Lembaga Desa terlebih dahulu', })
}
} else {
validationForm('group', userLogin.idGroup, userLogin.group);
setValChoose(userLogin.idGroup)
setSelect(true);
setValSelect("member");
}
)}
</View>
}}
/>
{
entitiesMember.length > 0 &&
<View>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>Anggota</Text>
<Text style={[Styles.textDefault]}>{entitiesMember.length} Anggota</Text>
{/* Anggota */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={handleOpenMemberPicker}
style={[Styles.sectionActionRow, { marginBottom: entitiesMember.length > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.tabActive + '18' }]}>
<MaterialIcons name="people" size={18} color={colors.tabActive} />
</View>
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>Anggota</Text>
{entitiesMember.length === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Belum ada anggota dipilih</Text>
)}
</View>
{entitiesMember.length > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.tabActive + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.tabActive }]}>{entitiesMember.length} anggota</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{entitiesMember.length > 0 && (
<View style={{ gap: 6 }}>
{entitiesMember.map((item: any, index: number) => (
<View key={index} style={[Styles.listItemCard, { borderColor: colors.icon + '18' }]}>
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
<Text style={[Styles.textDefault, Styles.flex1, { color: colors.text }]} numberOfLines={1}>
{item.name}
</Text>
</View>
))}
</View>
)}
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
entitiesMember.map((item: { img: any; name: any; }, index: any) => {
return (
<BorderBottomItem
key={index}
borderType={entitiesMember.length - 1 == index ? "none" : "bottom"}
icon={
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
}
title={item.name}
bgColor="transparent"
/>
)
})
}
</View>
</View>
}
</View>
</ScrollView>
<ModalSelect
category={valSelect}
close={setSelect}
onSelect={(value) => {
validationForm(valSelect, value.val, value.label);
}}
title={valSelect == "group" ? "Lembaga Desa" : "Pilih Anggota"}
onSelect={(value) => validationForm(valSelect, value.val, value.label)}
title={valSelect === "group" ? "Lembaga Desa" : "Pilih Anggota"}
open={isSelect}
idParent={valSelect == "member" ? chooseGroup.val : ""}
idParent={valSelect === "member" ? chooseGroup.val : ""}
valChoose={valChoose}
/>
@@ -343,7 +319,7 @@ export default function CreateDiscussionGeneral() {
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus"
onPress={() => { deleteFile(indexDelFile) }}
onPress={() => deleteFile(indexDelFile)}
/>
</View>
</DrawerBottom>

View File

@@ -1,12 +1,10 @@
import AppHeader from "@/components/AppHeader";
import Text from "@/components/Text";
import BorderBottomItem from "@/components/borderBottomItem";
import ButtonSaveHeader from "@/components/buttonSaveHeader";
import ButtonSelect from "@/components/buttonSelect";
import DrawerBottom from "@/components/drawerBottom";
import { InputForm } from "@/components/inputForm";
import LoadingCenter from "@/components/loadingCenter";
import MenuItemRow from "@/components/menuItemRow";
import Text from "@/components/Text";
import Styles from "@/constants/Styles";
import { apiEditDiscussionGeneral, apiGetDiscussionGeneralOne } from "@/lib/api";
import { setUpdateDiscussionGeneralDetail } from "@/lib/discussionGeneralDetail";
@@ -16,10 +14,30 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import * as DocumentPicker from "expo-document-picker";
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { SafeAreaView, ScrollView, View } from "react-native";
import { Pressable, SafeAreaView, ScrollView, View } from "react-native";
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
function getFileIcon(ext: string): keyof typeof MaterialCommunityIcons.glyphMap {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return 'image-outline'
if (ext === 'pdf') return 'file-pdf-box'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline'
if (['doc', 'docx'].includes(ext)) return 'file-word-outline'
if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline'
if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline'
return 'file-outline'
}
function getFileColor(ext: string): string {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return '#339AF0'
if (ext === 'pdf') return '#F03E3E'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return '#AE3EC9'
if (['doc', 'docx'].includes(ext)) return '#1C7ED6'
if (['xls', 'xlsx'].includes(ext)) return '#2F9E44'
if (['zip', 'rar', '7z'].includes(ext)) return '#E8590C'
return '#868E96'
}
export default function EditDiscussionGeneral() {
const { token, decryptToken } = useAuthSession();
const { colors } = useTheme();
@@ -32,136 +50,91 @@ export default function EditDiscussionGeneral() {
const [indexDelFile, setIndexDelFile] = useState<{ id: string | number; cat: "newFile" | "oldFile" }>({ id: "", cat: "newFile" })
const [dataFile, setDataFile] = useState<{ id: string; idStorage: string; name: string; extension: string; delete?: boolean }[]>([])
const update = useSelector((state: any) => state.discussionGeneralDetailUpdate)
const [dataForm, setDataForm] = useState({
title: "",
desc: "",
});
const [error, setError] = useState({
title: false,
desc: false,
})
const [dataForm, setDataForm] = useState({ title: "", desc: "" });
const [error, setError] = useState({ title: false, desc: false })
const visibleOldFiles = dataFile.filter(v => !v.delete)
const totalFiles = fileForm.length + visibleOldFiles.length
async function handleLoad() {
try {
const hasil = await decryptToken(String(token?.current));
const response = await apiGetDiscussionGeneralOne({
id: id,
user: hasil,
cat: "detail",
});
const responseFile = await apiGetDiscussionGeneralOne({
id: id,
user: hasil,
cat: "file",
});
if (response.success) {
setDataForm(response.data);
}
if (responseFile.success) {
setDataFile(responseFile.data);
}
const response = await apiGetDiscussionGeneralOne({ id, user: hasil, cat: "detail" });
const responseFile = await apiGetDiscussionGeneralOne({ id, user: hasil, cat: "file" });
if (response.success) setDataForm(response.data);
if (responseFile.success) setDataFile(responseFile.data);
} catch (error) {
console.error(error);
}
}
useEffect(() => {
handleLoad();
}, []);
useEffect(() => { handleLoad() }, []);
function validationForm(cat: string, val: any) {
if (cat == "title") {
if (cat === "title") {
setDataForm({ ...dataForm, title: val });
if (val == "" || val == "null") {
setError({ ...error, title: true });
} else {
setError({ ...error, title: false });
}
} else if (cat == "desc") {
setError({ ...error, title: val === "" || val === "null" });
} else if (cat === "desc") {
setDataForm({ ...dataForm, desc: val });
if (val == "" || val == "null") {
setError({ ...error, desc: true });
} else {
setError({ ...error, desc: false });
}
setError({ ...error, desc: val === "" || val === "null" });
}
}
function checkForm() {
if (Object.values(error).some((v) => v == true) == true || Object.values(dataForm).some((v) => v == "") == true) {
setDisableBtn(true)
} else {
setDisableBtn(false)
}
const hasError = Object.values(error).some(v => v)
const hasEmpty = Object.values(dataForm).some(v => v === "")
setDisableBtn(hasError || hasEmpty);
}
useEffect(() => {
checkForm()
}, [error, dataForm])
useEffect(() => { checkForm() }, [error, dataForm])
const pickDocumentAsync = async () => {
let result = await DocumentPicker.getDocumentAsync({
type: ["*/*"],
multiple: true
});
const result = await DocumentPicker.getDocumentAsync({ type: ["*/*"], multiple: true });
if (!result.canceled) {
for (let i = 0; i < result.assets?.length; i++) {
if (result.assets[i].uri) {
setFileForm((prev) => [...prev, result.assets[i]])
let skipped = 0
for (const asset of result.assets) {
if (!asset.uri) continue
const isDup = fileForm.some(f => f.name === asset.name) ||
visibleOldFiles.some(f => `${f.name}.${f.extension}` === asset.name)
if (isDup) {
skipped++
} else {
setFileForm(prev => [...prev, asset])
}
}
if (skipped > 0) Toast.show({ type: 'small', text1: 'Beberapa file sudah ditambahkan' })
}
};
function deleteFile(index: number | string, cat: "newFile" | "oldFile" | null) {
if (cat == "newFile") {
setFileForm([...fileForm.filter((val, i) => i !== index)])
if (cat === "newFile") {
setFileForm(fileForm.filter((_, i) => i !== index))
} else {
setDataFile(prev =>
prev.map(item =>
item.id === index
? { ...item, delete: true }
: item
)
);
setDataFile(prev => prev.map(item => item.id === index ? { ...item, delete: true } : item))
}
setModalFile(false)
}
async function handleEdit() {
try {
setLoading(true)
const hasil = await decryptToken(String(token?.current));
const fd = new FormData()
for (let i = 0; i < fileForm.length; i++) {
fd.append(`file${i}`, {
uri: fileForm[i].uri,
type: 'application/octet-stream',
name: fileForm[i].name,
} as any);
fd.append(`file${i}`, { uri: fileForm[i].uri, type: 'application/octet-stream', name: fileForm[i].name } as any);
}
fd.append("data", JSON.stringify(
{
user: hasil, title: dataForm.title, desc: dataForm.desc, oldFile: dataFile
}
))
fd.append("data", JSON.stringify({ user: hasil, title: dataForm.title, desc: dataForm.desc, oldFile: dataFile }))
const response = await apiEditDiscussionGeneral(fd, id);
if (response.success) {
dispatch(setUpdateDiscussionGeneralDetail(!update))
Toast.show({ type: 'small', text1: 'Berhasil mengubah data', })
Toast.show({ type: 'small', text1: 'Berhasil mengubah data' })
router.back();
} else {
Toast.show({ type: 'small', text1: 'Gagal mengubah data', })
Toast.show({ type: 'small', text1: 'Gagal mengubah data' })
}
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal mengubah data"
Toast.show({ type: 'small', text1: message })
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal mengubah data" })
} finally {
setLoading(false)
}
@@ -171,22 +144,6 @@ export default function EditDiscussionGeneral() {
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
// headerLeft: () => (
// <ButtonBackHeader
// onPress={() => {
// router.back();
// }}
// />
// ),
headerTitle: "Edit Diskusi",
headerTitleAlign: "center",
// headerRight: () => (
// <ButtonSaveHeader
// disable={disableBtn || loading ? true : false}
// category="update"
// onPress={() => { handleEdit() }}
// />
// ),
header: () => (
<AppHeader
title="Edit Diskusi"
@@ -194,9 +151,9 @@ export default function EditDiscussionGeneral() {
onPressLeft={() => router.back()}
right={
<ButtonSaveHeader
disable={disableBtn || loading ? true : false}
disable={disableBtn || loading}
category="update"
onPress={() => { handleEdit() }}
onPress={() => handleEdit()}
/>
}
/>
@@ -205,7 +162,8 @@ export default function EditDiscussionGeneral() {
/>
{loading && <LoadingCenter />}
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100, Styles.flex1, { backgroundColor: colors.background }]}>
<View style={[Styles.p15]}>
<View style={Styles.p15}>
<InputForm
label="Judul"
type="default"
@@ -217,6 +175,7 @@ export default function EditDiscussionGeneral() {
errorText="Judul tidak boleh kosong"
onChange={(val) => validationForm("title", val)}
/>
<InputForm
label="Diskusi"
type="default"
@@ -229,45 +188,77 @@ export default function EditDiscussionGeneral() {
onChange={(val) => validationForm("desc", val)}
multiline
/>
<ButtonSelect value="Upload File" onPress={pickDocumentAsync} />
{
(fileForm.length > 0 || dataFile.filter((val) => !val.delete).length > 0)
&&
<>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>File</Text>
<Text style={[Styles.textDefault]}>{fileForm.length + dataFile.filter((val) => !val.delete).length} file</Text>
{/* File */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={pickDocumentAsync}
style={[Styles.sectionActionRow, { marginBottom: totalFiles > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.icon + '15' }]}>
<MaterialCommunityIcons name="paperclip" size={18} color={colors.dimmed} />
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, Styles.mb10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
dataFile.filter((val) => !val.delete).map((item, index) => (
<BorderBottomItem
key={index}
borderType={dataFile.filter((val) => !val.delete).length - 1 == index && fileForm.length == 0 ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name + '.' + item.extension}
titleWeight="normal"
bgColor="transparent"
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>File</Text>
{totalFiles === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Opsional ketuk untuk upload</Text>
)}
</View>
{totalFiles > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.dimmed + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{totalFiles} file</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{totalFiles > 0 && (
<View style={Styles.fileGrid}>
{visibleOldFiles.map((item, index) => {
const ext = item.extension.toLowerCase()
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={`old-${index}`}
onPress={() => { setIndexDelFile({ id: item.id, cat: "oldFile" }); setModalFile(true) }}
/>
))
}
{
fileForm.map((item, index) => (
<BorderBottomItem
key={index}
borderType={fileForm.length - 1 == index ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name}
titleWeight="normal"
bgColor="transparent"
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{item.name}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
{fileForm.map((item, index) => {
const ext = item.name.split('.').pop()?.toLowerCase() ?? ''
const baseName = item.name.includes('.') ? item.name.split('.').slice(0, -1).join('.') : item.name
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={`new-${index}`}
onPress={() => { setIndexDelFile({ id: index, cat: "newFile" }); setModalFile(true) }}
/>
))
}
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{baseName}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
</View>
</>
}
)}
</View>
</View>
</ScrollView>
@@ -276,7 +267,7 @@ export default function EditDiscussionGeneral() {
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus"
onPress={() => { deleteFile(indexDelFile.id, indexDelFile.cat) }}
onPress={() => deleteFile(indexDelFile.id, indexDelFile.cat)}
/>
</View>
</DrawerBottom>

View File

@@ -1,3 +1,4 @@
import GuideOverlay from "@/components/GuideOverlay";
import ButtonTab from "@/components/buttonTab";
import InputSearch from "@/components/inputSearch";
import LabelStatus from "@/components/labelStatus";
@@ -6,6 +7,8 @@ import Text from "@/components/Text";
import WrapTab from "@/components/wrapTab";
import Styles from "@/constants/Styles";
import { apiGetDiscussionGeneral } from "@/lib/api";
import { GUIDE_DISCUSSION } from "@/lib/guideSteps";
import { useGuide } from "@/lib/useGuide";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { AntDesign, Feather } from "@expo/vector-icons";
@@ -34,6 +37,7 @@ export default function Discussion() {
const queryClient = useQueryClient()
const [status, setStatus] = useState<'true' | 'false'>(active == 'false' ? 'false' : 'true')
const [refreshing, setRefreshing] = useState(false)
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('discussion')
const {
data,
@@ -83,10 +87,24 @@ export default function Discussion() {
const isOpen = (item: Props) => item.status === 1
const themed = {
background: { backgroundColor: colors.background },
card: { backgroundColor: colors.card, borderColor: colors.icon + '20' },
cardPressed: { backgroundColor: colors.icon + '10' },
iconCircle: { backgroundColor: colors.icon + '20' },
title: { color: colors.text },
dimmed: { color: colors.dimmed },
statusOpen: { borderColor: '#10B981' as const },
statusClosed: { borderColor: colors.dimmed + '80' },
statusTextOpen: { color: '#10B981' as const },
statusTextClosed: { color: colors.dimmed },
}
return (
<View style={[Styles.flex1, { backgroundColor: colors.background }]}>
<View style={[Styles.flex1, themed.background]}>
<GuideOverlay visible={guideVisible} steps={GUIDE_DISCUSSION} onDismiss={dismissGuide} />
{/* Header controls */}
<View style={[Styles.ph15, { paddingTop: 12 }]}>
<View style={[Styles.ph15, Styles.discussionHeaderPadding]}>
{entityUser.role != "user" && entityUser.role != "coadmin" && (
<WrapTab>
<ButtonTab
@@ -109,21 +127,21 @@ export default function Discussion() {
)}
<InputSearch onChange={setSearch} />
{(entityUser.role == "supadmin" || entityUser.role == "developer") && (
<View style={[Styles.mv05, Styles.rowItemsCenter]}>
<Text style={{ color: colors.dimmed, fontSize: 12 }}>Filter:</Text>
<View style={[Styles.mt10, Styles.rowOnly]}>
<Text>Filter :</Text>
<LabelStatus size="small" category="secondary" text={nameGroup} style={[Styles.mh05]} />
</View>
)}
</View>
{/* List */}
<View style={[Styles.flex1, Styles.ph15, { paddingTop: 8 }]}>
<View style={[Styles.flex1, Styles.ph15, Styles.discussionListPadding]}>
{isLoading ? (
[0, 1, 2, 3, 4].map((_, i) => <SkeletonContent key={i} />)
) : flatData.length === 0 ? (
<View style={[Styles.contentItemCenter, Styles.mt30]}>
<Feather name="message-circle" size={42} color={colors.icon + '40'} />
<Text style={[Styles.mt10, { color: colors.dimmed, fontSize: 14 }]}>
<Text style={[Styles.mt10, Styles.discussionEmptyText, themed.dimmed]}>
Tidak ada diskusi
</Text>
</View>
@@ -149,27 +167,25 @@ export default function Discussion() {
onPress={() => router.push(`/discussion/${item.id}`)}
style={({ pressed }) => [
Styles.discussionCard,
{
backgroundColor: pressed ? colors.icon + '10' : colors.card,
borderColor: colors.icon + '20',
}
themed.card,
pressed && themed.cardPressed,
]}
>
{/* Top row: icon + title + status badge */}
<View style={[Styles.rowItemsCenter, Styles.mb08]}>
{/* Discussion icon */}
<View style={[Styles.discussionIconCircle, { backgroundColor: colors.icon + '20' }]}>
<View style={[Styles.discussionIconCircle, themed.iconCircle]}>
<Feather name="message-circle" size={20} color={colors.icon} />
</View>
{/* Title + status badge */}
<View style={[Styles.flex1, { marginLeft: 10 }]}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]} numberOfLines={1}>
<View style={[Styles.flex1, Styles.discussionTitleCol]}>
<Text style={[Styles.textDefaultSemiBold, themed.title]} numberOfLines={1}>
{item.title}
</Text>
{status !== "false" && (
<View style={[Styles.discussionStatusPill, { borderColor: isOpen(item) ? '#10B981' : colors.dimmed + '80' }]}>
<Text style={[Styles.discussionStatusText, { color: isOpen(item) ? '#10B981' : colors.dimmed }]}>
<View style={[Styles.discussionStatusPill, isOpen(item) ? themed.statusOpen : themed.statusClosed]}>
<Text style={[Styles.discussionStatusText, isOpen(item) ? themed.statusTextOpen : themed.statusTextClosed]}>
{isOpen(item) ? 'Buka' : 'Tutup'}
</Text>
</View>
@@ -180,7 +196,7 @@ export default function Discussion() {
{/* Description */}
{item.desc ? (
<Text
style={[Styles.textMediumNormal, Styles.discussionCardIndent, { color: colors.dimmed, marginBottom: 10 }]}
style={[Styles.textMediumNormal, Styles.discussionCardIndent, Styles.discussionDescMargin, themed.title]}
numberOfLines={2}
>
{item.desc.replace(/<[^>]*>?/gm, ' ').replace(/\r?\n|\r/g, ' ')}
@@ -191,11 +207,11 @@ export default function Discussion() {
<View style={[Styles.rowItemsCenter, Styles.rowSpaceBetween, Styles.discussionCardIndent]}>
<View style={Styles.rowItemsCenter}>
<Feather name="message-square" size={14} color={colors.dimmed} />
<Text style={[Styles.discussionCommentText, { color: colors.dimmed }]}>
<Text style={[Styles.discussionCommentText, themed.dimmed]}>
{item.total_komentar} Komentar
</Text>
</View>
<Text style={[Styles.discussionDateText, { color: colors.dimmed }]}>
<Text style={[Styles.discussionDateText, themed.dimmed]}>
{item.createdAt}
</Text>
</View>

View File

@@ -1,21 +1,18 @@
import AppHeader from "@/components/AppHeader";
import BorderBottomItem from "@/components/borderBottomItem";
import DrawerBottom from "@/components/drawerBottom";
import ImageUser from "@/components/imageNew";
import MenuItemRow from "@/components/menuItemRow";
import ModalConfirmation from "@/components/ModalConfirmation";
import SkeletonTwoItem from "@/components/skeletonTwoItem";
import Text from '@/components/Text';
import { ColorsStatus } from "@/constants/ColorsStatus";
import { ConstEnv } from "@/constants/ConstEnv";
import Styles from "@/constants/Styles";
import { apiDeleteMemberDiscussionGeneral, apiGetDiscussionGeneralOne } from "@/lib/api";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Feather, MaterialCommunityIcons } from "@expo/vector-icons";
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { SafeAreaView, ScrollView, View } from "react-native";
import { Pressable, SafeAreaView, ScrollView, View } from "react-native";
import Toast from "react-native-toast-message";
import { useSelector } from "react-redux";
@@ -25,6 +22,8 @@ type Props = {
img: string
}
const SKELETON_COUNT = 5
export default function MemberDiscussionDetail() {
const { token, decryptToken } = useAuthSession()
const { colors } = useTheme();
@@ -35,13 +34,12 @@ export default function MemberDiscussionDetail() {
const [chooseUser, setChooseUser] = useState({ idUser: '', name: '', img: '' })
const update = useSelector((state: any) => state.discussionGeneralDetailUpdate)
const [loading, setLoading] = useState(true)
const arrSkeleton = Array.from({ length: 5 }, (_, index) => index)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const canManage = entityUser.role !== "user" && entityUser.role !== "coadmin"
async function handleLoad(loading: boolean) {
async function handleLoad(showLoadingIndicator: boolean) {
try {
setLoading(loading)
setLoading(showLoadingIndicator)
const hasil = await decryptToken(String(token?.current))
const response = await apiGetDiscussionGeneralOne({ id: id, user: hasil, cat: 'anggota' })
setData(response.data)
@@ -52,26 +50,18 @@ export default function MemberDiscussionDetail() {
}
}
useEffect(() => {
handleLoad(false)
}, [update]);
useEffect(() => {
handleLoad(true)
}, []);
useEffect(() => { handleLoad(false) }, [update]);
useEffect(() => { handleLoad(true) }, []);
async function handleDeleteUser() {
try {
const hasil = await decryptToken(String(token?.current))
await apiDeleteMemberDiscussionGeneral({ user: hasil, idUser: chooseUser.idUser }, id)
Toast.show({ type: 'small', text1: 'Berhasil mengeluarkan anggota dari diskusi', })
Toast.show({ type: 'small', text1: 'Berhasil mengeluarkan anggota dari diskusi' })
handleLoad(false)
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal mengeluarkan anggota"
Toast.show({ type: 'small', text1: message })
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal mengeluarkan anggota" })
} finally {
setModal(false)
}
@@ -81,9 +71,6 @@ export default function MemberDiscussionDetail() {
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
headerTitle: 'Anggota Diskusi',
headerTitleAlign: 'center',
header: () => (
<AppHeader
title="Anggota Diskusi"
@@ -93,49 +80,82 @@ export default function MemberDiscussionDetail() {
)
}}
/>
<ScrollView style={[Styles.h100, Styles.flex1, { backgroundColor: colors.background }]}>
<View style={[Styles.p15]}>
<Text style={[Styles.textDefault, Styles.mv05]}>{data.length} Anggota</Text>
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.background }]}>
{
entityUser.role != "user" && entityUser.role != "coadmin" &&
<BorderBottomItem
onPress={() => { router.push(`/discussion/add-member/${id}`) }}
borderType="none"
icon={
<View style={[Styles.iconContent, ColorsStatus.gray]}>
<Feather name="user-plus" size={25} color={'#384288'} />
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100, Styles.flex1, { backgroundColor: colors.background }]}>
<View style={[Styles.p15, Styles.mb100]}>
{/* Tombol tambah anggota */}
{canManage && (
<View style={[Styles.wrapPaper, Styles.sectionCard, Styles.mb15,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={() => router.push(`/discussion/add-member/${id}`)}
style={Styles.sectionActionRow}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.icon + '18' }]}>
<MaterialCommunityIcons name="account-plus-outline" size={18} color={colors.icon} />
</View>
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>Tambah Anggota</Text>
</View>
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
</View>
)}
{/* Full list */}
<View style={[Styles.wrapPaper, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18', padding: 0, overflow: 'hidden' }]}>
<View style={[Styles.sectionActionRow, { padding: 16, borderBottomWidth: 1, borderBottomColor: colors.icon + '14' }]}>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.dimmed + '18' }]}>
<MaterialIcons name="people" size={18} color={colors.dimmed} />
</View>
<Text style={[Styles.textDefaultSemiBold, Styles.flex1, { color: colors.text }]}>Anggota</Text>
{!loading && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>{data.length} anggota</Text>
)}
</View>
{loading
? Array.from({ length: SKELETON_COUNT }).map((_, i) => (
<View
key={i}
style={[Styles.rowItemsCenter, Styles.ph15,
{ paddingVertical: 14, gap: 14, borderBottomWidth: i < SKELETON_COUNT - 1 ? 1 : 0, borderBottomColor: colors.icon + '14' }]}
>
<View style={[Styles.userProfileExtraSmall, { backgroundColor: colors.icon + '20', borderRadius: 100 }]} />
<View style={{ height: 13, borderRadius: 6, flex: 1, backgroundColor: colors.icon + '20', maxWidth: 140 + (i % 3) * 30 }} />
</View>
))
: data.length === 0
? (
<View style={[Styles.contentItemCenter, { paddingVertical: 40 }]}>
<MaterialIcons name="people-outline" size={34} color={colors.icon + '50'} />
<Text style={[Styles.textMediumNormal, Styles.mt10, { color: colors.dimmed }]}>Belum ada anggota</Text>
</View>
}
title="Tambah Anggota"
/>
}
{
loading ?
arrSkeleton.map((item, index) => {
return (
<SkeletonTwoItem key={index} />
)
})
:
data.map((item, index) => {
return (
<BorderBottomItem
key={index}
borderType="bottom"
icon={
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="sm" />
}
title={item.name}
onPress={() => {
setChooseUser(item)
setModal(true)
}}
/>
)
})
)
: data.map((item, index) => (
<Pressable
key={index}
onPress={() => { setChooseUser(item); setModal(true) }}
style={({ pressed }) => [
Styles.rowItemsCenter, Styles.ph15,
{
paddingVertical: 13, gap: 14,
borderBottomWidth: index < data.length - 1 ? 1 : 0,
borderBottomColor: colors.icon + '14',
backgroundColor: pressed ? colors.icon + '0E' : 'transparent',
},
]}
>
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
<Text style={[Styles.textDefault, Styles.flex1, { color: colors.text }]} numberOfLines={1}>
{item.name}
</Text>
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.icon + '60'} />
</Pressable>
))
}
</View>
</View>
</ScrollView>
@@ -149,20 +169,16 @@ export default function MemberDiscussionDetail() {
router.push(`/member/${chooseUser.idUser}`)
}}
/>
{
entityUser.role != "user" && entityUser.role != "coadmin" &&
{canManage && (
<MenuItemRow
icon={<MaterialCommunityIcons name="account-remove" color={colors.text} size={25} />}
title="Keluarkan"
onPress={() => {
setModal(false)
setTimeout(() => {
setShowDeleteModal(true)
}, 600)
setTimeout(() => setShowDeleteModal(true), 600)
}}
/>
}
)}
</View>
</DrawerBottom>
@@ -170,10 +186,7 @@ export default function MemberDiscussionDetail() {
visible={showDeleteModal}
title="Konfirmasi"
message="Apakah anda yakin ingin mengeluarkan anggota?"
onConfirm={() => {
setShowDeleteModal(false)
handleDeleteUser()
}}
onConfirm={() => { setShowDeleteModal(false); handleDeleteUser() }}
onCancel={() => setShowDeleteModal(false)}
confirmText="Hapus"
cancelText="Batal"

View File

@@ -1,12 +1,9 @@
import ModalConfirmation from "@/components/ModalConfirmation"
import AppHeader from "@/components/AppHeader"
import BorderBottomItem from "@/components/borderBottomItem"
import ButtonBackHeader from "@/components/buttonBackHeader"
import HeaderRightCalendarDetail from "@/components/calendar/headerCalendarDetail"
import DrawerBottom from "@/components/drawerBottom"
import HeaderRightCalendarDetail from "@/components/calendar/headerCalendarDetail"
import ImageUser from "@/components/imageNew"
import MenuItemRow from "@/components/menuItemRow"
import Skeleton from "@/components/skeleton"
import ModalConfirmation from "@/components/ModalConfirmation"
import Text from "@/components/Text"
import { ConstEnv } from "@/constants/ConstEnv"
import Styles from "@/constants/Styles"
@@ -14,7 +11,7 @@ import { apiDeleteCalendarMember, apiGetCalendarOne, apiGetDivisionOneFeature }
import { setUpdateCalendar } from "@/lib/calendarUpdate"
import { useAuthSession } from "@/providers/AuthProvider"
import { useTheme } from "@/providers/ThemeProvider"
import { MaterialCommunityIcons } from "@expo/vector-icons"
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons"
import Clipboard from "@react-native-clipboard/clipboard"
import { router, Stack, useLocalSearchParams } from "expo-router"
import { useEffect, useState } from "react"
@@ -156,135 +153,142 @@ export default function DetailEventCalendar() {
setRefreshing(false)
};
const canManage = !((entityUser.role === "user" || entityUser.role === "coadmin") && !isMemberDivision)
const repeatLabel: Record<string, string> = {
once: 'Acara 1 Kali',
daily: 'Setiap Hari',
weekly: 'Mingguan',
monthly: 'Bulanan',
yearly: 'Tahunan',
}
function InfoRow({ icon, label, value, onCopy }: { icon: string, label: string, value?: string, onCopy?: () => void }) {
return (
<View style={[Styles.sectionActionRow, { paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: colors.icon + '14' }]}>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.dimmed + '18' }]}>
<MaterialCommunityIcons name={icon as any} size={18} color={colors.dimmed} />
</View>
<View style={Styles.flex1}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed, marginBottom: 2 }]}>{label}</Text>
{loading
? <View style={{ height: 13, borderRadius: 6, backgroundColor: colors.icon + '20', width: '70%' }} />
: <Text style={[Styles.textDefault, { color: colors.text }]}>{value || '-'}</Text>
}
</View>
{onCopy && !loading && value && (
<Pressable onPress={onCopy} style={{ padding: 4 }}>
<MaterialCommunityIcons name="content-copy" size={16} color={colors.dimmed} />
</Pressable>
)}
</View>
)
}
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
headerTitle: 'Detail Acara',
headerTitleAlign: 'center',
// headerRight: () => (entityUser.role == "user" || entityUser.role == "coadmin") && !isMemberDivision ? <></> : <HeaderRightCalendarDetail id={String(data?.idCalendar)} idReminder={String(detail)} />
header: () => (
<AppHeader
title="Detail Acara"
showBack={true}
onPressLeft={() => router.back()}
right={
(entityUser.role == "user" || entityUser.role == "coadmin") && !isMemberDivision ? <></> : <HeaderRightCalendarDetail id={String(data?.idCalendar)} idReminder={String(detail)} />
(entityUser.role === "user" || entityUser.role === "coadmin") && !isMemberDivision
? <></> : <HeaderRightCalendarDetail id={String(data?.idCalendar)} idReminder={String(detail)} />
}
/>
)
}}
/>
<ScrollView
style={[Styles.h100]}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.icon}
/>
}
showsVerticalScrollIndicator={false}
style={Styles.h100}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} tintColor={colors.icon} />}
>
<View style={[Styles.p15]}>
<View style={[Styles.wrapPaper, Styles.mb15, { backgroundColor: colors.card, borderColor: colors.background }]}>
<View style={[Styles.rowItemsCenter, { alignItems: 'flex-start' }]}>
<MaterialCommunityIcons name="calendar-text" size={30} color={colors.text} style={Styles.mr10} />
{
loading ?
<Skeleton width={80} height={10} borderRadius={10} widthType="percent" />
: <Text style={[Styles.textDefault, Styles.w90]}>{data?.title}</Text>
}
<View style={[Styles.p15, Styles.mb100]}>
{/* Info acara */}
<View style={[Styles.wrapPaper, Styles.sectionCard, Styles.noShadow, Styles.mb15,
{ backgroundColor: colors.card, borderColor: colors.icon + '18', padding: 0, overflow: 'hidden' }]}>
<View style={{ padding: 16, borderBottomWidth: 1, borderBottomColor: colors.icon + '14' }}>
<View style={Styles.sectionActionRow}>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.dimmed + '18' }]}>
<MaterialCommunityIcons name="calendar-text" size={18} color={colors.dimmed} />
</View>
<Text style={[Styles.textDefaultSemiBold, Styles.flex1, { color: colors.text }]}>Detail Acara</Text>
</View>
</View>
<View style={[Styles.rowItemsCenter, Styles.mt10]}>
<MaterialCommunityIcons name="calendar-month-outline" size={30} color={colors.text} style={Styles.mr10} />
{
loading ?
<Skeleton width={80} height={10} borderRadius={10} widthType="percent" />
:
<Text style={[Styles.textDefault]}>{data?.dateStart}</Text>
}
<View style={{ paddingHorizontal: 16 }}>
<InfoRow icon="format-title" label="Judul" value={data?.title} />
<InfoRow icon="calendar-month-outline" label="Tanggal" value={data?.dateStart} />
<InfoRow icon="clock-outline" label="Waktu" value={data ? `${data.timeStart} ${data.timeEnd}` : undefined} />
<InfoRow icon="repeat" label="Pengulangan" value={data?.repeatEventTyper ? repeatLabel[data.repeatEventTyper] : undefined} />
<InfoRow icon="link-variant" label="Link Meet" value={data?.linkMeet} onCopy={data?.linkMeet ? () => handleCopy(data.linkMeet) : undefined} />
<View style={[Styles.sectionActionRow, { paddingVertical: 10, alignItems: 'flex-start' }]}>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.dimmed + '18' }]}>
<MaterialCommunityIcons name="card-text-outline" size={18} color={colors.dimmed} />
</View>
<View style={Styles.flex1}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed, marginBottom: 2 }]}>Deskripsi</Text>
{loading
? <View style={{ height: 13, borderRadius: 6, backgroundColor: colors.icon + '20', width: '80%' }} />
: <Text style={[Styles.textDefault, { color: colors.text, lineHeight: 22 }]}>{data?.desc || '-'}</Text>
}
</View>
</View>
</View>
<View style={[Styles.rowItemsCenter, Styles.mt10]}>
<MaterialCommunityIcons name="clock-outline" size={30} color={colors.text} style={Styles.mr10} />
{
loading ?
<Skeleton width={80} height={10} borderRadius={10} widthType="percent" />
:
<Text style={[Styles.textDefault]}>{data?.timeStart} | {data?.timeEnd}</Text>
}
</View>
{/* Daftar anggota */}
<View style={[Styles.wrapPaper, Styles.sectionCard, Styles.noShadow,
{ backgroundColor: colors.card, borderColor: colors.icon + '18', padding: 0, overflow: 'hidden' }]}>
<View style={[Styles.sectionActionRow, { padding: 16, borderBottomWidth: 1, borderBottomColor: colors.icon + '14' }]}>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.dimmed + '18' }]}>
<MaterialIcons name="people" size={18} color={colors.dimmed} />
</View>
<Text style={[Styles.textDefaultSemiBold, Styles.flex1, { color: colors.text }]}>Anggota</Text>
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>{member.length} anggota</Text>
</View>
<View style={[Styles.rowItemsCenter, Styles.mt10]}>
<MaterialCommunityIcons name="repeat" size={30} color={colors.text} style={Styles.mr10} />
{
loading ?
<Skeleton width={80} height={10} borderRadius={10} widthType="percent" />
:
<Text style={[Styles.textDefault]}>
{member.length === 0
? (
<View style={[Styles.contentItemCenter, { paddingVertical: 40 }]}>
<MaterialIcons name="people-outline" size={34} color={colors.icon + '50'} />
<Text style={[Styles.textMediumNormal, Styles.mt10, { color: colors.dimmed }]}>Belum ada anggota</Text>
</View>
)
: member.map((item, index) => (
<Pressable
key={index}
onPress={() => {
if (!canManage) return
setMemberChoose({ id: item.idUser, name: item.name })
setModalMember(true)
}}
style={({ pressed }) => [
Styles.rowItemsCenter, Styles.ph15,
{
data?.repeatEventTyper.toString() === 'once' ? 'Acara 1 Kali' :
data?.repeatEventTyper.toString() === 'daily' ? 'Setiap Hari' :
data?.repeatEventTyper.toString() === 'weekly' ? 'Mingguan' :
data?.repeatEventTyper.toString() === 'monthly' ? 'Bulanan' :
data?.repeatEventTyper.toString() === 'yearly' ? 'Tahunan' :
''
}
</Text>
}
</View>
<View style={[Styles.rowItemsCenter, Styles.mt10]}>
<MaterialCommunityIcons name="link-variant" size={30} color={colors.text} style={Styles.mr10} />
{
loading ?
<Skeleton width={80} height={10} borderRadius={10} widthType="percent" />
:
data?.linkMeet ?
<Pressable onPress={() => { handleCopy(data.linkMeet) }}>
<Text style={[Styles.textDefault]}>{data.linkMeet}</Text>
</Pressable>
: <Text style={[Styles.textDefault]}>-</Text>
}
</View>
<View style={[Styles.rowItemsCenter, Styles.mt10, { alignItems: 'flex-start' }]}>
<MaterialCommunityIcons name="card-text-outline" size={30} color={colors.text} style={Styles.mr10} />
{
loading ?
<Skeleton width={80} height={10} borderRadius={10} widthType="percent" />
:
<Text style={[Styles.textDefault, Styles.w90]}>{data?.desc}</Text>
}
</View>
paddingVertical: 12, gap: 14,
borderBottomWidth: index < member.length - 1 ? 1 : 0,
borderBottomColor: colors.icon + '14',
backgroundColor: pressed && canManage ? colors.icon + '0E' : 'transparent',
},
]}
>
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
<View style={Styles.flex1}>
<Text style={[Styles.textDefault, { color: colors.text }]} numberOfLines={1}>{item.name}</Text>
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]} numberOfLines={1}>{item.email}</Text>
</View>
{canManage && <MaterialCommunityIcons name="chevron-right" size={18} color={colors.icon + '60'} />}
</Pressable>
))
}
</View>
<View style={[Styles.mb15]}>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>Anggota</Text>
<Text style={[Styles.textDefault]}>Total {member.length} Anggota</Text>
</View>
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.background }]}>
{
member.map((item, index) => (
<BorderBottomItem
key={index}
borderType="bottom"
icon={<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} />}
title={item.name}
subtitle={item.email}
onPress={() => {
if ((entityUser.role == "user" || entityUser.role == "coadmin") && !isMemberDivision) {
null
} else {
setMemberChoose({ id: item.idUser, name: item.name })
setModalMember(true)
}
}}
/>
))
}
</View>
</View>
</View>
</ScrollView>

View File

@@ -1,4 +1,5 @@
import AppHeader from "@/components/AppHeader";
import GuideOverlay from "@/components/GuideOverlay";
import HeaderRightCalendarList from "@/components/calendar/headerCalendarList";
import ItemDateCalendar from "@/components/calendar/itemDateCalendar";
import EventItem from "@/components/eventItem";
@@ -6,6 +7,8 @@ import Skeleton from "@/components/skeleton";
import Text from "@/components/Text";
import Styles from "@/constants/Styles";
import { apiGetCalendarByDateDivision, apiGetIndicatorCalendar } from "@/lib/api";
import { GUIDE_DIVISION_CALENDAR } from "@/lib/guideSteps";
import { useGuide } from "@/lib/useGuide";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Feather } from "@expo/vector-icons";
@@ -46,6 +49,7 @@ export default function CalendarDivision() {
const [loading, setLoading] = useState(true)
const [loadingBtn, setLoadingBtn] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('division-calendar')
async function handleLoad(loading: boolean) {
@@ -150,6 +154,7 @@ export default function CalendarDivision() {
)
}}
/>
<GuideOverlay visible={guideVisible} steps={GUIDE_DIVISION_CALENDAR} onDismiss={dismissGuide} />
<ScrollView
refreshControl={
<RefreshControl

View File

@@ -1,7 +1,5 @@
import AppHeader from "@/components/AppHeader";
import BorderBottomItem from "@/components/borderBottomItem";
import ButtonSaveHeader from "@/components/buttonSaveHeader";
import ButtonSelect from "@/components/buttonSelect";
import DrawerBottom from "@/components/drawerBottom";
import { InputForm } from "@/components/inputForm";
import LoadingCenter from "@/components/loadingCenter";
@@ -16,10 +14,30 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import * as DocumentPicker from "expo-document-picker";
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { SafeAreaView, ScrollView, View } from "react-native";
import { Pressable, SafeAreaView, ScrollView, View } from "react-native";
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
function getFileIcon(ext: string): keyof typeof MaterialCommunityIcons.glyphMap {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return 'image-outline'
if (ext === 'pdf') return 'file-pdf-box'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline'
if (['doc', 'docx'].includes(ext)) return 'file-word-outline'
if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline'
if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline'
return 'file-outline'
}
function getFileColor(ext: string): string {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return '#339AF0'
if (ext === 'pdf') return '#F03E3E'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return '#AE3EC9'
if (['doc', 'docx'].includes(ext)) return '#1C7ED6'
if (['xls', 'xlsx'].includes(ext)) return '#2F9E44'
if (['zip', 'rar', '7z'].includes(ext)) return '#E8590C'
return '#868E96'
}
export default function DiscussionDivisionEdit() {
const { colors } = useTheme();
const { id, detail } = useLocalSearchParams<{ id: string; detail: string }>();
@@ -33,30 +51,49 @@ export default function DiscussionDivisionEdit() {
const [indexDelFile, setIndexDelFile] = useState<{ id: string | number; cat: "newFile" | "oldFile" }>({ id: "", cat: "newFile" })
const [dataFile, setDataFile] = useState<{ id: string; idStorage: string; name: string; extension: string; delete?: boolean }[]>([])
const visibleOldFiles = dataFile.filter(v => !v.delete)
const totalFiles = fileForm.length + visibleOldFiles.length
async function handleLoad() {
try {
const hasil = await decryptToken(String(token?.current));
const response = await apiGetDiscussionOne({
id: detail,
user: hasil,
cat: "data",
});
const response2 = await apiGetDiscussionOne({
id: detail,
user: hasil,
cat: "file",
});
setDataFile(response2.data);
const response = await apiGetDiscussionOne({ id: detail, user: hasil, cat: "data" });
const response2 = await apiGetDiscussionOne({ id: detail, user: hasil, cat: "file" });
setData(response.data.desc);
setDataFile(response2.data);
} catch (error) {
console.error(error);
}
}
useEffect(() => {
handleLoad();
}, []);
useEffect(() => { handleLoad() }, []);
const pickDocumentAsync = async () => {
const result = await DocumentPicker.getDocumentAsync({ type: ["*/*"], multiple: true });
if (!result.canceled) {
let skipped = 0
for (const asset of result.assets) {
if (!asset.uri) continue
const isDup = fileForm.some(f => f.name === asset.name) ||
visibleOldFiles.some(f => `${f.name}.${f.extension}` === asset.name)
if (isDup) {
skipped++
} else {
setFileForm(prev => [...prev, asset])
}
}
if (skipped > 0) Toast.show({ type: 'small', text1: 'Beberapa file sudah ditambahkan' })
}
};
function deleteFile(index: number | string, cat: "newFile" | "oldFile" | null) {
if (cat === "newFile") {
setFileForm(fileForm.filter((_, i) => i !== index))
} else {
setDataFile(prev => prev.map(item => item.id === index ? { ...item, delete: true } : item))
}
setModalFile(false)
}
async function handleUpdate() {
try {
@@ -64,94 +101,29 @@ export default function DiscussionDivisionEdit() {
const hasil = await decryptToken(String(token?.current));
const fd = new FormData()
for (let i = 0; i < fileForm.length; i++) {
fd.append(`file${i}`, {
uri: fileForm[i].uri,
type: 'application/octet-stream',
name: fileForm[i].name,
} as any);
fd.append(`file${i}`, { uri: fileForm[i].uri, type: 'application/octet-stream', name: fileForm[i].name } as any);
}
fd.append("data", JSON.stringify(
{
user: hasil, desc: data, oldFile: dataFile
}
))
fd.append("data", JSON.stringify({ user: hasil, desc: data, oldFile: dataFile }))
const response = await apiEditDiscussion(fd, detail);
// const response = await apiEditDiscussion({
// data: { user: hasil, desc: data },
// id: detail,
// });
if (response.success) {
Toast.show({ type: 'small', text1: 'Berhasil mengubah data', })
Toast.show({ type: 'small', text1: 'Berhasil mengubah data' })
dispatch(setUpdateDiscussion({ ...update, data: !update.data }));
router.back();
} else {
Toast.show({ type: 'small', text1: response.message, })
Toast.show({ type: 'small', text1: response.message })
}
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal mengubah data"
Toast.show({ type: 'small', text1: message })
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal mengubah data" })
} finally {
setLoading(false)
}
}
const pickDocumentAsync = async () => {
let result = await DocumentPicker.getDocumentAsync({
type: ["*/*"],
multiple: true
});
if (!result.canceled) {
for (let i = 0; i < result.assets?.length; i++) {
if (result.assets[i].uri) {
setFileForm((prev) => [...prev, result.assets[i]])
}
}
}
};
function deleteFile(index: number | string, cat: "newFile" | "oldFile" | null) {
if (cat == "newFile") {
setFileForm([...fileForm.filter((val, i) => i !== index)])
} else {
setDataFile(prev =>
prev.map(item =>
item.id === index
? { ...item, delete: true }
: item
)
);
}
setModalFile(false)
}
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
// headerLeft: () => (
// <ButtonBackHeader
// onPress={() => {
// router.back();
// }}
// />
// ),
headerTitle: "Edit Diskusi",
headerTitleAlign: "center",
// headerRight: () => (
// <ButtonSaveHeader
// disable={data == "" || loading}
// category="update"
// onPress={() => {
// handleUpdate();
// }}
// />
// ),
header: () => (
<AppHeader
title="Edit Diskusi"
@@ -161,9 +133,7 @@ export default function DiscussionDivisionEdit() {
<ButtonSaveHeader
disable={data == "" || loading}
category="update"
onPress={() => {
handleUpdate();
}}
onPress={() => handleUpdate()}
/>
}
/>
@@ -171,8 +141,8 @@ export default function DiscussionDivisionEdit() {
}}
/>
{loading && <LoadingCenter />}
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100]}>
<View style={[Styles.p15]}>
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100, Styles.flex1, { backgroundColor: colors.background }]}>
<View style={Styles.p15}>
<InputForm
label="Diskusi"
type="default"
@@ -184,57 +154,87 @@ export default function DiscussionDivisionEdit() {
bg={colors.card}
/>
<ButtonSelect value="Upload File" onPress={pickDocumentAsync} />
{
(fileForm.length > 0 || dataFile.filter((val) => !val.delete).length > 0)
&&
<>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>File</Text>
<Text style={[Styles.textDefault]}>{fileForm.length + dataFile.filter((val) => !val.delete).length} file</Text>
{/* File */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={pickDocumentAsync}
style={[Styles.sectionActionRow, { marginBottom: totalFiles > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.icon + '15' }]}>
<MaterialCommunityIcons name="paperclip" size={18} color={colors.dimmed} />
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, Styles.mb10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
dataFile.filter((val) => !val.delete).map((item, index) => (
<BorderBottomItem
key={index}
borderType={dataFile.filter((val) => !val.delete).length - 1 == index && fileForm.length == 0 ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name + '.' + item.extension}
titleWeight="normal"
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>File</Text>
{totalFiles === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Opsional ketuk untuk upload</Text>
)}
</View>
{totalFiles > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.dimmed + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{totalFiles} file</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{totalFiles > 0 && (
<View style={Styles.fileGrid}>
{visibleOldFiles.map((item, index) => {
const ext = item.extension.toLowerCase()
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={`old-${index}`}
onPress={() => { setIndexDelFile({ id: item.id, cat: "oldFile" }); setModalFile(true) }}
/>
))
}
{
fileForm.map((item, index) => (
<BorderBottomItem
key={index}
borderType={fileForm.length - 1 == index ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name}
titleWeight="normal"
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{item.name}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
{fileForm.map((item, index) => {
const ext = item.name.split('.').pop()?.toLowerCase() ?? ''
const baseName = item.name.includes('.') ? item.name.split('.').slice(0, -1).join('.') : item.name
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={`new-${index}`}
onPress={() => { setIndexDelFile({ id: index, cat: "newFile" }); setModalFile(true) }}
/>
))
}
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{baseName}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
</View>
</>
}
)}
</View>
</View>
</ScrollView>
<DrawerBottom animation="slide" isVisible={isModalFile} setVisible={setModalFile} title="Menu">
<View style={Styles.rowItemsCenter}>
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus"
onPress={() => { deleteFile(indexDelFile.id, indexDelFile.cat) }}
onPress={() => deleteFile(indexDelFile.id, indexDelFile.cat)}
/>
</View>
</DrawerBottom>
</SafeAreaView>
);
}

View File

@@ -1,7 +1,5 @@
import AppHeader from "@/components/AppHeader"
import BorderBottomItem from "@/components/borderBottomItem"
import ButtonSaveHeader from "@/components/buttonSaveHeader"
import ButtonSelect from "@/components/buttonSelect"
import DrawerBottom from "@/components/drawerBottom"
import { InputForm } from "@/components/inputForm"
import LoadingCenter from "@/components/loadingCenter"
@@ -16,10 +14,29 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"
import * as DocumentPicker from "expo-document-picker"
import { router, Stack, useLocalSearchParams } from "expo-router"
import { useState } from "react"
import { SafeAreaView, ScrollView, View } from "react-native"
import { Pressable, SafeAreaView, ScrollView, View } from "react-native"
import Toast from "react-native-toast-message"
import { useDispatch, useSelector } from "react-redux"
function getFileIcon(ext: string): keyof typeof MaterialCommunityIcons.glyphMap {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return 'image-outline'
if (ext === 'pdf') return 'file-pdf-box'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline'
if (['doc', 'docx'].includes(ext)) return 'file-word-outline'
if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline'
if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline'
return 'file-outline'
}
function getFileColor(ext: string): string {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return '#339AF0'
if (ext === 'pdf') return '#F03E3E'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return '#AE3EC9'
if (['doc', 'docx'].includes(ext)) return '#1C7ED6'
if (['xls', 'xlsx'].includes(ext)) return '#2F9E44'
if (['zip', 'rar', '7z'].includes(ext)) return '#E8590C'
return '#868E96'
}
export default function CreateDiscussionDivision() {
const { colors } = useTheme();
@@ -34,76 +51,55 @@ export default function CreateDiscussionDivision() {
const [indexDelFile, setIndexDelFile] = useState<number>(0)
const pickDocumentAsync = async () => {
let result = await DocumentPicker.getDocumentAsync({
type: ["*/*"],
multiple: true
});
const result = await DocumentPicker.getDocumentAsync({ type: ["*/*"], multiple: true });
if (!result.canceled) {
for (let i = 0; i < result.assets?.length; i++) {
if (result.assets[i].uri) {
setFileForm((prev) => [...prev, result.assets[i]])
let skipped = 0
for (const asset of result.assets) {
if (!asset.uri) continue
if (fileForm.some(f => f.name === asset.name)) {
skipped++
} else {
setFileForm(prev => [...prev, asset])
}
}
if (skipped > 0) Toast.show({ type: 'small', text1: 'Beberapa file sudah ditambahkan' })
}
};
function deleteFile(index: number) {
setFileForm([...fileForm.filter((val, i) => i !== index)])
setFileForm(fileForm.filter((_, i) => i !== index))
setModalFile(false)
}
async function handleCreate() {
try {
setLoading(true)
const hasil = await decryptToken(String(token?.current))
const fd = new FormData()
for (let i = 0; i < fileForm.length; i++) {
fd.append(`file${i}`, {
uri: fileForm[i].uri,
type: 'application/octet-stream',
name: fileForm[i].name,
} as any);
fd.append(`file${i}`, { uri: fileForm[i].uri, type: 'application/octet-stream', name: fileForm[i].name } as any);
}
fd.append("data", JSON.stringify(
{ user: hasil, desc, idDivision: id }
))
fd.append("data", JSON.stringify({ user: hasil, desc, idDivision: id }))
const response = await apiCreateDiscussion(fd)
// const response = await apiCreateDiscussion({ data: { user: hasil, desc, idDivision: id } })
if (response.success) {
Toast.show({ type: 'small', text1: 'Berhasil menambahkan data', })
Toast.show({ type: 'small', text1: 'Berhasil menambahkan data' })
dispatch(setUpdateDiscussion({ ...update, data: !update.data }));
router.back()
} else {
Toast.show({ type: 'small', text1: response.message, })
Toast.show({ type: 'small', text1: response.message })
}
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal menambahkan data"
Toast.show({ type: 'small', text1: message })
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menambahkan data" })
} finally {
setLoading(false)
}
}
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
headerTitle: 'Tambah Diskusi',
headerTitleAlign: 'center',
// headerRight: () => <ButtonSaveHeader
// disable={desc == "" || loading}
// category="create"
// onPress={() => {
// handleCreate()
// }} />
header: () => (
<AppHeader
title="Tambah Diskusi"
@@ -113,16 +109,15 @@ export default function CreateDiscussionDivision() {
<ButtonSaveHeader
disable={desc == "" || loading}
category="create"
onPress={() => {
handleCreate()
}} />
onPress={() => handleCreate()}
/>
}
/>
)
}}
/>
{loading && <LoadingCenter />}
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100]}>
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100, Styles.flex1, { backgroundColor: colors.background }]}>
<View style={[Styles.p15, Styles.mb100]}>
<InputForm
label="Diskusi"
@@ -133,32 +128,56 @@ export default function CreateDiscussionDivision() {
multiline
bg={colors.card}
/>
<ButtonSelect value="Upload File" onPress={pickDocumentAsync} />
{
fileForm.length > 0
&&
<>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>File</Text>
<Text style={[Styles.textDefault]}>{fileForm.length} file</Text>
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, Styles.mb10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
fileForm.map((item, index) => (
<BorderBottomItem
key={index}
borderType={fileForm.length - 1 == index ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name}
titleWeight="normal"
onPress={() => { setIndexDelFile(index); setModalFile(true) }}
/>
))
}
</View>
</>
}
{/* File */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={pickDocumentAsync}
style={[Styles.sectionActionRow, { marginBottom: fileForm.length > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.icon + '15' }]}>
<MaterialCommunityIcons name="paperclip" size={18} color={colors.dimmed} />
</View>
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>File</Text>
{fileForm.length === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Opsional ketuk untuk upload</Text>
)}
</View>
{fileForm.length > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.dimmed + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{fileForm.length} file</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{fileForm.length > 0 && (
<View style={Styles.fileGrid}>
{fileForm.map((item, index) => {
const ext = item.name.split('.').pop()?.toLowerCase() ?? ''
const baseName = item.name.includes('.') ? item.name.split('.').slice(0, -1).join('.') : item.name
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={index}
onPress={() => { setIndexDelFile(index); setModalFile(true) }}
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{baseName}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
</View>
)}
</View>
</View>
</ScrollView>
@@ -167,10 +186,10 @@ export default function CreateDiscussionDivision() {
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus"
onPress={() => { deleteFile(indexDelFile) }}
onPress={() => deleteFile(indexDelFile)}
/>
</View>
</DrawerBottom>
</SafeAreaView>
)
}
}

View File

@@ -1,3 +1,4 @@
import GuideOverlay from "@/components/GuideOverlay";
import ButtonTab from "@/components/buttonTab";
import ImageUser from "@/components/imageNew";
import InputSearch from "@/components/inputSearch";
@@ -7,6 +8,8 @@ import WrapTab from "@/components/wrapTab";
import { ConstEnv } from "@/constants/ConstEnv";
import Styles from "@/constants/Styles";
import { apiGetDiscussion, apiGetDivisionOneFeature } from "@/lib/api";
import { GUIDE_DIVISION_DISCUSSION } from "@/lib/guideSteps";
import { useGuide } from "@/lib/useGuide";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { AntDesign, Feather } from "@expo/vector-icons";
@@ -43,6 +46,7 @@ export default function DiscussionDivision() {
const [isMemberDivision, setIsMemberDivision] = useState(false)
const [isAdminDivision, setIsAdminDivision] = useState(false)
const entityUser = useSelector((state: any) => state.user)
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('division-discussion')
async function handleCheckMember() {
try {
@@ -94,10 +98,23 @@ export default function DiscussionDivision() {
const isOpen = (item: Props) => item.status === 1
const themed = {
background: { backgroundColor: colors.background },
card: { backgroundColor: colors.card, borderColor: colors.icon + '20' },
cardPressed: { backgroundColor: colors.icon + '10' },
title: { color: colors.text },
dimmed: { color: colors.dimmed },
statusOpen: { borderColor: '#10B981' as const },
statusClosed: { borderColor: colors.dimmed + '80' },
statusTextOpen: { color: '#10B981' as const },
statusTextClosed: { color: colors.dimmed },
}
return (
<View style={[Styles.flex1, { backgroundColor: colors.background }]}>
<View style={[Styles.flex1, themed.background]}>
<GuideOverlay visible={guideVisible} steps={GUIDE_DIVISION_DISCUSSION} onDismiss={dismissGuide} />
{((entityUser.role != "user" && entityUser.role != "coadmin") || isAdminDivision) && (
<View style={[Styles.ph15, { paddingTop: 12 }]}>
<View style={[Styles.ph15, Styles.discussionHeaderPadding]}>
<WrapTab>
<ButtonTab
active={status == "false" ? "false" : "true"}
@@ -120,13 +137,13 @@ export default function DiscussionDivision() {
</View>
)}
<View style={[Styles.flex1, Styles.ph15, { paddingTop: 8 }]}>
<View style={[Styles.flex1, Styles.ph15, Styles.discussionListPadding]}>
{loading ? (
arrSkeleton.map((_, i) => <SkeletonContent key={i} />)
) : data.length === 0 ? (
<View style={[Styles.contentItemCenter, Styles.mt30]}>
<Feather name="message-circle" size={42} color={colors.icon + '40'} />
<Text style={[Styles.mt10, { color: colors.dimmed, fontSize: 14 }]}>
<Text style={[Styles.mt10, Styles.discussionEmptyText, themed.dimmed]}>
Tidak ada diskusi
</Text>
</View>
@@ -146,18 +163,19 @@ export default function DiscussionDivision() {
onPress={() => router.push(`./discussion/${item.id}`)}
style={({ pressed }) => [
Styles.discussionCard,
{ backgroundColor: pressed ? colors.icon + '10' : colors.card, borderColor: colors.icon + '20' }
themed.card,
pressed && themed.cardPressed,
]}
>
<View style={[Styles.rowItemsCenter, Styles.mb08]}>
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
<View style={[Styles.flex1, { marginLeft: 10 }]}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]} numberOfLines={1}>
<View style={[Styles.flex1, Styles.discussionTitleCol]}>
<Text style={[Styles.textDefaultSemiBold, themed.title]} numberOfLines={1}>
{item.user_name}
</Text>
{status === "true" && (
<View style={[Styles.discussionStatusPill, { borderColor: isOpen(item) ? '#10B981' : colors.dimmed + '80' }]}>
<Text style={[Styles.discussionStatusText, { color: isOpen(item) ? '#10B981' : colors.dimmed }]}>
<View style={[Styles.discussionStatusPill, isOpen(item) ? themed.statusOpen : themed.statusClosed]}>
<Text style={[Styles.discussionStatusText, isOpen(item) ? themed.statusTextOpen : themed.statusTextClosed]}>
{isOpen(item) ? 'Buka' : 'Tutup'}
</Text>
</View>
@@ -166,7 +184,7 @@ export default function DiscussionDivision() {
</View>
{item.desc ? (
<Text style={[Styles.textMediumNormal, Styles.discussionCardIndent, { color: colors.dimmed, marginBottom: 10 }]} numberOfLines={2}>
<Text style={[Styles.textMediumNormal, Styles.discussionCardIndent, Styles.discussionDescMargin, themed.title]} numberOfLines={2}>
{item.desc}
</Text>
) : null}
@@ -174,11 +192,11 @@ export default function DiscussionDivision() {
<View style={[Styles.rowItemsCenter, Styles.rowSpaceBetween, Styles.discussionCardIndent]}>
<View style={Styles.rowItemsCenter}>
<Feather name="message-square" size={14} color={colors.dimmed} />
<Text style={[Styles.discussionCommentText, { color: colors.dimmed }]}>
<Text style={[Styles.discussionCommentText, themed.dimmed]}>
{item.total_komentar} Komentar
</Text>
</View>
<Text style={[Styles.discussionDateText, { color: colors.dimmed }]}>
<Text style={[Styles.discussionDateText, themed.dimmed]}>
{item.createdAt}
</Text>
</View>

View File

@@ -1,3 +1,4 @@
import GuideOverlay from "@/components/GuideOverlay";
import ModalConfirmation from "@/components/ModalConfirmation";
import AppHeader from "@/components/AppHeader";
import { ButtonHeader } from "@/components/buttonHeader";
@@ -22,6 +23,8 @@ import {
apiShareDocument,
} from "@/lib/api";
import { setUpdateDokumen } from "@/lib/dokumenUpdate";
import { GUIDE_DIVISION_DOCUMENT } from "@/lib/guideSteps";
import { useGuide } from "@/lib/useGuide";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import {
@@ -90,6 +93,7 @@ export default function DocumentDivision() {
const [isMemberDivision, setIsMemberDivision] = useState(false)
const entityUser = useSelector((state: any) => state.user)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('division-document')
const [bodyRename, setBodyRename] = useState({
id: "",
name: "",
@@ -415,6 +419,7 @@ export default function DocumentDivision() {
)
}}
/>
<GuideOverlay visible={guideVisible} steps={GUIDE_DIVISION_DOCUMENT} onDismiss={dismissGuide} />
<ModalLoading isVisible={loadingOpen} setVisible={setLoadingOpen} />
<ScrollView
refreshControl={

View File

@@ -1,4 +1,5 @@
import AppHeader from "@/components/AppHeader";
import GuideOverlay from "@/components/GuideOverlay";
import SectionCancel from "@/components/sectionCancel";
import SectionProgress from "@/components/sectionProgress";
import HeaderRightTaskDetail from "@/components/task/headerTaskDetail";
@@ -9,6 +10,8 @@ import SectionReportTask from "@/components/task/sectionReportTask";
import SectionTanggalTugasTask from "@/components/task/sectionTanggalTugasTask";
import Styles from "@/constants/Styles";
import { apiGetDivisionOneFeature, apiGetTaskOne } from "@/lib/api";
import { GUIDE_PROJECT_DETAIL } from "@/lib/guideSteps";
import { useGuide } from "@/lib/useGuide";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { router, Stack, useLocalSearchParams } from "expo-router";
@@ -23,6 +26,7 @@ type Props = {
reason: string
status: number
isActive: boolean
idGroup: string
}
export default function DetailTaskDivision() {
@@ -36,6 +40,7 @@ export default function DetailTaskDivision() {
const update = useSelector((state: any) => state.taskUpdate)
const [refreshing, setRefreshing] = useState(false)
const [isMemberDivision, setIsMemberDivision] = useState(false);
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('division-task-detail')
const [isAdminDivision, setIsAdminDivision] = useState(false);
const entityUser = useSelector((state: any) => state.user);
@@ -139,6 +144,7 @@ export default function DetailTaskDivision() {
)
}}
/>
<GuideOverlay visible={guideVisible} steps={GUIDE_PROJECT_DETAIL} onDismiss={dismissGuide} />
<ScrollView
refreshControl={
<RefreshControl
@@ -154,7 +160,7 @@ export default function DetailTaskDivision() {
}
<SectionProgress progress={progress} doneCount={taskStats?.done} totalCount={taskStats?.total} />
<SectionReportTask refreshing={refreshing} />
<SectionTanggalTugasTask refreshing={refreshing} isMemberDivision={isMemberDivision} isAdminDivision={isAdminDivision} status={data?.status} />
<SectionTanggalTugasTask refreshing={refreshing} isMemberDivision={isMemberDivision} isAdminDivision={isAdminDivision} status={data?.status} idGroup={data?.idGroup ?? ''} />
<SectionFileTask refreshing={refreshing} isMemberDivision={isMemberDivision} />
<SectionLinkTask refreshing={refreshing} isMemberDivision={isMemberDivision} />
<SectionMemberTask refreshing={refreshing} isAdminDivision={isAdminDivision} />

View File

@@ -251,7 +251,7 @@ export default function TugasFileScreen() {
disabled={loadingUpload}
/>
<ButtonSelect
value="Pilih dari File Proyek"
value="Pilih dari File Kegiatan ini"
onPress={() => {
setSelectedProjectFiles([]);
setPickerModal(true);

View File

@@ -169,7 +169,7 @@ export default function AddMemberDivision() {
return (
<Pressable
key={index}
style={[Styles.itemSelectModal]}
style={[Styles.itemSelectModal, { borderBottomColor: colors.icon + '20' }]}
onPress={() => {
!found && onChoose(item.id, item.name, item.img)
}}

View File

@@ -1,20 +1,17 @@
import ModalConfirmation from "@/components/ModalConfirmation"
import AppHeader from "@/components/AppHeader"
import BorderBottomItem from "@/components/borderBottomItem"
import HeaderRightDivisionInfo from "@/components/division/headerDivisionInfo"
import DrawerBottom from "@/components/drawerBottom"
import HeaderRightDivisionInfo from "@/components/division/headerDivisionInfo"
import ImageUser from "@/components/imageNew"
import MenuItemRow from "@/components/menuItemRow"
import ModalConfirmation from "@/components/ModalConfirmation"
import SectionCancel from "@/components/sectionCancel"
import Skeleton from "@/components/skeleton"
import SkeletonTwoItem from "@/components/skeletonTwoItem"
import Text from "@/components/Text"
import { ColorsStatus } from "@/constants/ColorsStatus"
import { ConstEnv } from "@/constants/ConstEnv"
import Styles from "@/constants/Styles"
import { apiDeleteMemberDivision, apiGetDivisionOneDetail, apiGetDivisionOneFeature, apiUpdateStatusAdminDivision } from "@/lib/api"
import { useAuthSession } from "@/providers/AuthProvider"
import { useTheme } from "@/providers/ThemeProvider"
import { Feather, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons"
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons"
import { router, Stack, useLocalSearchParams } from "expo-router"
import { useEffect, useState } from "react"
import { Pressable, RefreshControl, SafeAreaView, ScrollView, View } from "react-native"
@@ -50,8 +47,8 @@ export default function InformationDivision() {
const [dataMember, setDataMember] = useState<PropsMember[]>([])
const [refresh, setRefresh] = useState(false)
const update = useSelector((state: any) => state.divisionUpdate)
const arrSkeleton = Array.from({ length: 5 }, (_, index) => index)
const [loading, setLoading] = useState(true)
const SKELETON_COUNT = 5
const [isMemberDivision, setIsMemberDivision] = useState(false)
const [isAdminDivision, setIsAdminDivision] = useState(false)
const [dataMemberChoose, setDataMemberChoose] = useState({
@@ -186,109 +183,123 @@ export default function InformationDivision() {
}}
/>
<ScrollView
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.icon}
/>
}
showsVerticalScrollIndicator={false}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} tintColor={colors.icon} />}
style={[Styles.h100, { backgroundColor: colors.background }]}
>
<View style={[Styles.p15]}>
{
dataDetail?.isActive == false && (
<SectionCancel title={'Divisi nonaktif'} />
)
}
<View style={[Styles.mb15]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mb05]}>Deskripsi Divisi</Text>
<View style={[Styles.wrapPaper, Styles.noShadow, { backgroundColor: colors.card, borderWidth: 1, borderColor: colors.icon + '20' }]}>
{loading ?
arrSkeleton.map((item, index) => {
<View style={[Styles.p15, Styles.mb100]}>
{dataDetail?.isActive === false && <SectionCancel title="Divisi nonaktif" />}
{/* Deskripsi */}
<View style={[Styles.wrapPaper, Styles.sectionCard, Styles.noShadow, Styles.mb15,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<View style={[Styles.sectionActionRow, { marginBottom: 12 }]}>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.dimmed + '18' }]}>
<MaterialIcons name="info-outline" size={18} color={colors.dimmed} />
</View>
<Text style={[Styles.textDefaultSemiBold, Styles.flex1, { color: colors.text }]}>Deskripsi</Text>
</View>
{loading
? Array.from({ length: 3 }).map((_, i) => (
<View key={i} style={{ height: 13, borderRadius: 6, marginBottom: 8, backgroundColor: colors.icon + '20', width: i === 2 ? '60%' : '100%' }} />
))
: <Text style={[Styles.textDefault, { color: colors.text, lineHeight: 22 }]}>{dataDetail?.desc}</Text>
}
</View>
{/* Tombol tambah anggota */}
{((entityUser.role !== "user" && entityUser.role !== "coadmin") || isAdminDivision) && dataDetail?.isActive && (
<View style={[Styles.wrapPaper, Styles.sectionCard, Styles.mb15,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={() => router.push(`/division/${id}/add-member`)}
style={Styles.sectionActionRow}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.icon + '18' }]}>
<MaterialCommunityIcons name="account-plus-outline" size={18} color={colors.icon} />
</View>
<Text style={[Styles.textDefaultSemiBold, Styles.flex1, { color: colors.text }]}>Tambah Anggota</Text>
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
</View>
)}
{/* Daftar anggota */}
<View style={[Styles.wrapPaper, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18', padding: 0, overflow: 'hidden' }]}>
{/* Header */}
<View style={[Styles.sectionActionRow, { padding: 16, borderBottomWidth: 1, borderBottomColor: colors.icon + '14' }]}>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.dimmed + '18' }]}>
<MaterialIcons name="people" size={18} color={colors.dimmed} />
</View>
<Text style={[Styles.textDefaultSemiBold, Styles.flex1, { color: colors.text }]}>Anggota</Text>
{!loading && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>{dataMember.length} anggota</Text>
)}
</View>
{loading
? Array.from({ length: SKELETON_COUNT }).map((_, i) => (
<View key={i} style={[Styles.rowItemsCenter, Styles.ph15,
{ paddingVertical: 14, gap: 14, borderBottomWidth: i < SKELETON_COUNT - 1 ? 1 : 0, borderBottomColor: colors.icon + '14' }]}>
<View style={[Styles.userProfileExtraSmall, { backgroundColor: colors.icon + '20', borderRadius: 100 }]} />
<View style={{ height: 13, borderRadius: 6, flex: 1, backgroundColor: colors.icon + '20', maxWidth: 140 + (i % 3) * 30 }} />
</View>
))
: dataMember.length === 0
? (
<View style={[Styles.contentItemCenter, { paddingVertical: 40 }]}>
<MaterialIcons name="people-outline" size={34} color={colors.icon + '50'} />
<Text style={[Styles.textMediumNormal, Styles.mt10, { color: colors.dimmed }]}>Belum ada anggota</Text>
</View>
)
: dataMember.map((item, index) => {
const canPress = dataDetail?.isActive && (isAdminDivision || (entityUser.role !== "user" && entityUser.role !== "coadmin"))
return (
<Skeleton key={index} width={100} height={10} widthType="percent" borderRadius={10} />
<Pressable
key={index}
onPress={() => canPress && handleChooseMember(item)}
style={({ pressed }) => [
Styles.rowItemsCenter, Styles.ph15,
{
paddingVertical: 13, gap: 14,
borderBottomWidth: index < dataMember.length - 1 ? 1 : 0,
borderBottomColor: colors.icon + '14',
backgroundColor: pressed && canPress ? colors.icon + '0E' : 'transparent',
},
]}
>
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
<Text style={[Styles.textDefault, Styles.flex1, { color: colors.text }]} numberOfLines={1}>
{item.name}
</Text>
<Text style={[Styles.textMediumNormal, { color: item.isAdmin ? colors.tabActive : colors.dimmed }]}>
{item.isAdmin ? 'Admin' : 'Anggota'}
</Text>
{canPress && <MaterialCommunityIcons name="chevron-right" size={18} color={colors.icon + '60'} />}
</Pressable>
)
})
:
<Text>{dataDetail?.desc}</Text>
}
</View>
}
</View>
<View style={[Styles.mb15]}>
<Text style={[Styles.textDefault, Styles.mv05]}>{dataMember.length} Anggota</Text>
<View style={[Styles.wrapPaper, Styles.noShadow, { backgroundColor: colors.card, borderWidth: 1, borderColor: colors.icon + '20' }]}>
{
((entityUser.role != "user" && entityUser.role != "coadmin") || isAdminDivision) &&
dataDetail?.isActive && (
<BorderBottomItem
onPress={() => { router.push(`/division/${id}/add-member`) }}
borderType="none"
icon={
<View style={[Styles.iconContent]}>
<Feather name="user-plus" size={25} color={'black'} />
</View>
}
title="Tambah Anggota"
/>
)
}
{
loading ?
arrSkeleton.map((item, index) => {
return (
<SkeletonTwoItem key={index} />
)
})
:
dataMember.map((item, index) => {
return (
<BorderBottomItem
key={index}
borderType="bottom"
onPress={() => { dataDetail?.isActive && (isAdminDivision || (entityUser.role != "user" && entityUser.role != "coadmin")) && handleChooseMember(item) }}
icon={
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="sm" />
}
title={item.name}
rightTopInfo={item.isAdmin ? "Admin" : "Anggota"}
/>
)
})
}
</View>
</View>
</View>
</ScrollView>
<DrawerBottom animation="slide" isVisible={isModal} setVisible={setModal} title={dataMemberChoose.name}>
<View>
<Pressable style={[Styles.wrapItemBorderBottom]} onPress={() => { handleMemberAdmin() }}>
<View style={[Styles.rowItemsCenter]}>
<View style={[Styles.iconContent]}>
<MaterialIcons name="verified-user" size={25} color={'black'} />
</View>
<View style={[Styles.rowSpaceBetween, { width: '88%' }]}>
<View style={[Styles.ml10]}>
<Text style={[Styles.textDefault]}>{dataMemberChoose.isAdmin ? 'Memberhentikan sebagai admin' : 'Jadikan admin'}</Text>
</View>
</View>
</View>
</Pressable>
<Pressable style={[Styles.wrapItemBorderBottom]} onPress={() => { handleMemberOut() }}>
<View style={[Styles.rowItemsCenter]}>
<View style={[Styles.iconContent, ColorsStatus.info]}>
<MaterialCommunityIcons name="close-circle" size={25} color={colors.primary} />
</View>
<View style={[Styles.rowSpaceBetween, { width: '88%' }]}>
<View style={[Styles.ml10]}>
<Text style={[Styles.textDefault]}>Keluarkan dari divisi</Text>
</View>
</View>
</View>
</Pressable>
<View style={Styles.rowItemsCenter}>
<MenuItemRow
icon={<MaterialIcons name="verified-user" color={colors.text} size={25} />}
title={dataMemberChoose.isAdmin ? 'Berhentikan admin' : 'Jadikan admin'}
onPress={handleMemberAdmin}
/>
<MenuItemRow
icon={<MaterialCommunityIcons name="account-remove" color={colors.text} size={25} />}
title="Keluarkan"
onPress={handleMemberOut}
/>
</View>
</DrawerBottom>

View File

@@ -117,7 +117,7 @@ export default function CreateDivisionAddAdmin() {
return (
<Pressable
key={index}
style={[Styles.itemSelectModal]}
style={[Styles.itemSelectModal, { borderBottomColor: colors.icon + '20' }]}
onPress={() => {
!found && onChoose(item.idUser)
}}

View File

@@ -121,7 +121,7 @@ export default function CreateDivisionAddMember() {
return (
<Pressable
key={index}
style={[Styles.itemSelectModal]}
style={[Styles.itemSelectModal, { borderBottomColor: colors.icon + '20' }]}
onPress={() => {
!found && onChoose(item.id, item.name, item.img)
}}

View File

@@ -206,7 +206,7 @@ export default function ListDivision() {
</Pressable>
</View>
{(entityUser.role == "supadmin" || entityUser.role == "developer") && (
<View style={[Styles.mv05, Styles.rowOnly]}>
<View style={[Styles.mt10, Styles.rowOnly]}>
<Text>Filter :</Text>
<LabelStatus size="small" category="secondary" text={nameGroup} style={[Styles.mh05]} />
</View>

View File

@@ -23,13 +23,12 @@ export default function Feature() {
}}
/>
<View style={[Styles.p15]}>
<View style={[Styles.rowSpaceBetween, Styles.mb15]}>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', rowGap: 20 }}>
<ButtonFiturMenu icon={<Feather name="users" size={30} color={colors.icon} />} text="Divisi" onPress={() => { router.push('/division?active=true') }} />
<ButtonFiturMenu icon={<Feather name="bar-chart" size={30} color={colors.icon} />} text="Kegiatan" onPress={() => { router.push('/project?status=0') }} />
<ButtonFiturMenu icon={<Ionicons name="megaphone-outline" size={30} color={colors.icon} />} text="Pengumuman" onPress={() => { router.push('/announcement') }} />
<ButtonFiturMenu icon={<Ionicons name="chatbubbles-outline" size={30} color={colors.icon} />} text="Diskusi" onPress={() => { router.push('/discussion?active=true') }} />
</View>
<View style={[Styles.rowSpaceBetween, Styles.mb15, (entityUser.role == 'cosupadmin' ? Styles.w70 : entityUser.role == 'supadmin' || entityUser.role == 'developer' ? Styles.w100 : Styles.w40)]}>
<ButtonFiturMenu icon={<Feather name="calendar" size={30} color={colors.icon} />} text="Kalender" onPress={() => { router.push('/village-calendar') }} />
<ButtonFiturMenu icon={<MaterialCommunityIcons name="account-group-outline" size={30} color={colors.icon} />} text="Anggota" onPress={() => { router.push('/member') }} />
<ButtonFiturMenu icon={<MaterialCommunityIcons name="account-tie-outline" size={30} color={colors.icon} />} text="Jabatan" onPress={() => { router.push('/position') }} />
{
@@ -39,7 +38,6 @@ export default function Feature() {
(entityUser.role == "supadmin" || entityUser.role == "developer") &&
<>
<ButtonFiturMenu icon={<Ionicons name="bookmarks-outline" size={30} color={colors.icon} />} text="Lembaga Desa" onPress={() => { router.push('/group') }} />
{/* <ButtonFiturMenu icon={<Ionicons name="color-palette-sharp" size={30} color={colors.icon} />} text="Tema" onPress={() => { }} /> */}
<ButtonFiturMenu icon={<Ionicons name="images-outline" size={30} color={colors.icon} />} text="Banner" onPress={() => { router.push('/banner') }} />
</>
}

View File

@@ -1,3 +1,4 @@
import GuideOverlay from "@/components/GuideOverlay";
import ModalConfirmation from "@/components/ModalConfirmation";
import BorderBottomItem from "@/components/borderBottomItem";
import { ButtonForm } from "@/components/buttonForm";
@@ -12,6 +13,8 @@ import WrapTab from "@/components/wrapTab";
import Styles from "@/constants/Styles";
import { apiDeleteGroup, apiEditGroup, apiGetGroup } from "@/lib/api";
import { setUpdateGroup } from "@/lib/groupSlice";
import { GUIDE_GROUP } from "@/lib/guideSteps";
import { useGuide } from "@/lib/useGuide";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { AntDesign, Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
@@ -41,6 +44,7 @@ export default function Index() {
const [titleChoose, setTitleChoose] = useState('')
const queryClient = useQueryClient()
const [refreshing, setRefreshing] = useState(false)
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('group')
const dispatch = useDispatch()
const update = useSelector((state: any) => state.groupUpdate)
@@ -136,6 +140,7 @@ export default function Index() {
return (
<View style={[Styles.p15, { flex: 1, backgroundColor: colors.background }]}>
<GuideOverlay visible={guideVisible} steps={GUIDE_GROUP} onDismiss={dismissGuide} />
<View style={[Styles.mb10]}>
<WrapTab>
<ButtonTab

View File

@@ -1,3 +1,4 @@
import GuideOverlay from "@/components/GuideOverlay";
import BorderBottomItem from "@/components/borderBottomItem";
import ButtonTab from "@/components/buttonTab";
import ImageUser from "@/components/imageNew";
@@ -9,6 +10,8 @@ import WrapTab from "@/components/wrapTab";
import { ConstEnv } from "@/constants/ConstEnv";
import Styles from "@/constants/Styles";
import { apiGetUser } from "@/lib/api";
import { GUIDE_MEMBER } from "@/lib/guideSteps";
import { useGuide } from "@/lib/useGuide";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { AntDesign, Feather } from "@expo/vector-icons";
@@ -42,6 +45,7 @@ export default function Index() {
const queryClient = useQueryClient()
const [status, setStatus] = useState<'true' | 'false'>(active == 'false' ? 'false' : 'true')
const [refreshing, setRefreshing] = useState(false)
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('member')
// TanStack Query for Members with Infinite Scroll
const {
@@ -117,6 +121,7 @@ export default function Index() {
return (
<View style={[Styles.p15, { flex: 1, backgroundColor: colors.background }]}>
<GuideOverlay visible={guideVisible} steps={GUIDE_MEMBER} onDismiss={dismissGuide} />
<View>
<WrapTab>
<ButtonTab
@@ -137,7 +142,7 @@ export default function Index() {
<InputSearch onChange={setSearch} />
{
(entityUser.role == "supadmin" || entityUser.role == "developer") &&
<View style={[Styles.mv05, Styles.rowOnly]}>
<View style={[Styles.mt10, Styles.rowOnly]}>
<Text>Filter :</Text>
<LabelStatus size="small" category="secondary" text={nameGroup} style={[Styles.mh05]} />
</View>

View File

@@ -9,12 +9,11 @@ import { pushToPage } from "@/lib/pushToPage";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Feather } from "@expo/vector-icons";
import { router, Stack } from "expo-router";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo } from "react";
import { router, Stack } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import { FlatList, Pressable, RefreshControl, SafeAreaView, View } from "react-native";
import { useDispatch, useSelector } from "react-redux";
import { useState } from "react";
type Props = {
id: string
@@ -79,6 +78,15 @@ export default function Notification() {
}, [data])
const listData = useMemo<ListRow[]>(() => {
const BULAN: Record<string, number> = {
'JAN': 0, 'FEB': 1, 'MAR': 2, 'APR': 3, 'MEI': 4, 'JUN': 5,
'JUL': 6, 'AGU': 7, 'SEP': 8, 'OKT': 9, 'NOV': 10, 'DES': 11,
}
const parseDate = (str: string) => {
const [d, m, y] = str.split(' ')
return new Date(Number(y), BULAN[m] ?? 0, Number(d)).getTime()
}
const groups: Record<string, Props[]> = {}
const dateOrder: string[] = []
@@ -90,6 +98,8 @@ export default function Notification() {
groups[item.createdAt].push(item)
})
dateOrder.sort((a, b) => parseDate(b) - parseDate(a))
const result: ListRow[] = []
dateOrder.forEach((date) => {
result.push({ _type: 'header', date })
@@ -137,6 +147,17 @@ export default function Notification() {
}
}
async function handleMarkOneRead(id: string) {
try {
const hasil = await decryptToken(String(token?.current))
await apiReadOneNotification({ user: hasil, id: id })
await queryClient.invalidateQueries({ queryKey: ['notifications'] })
dispatch(setUpdateNotification(!updateNotification))
} catch (error) {
console.error(error)
}
}
return (
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
@@ -153,7 +174,7 @@ export default function Notification() {
disabled={markingAll}
style={{ opacity: markingAll ? 0.5 : 1, padding: 4 }}
>
<Feather name="check-square" size={22} color="white" />
<Feather name="check-square" size={20} color="white" />
</Pressable>
) : undefined
}
@@ -175,7 +196,8 @@ export default function Notification() {
onCancel={() => setShowConfirm(false)}
/>
<View style={[Styles.flex1, Styles.ph15, { paddingTop: 10 }]}>
<View style={[Styles.flex1, Styles.ph15, Styles.notifContainer]}>
{isLoading ? (
[0, 1, 2, 3, 4].map((_, i) => <SkeletonTwoItem key={i} />)
) : flatData.length === 0 ? (
@@ -204,11 +226,11 @@ export default function Notification() {
renderItem={({ item }) => {
if (item._type === 'header') {
return (
<View style={[Styles.rowItemsCenter, { marginTop: 16, marginBottom: 8 }]}>
<Text style={{ fontSize: 11, fontWeight: '600', color: colors.dimmed, letterSpacing: 0.6, textTransform: 'uppercase' }}>
<View style={[Styles.rowItemsCenter, Styles.notifHeaderRow]}>
<Text style={[Styles.notifDateText, { color: colors.dimmed }]}>
{item.date}
</Text>
<View style={{ flex: 1, height: 1, backgroundColor: colors.icon + '20', marginLeft: 8 }} />
<View style={[Styles.notifDateSeparator, { backgroundColor: colors.icon + '20' }]} />
</View>
)
}
@@ -218,37 +240,20 @@ export default function Notification() {
return (
<Pressable
onPress={() => handleReadNotification(item.id, item.category, item.idContent)}
style={({ pressed }) => [{
flexDirection: 'row',
alignItems: 'center',
borderRadius: 10,
borderWidth: 1,
style={({ pressed }) => [Styles.notifItemRow, {
borderColor: colors.icon + '20',
backgroundColor: pressed
? colors.icon + '10'
: item.isRead
? colors.icon + '10'
: colors.card,
paddingHorizontal: 12,
paddingVertical: 10,
marginBottom: 6,
}]}
>
{/* Colored icon */}
<View style={{
width: 42,
height: 42,
borderRadius: 21,
backgroundColor: color + '20',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}>
<View style={[Styles.notifIconContainer, { backgroundColor: color + '20' }]}>
<Feather name={icon} size={20} color={color} />
</View>
{/* Content */}
<View style={[Styles.flex1, { marginLeft: 10 }]}>
<View style={[Styles.flex1, Styles.notifContent]}>
<View style={[Styles.rowSpaceBetween, Styles.itemsCenter]}>
<View style={[Styles.flex1, Styles.mr10]}>
<Text
@@ -258,6 +263,20 @@ export default function Notification() {
{item.title}
</Text>
</View>
{!item.isRead && (
<Pressable
onPress={(e) => {
e.stopPropagation()
handleMarkOneRead(item.id)
}}
hitSlop={8}
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, flexShrink: 0 })}
>
<Text style={Styles.notifMarkReadText}>
Tandai dibaca
</Text>
</Pressable>
)}
</View>
<Text
style={[Styles.textMediumNormal, { color: item.isRead ? colors.dimmed : colors.text, opacity: item.isRead ? 0.7 : 1 }]}

View File

@@ -1,3 +1,4 @@
import GuideOverlay from "@/components/GuideOverlay";
import BorderBottomItem from "@/components/borderBottomItem";
import { ButtonForm } from "@/components/buttonForm";
import ButtonTab from "@/components/buttonTab";
@@ -13,6 +14,8 @@ import WrapTab from "@/components/wrapTab";
import Styles from "@/constants/Styles";
import { apiDeletePosition, apiEditPosition, apiGetPosition } from "@/lib/api";
import { setUpdatePosition } from "@/lib/positionSlice";
import { GUIDE_POSITION } from "@/lib/guideSteps";
import { useGuide } from "@/lib/useGuide";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { AntDesign, Feather, MaterialCommunityIcons } from "@expo/vector-icons";
@@ -48,6 +51,7 @@ export default function Index() {
const queryClient = useQueryClient()
const [refreshing, setRefreshing] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('position')
const dispatch = useDispatch()
const update = useSelector((state: any) => state.positionUpdate)
@@ -160,6 +164,7 @@ export default function Index() {
return (
<View style={[Styles.p15, Styles.flex1, { backgroundColor: colors.background }]}>
<GuideOverlay visible={guideVisible} steps={GUIDE_POSITION} onDismiss={dismissGuide} />
<View>
<WrapTab>
<ButtonTab
@@ -180,7 +185,7 @@ export default function Index() {
<InputSearch onChange={setSearch} />
{
(entityUser.role == "supadmin" || entityUser.role == "developer") &&
<View style={[Styles.mv05, Styles.rowOnly]}>
<View style={[Styles.mt10, Styles.rowOnly]}>
<Text>Filter :</Text>
<LabelStatus size="small" category="secondary" text={nameGroup} style={[Styles.mh05]} />
</View>

View File

@@ -1,4 +1,5 @@
import AppHeader from "@/components/AppHeader";
import GuideOverlay from "@/components/GuideOverlay";
import HeaderRightProjectDetail from "@/components/project/headerProjectDetail";
import SectionFile from "@/components/project/sectionFile";
import SectionLink from "@/components/project/sectionLink";
@@ -9,6 +10,8 @@ import SectionCancel from "@/components/sectionCancel";
import SectionProgress from "@/components/sectionProgress";
import Styles from "@/constants/Styles";
import { apiGetProjectOne } from "@/lib/api";
import { GUIDE_PROJECT_DETAIL } from "@/lib/guideSteps";
import { useGuide } from "@/lib/useGuide";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { router, Stack, useLocalSearchParams } from "expo-router";
@@ -43,6 +46,7 @@ export default function DetailProject() {
const [isMember, setIsMember] = useState(false)
const entityUser = useSelector((state: any) => state.user)
const [refreshing, setRefreshing] = useState(false)
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('project-detail')
async function handleLoad(cat: 'data' | 'progress') {
try {
@@ -129,6 +133,7 @@ export default function DetailProject() {
)
}}
/>
<GuideOverlay visible={guideVisible} steps={GUIDE_PROJECT_DETAIL} onDismiss={dismissGuide} />
<ScrollView
style={[Styles.h100, { backgroundColor: colors.background }]}
refreshControl={
@@ -145,7 +150,7 @@ export default function DetailProject() {
}
<SectionProgress progress={progress} doneCount={taskStats?.done} totalCount={taskStats?.total} />
<SectionReportProject refreshing={refreshing} />
<SectionTanggalTugasProject status={data?.status} member={isMember} refreshing={refreshing} />
<SectionTanggalTugasProject status={data?.status} member={isMember} refreshing={refreshing} idGroup={data?.idGroup ?? ''} />
<SectionFile status={data?.status} member={isMember} refreshing={refreshing} />
<SectionLink status={data?.status} member={isMember} refreshing={refreshing} />
<SectionMember status={data?.status} refreshing={refreshing} />

View File

@@ -246,7 +246,7 @@ export default function ProjectTugasFileScreen() {
disabled={loadingUpload}
/>
<ButtonSelect
value="Pilih dari File Proyek"
value="Pilih dari File Kegiatan ini"
onPress={() => {
setSelectedProjectFiles([]);
setPickerModal(true);

View File

@@ -0,0 +1,304 @@
import AppHeader from "@/components/AppHeader";
import ItemDateCalendar from "@/components/calendar/itemDateCalendar";
import GuideOverlay from "@/components/GuideOverlay";
import Skeleton from "@/components/skeleton";
import Text from "@/components/Text";
import Styles from "@/constants/Styles";
import { apiGetVillageCalendarByDate, apiGetVillageCalendarIndicator } from "@/lib/api";
import { GUIDE_VILLAGE_CALENDAR } from "@/lib/guideSteps";
import { useGuide } from "@/lib/useGuide";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Feather } from "@expo/vector-icons";
import { router, Stack } from "expo-router";
import 'intl';
import 'intl/locale-data/jsonp/id';
import moment from "moment";
import "moment/locale/id";
import { useEffect, useState } from "react";
import { Pressable, RefreshControl, SafeAreaView, ScrollView, View } from "react-native";
import Datepicker, {
CalendarComponents,
CalendarDay
} from "react-native-ui-datepicker";
moment.locale('id');
type EventItem = {
id: string;
type: 'calendar' | 'task';
title: string;
desc: string;
dateStart: string;
dateEnd: string;
timeStart: string | null;
timeEnd: string | null;
divisionName: string | null;
projectName: string | null;
idDivision: string | null;
idRef: string;
};
const TYPE_COLORS = {
calendar: {
light: { bg: '#A9B5DF', stick: '#7886C7' },
dark: { bg: '#2D2B5E', stick: '#7886C7' },
},
task: {
light: { bg: '#D6E6F2', stick: '#94B4C1' },
dark: { bg: '#1C3347', stick: '#94B4C1' },
},
};
function VillageEventItem({ item, onPress }: { item: EventItem; onPress: () => void }) {
const { activeTheme, colors } = useTheme();
const palette = TYPE_COLORS[item.type][activeTheme === 'dark' ? 'dark' : 'light'];
return (
<Pressable
style={[Styles.itemEvent, { backgroundColor: palette.bg }]}
onPress={onPress}
>
<View style={[Styles.dividerEvent, { backgroundColor: palette.stick }]} />
<View style={[Styles.flex1]}>
{item.timeStart ? (
<Text style={[Styles.textMediumNormal, { color: activeTheme === 'dark' ? '#ffffff' : '#000000' }]}>
{item.timeStart} - {item.timeEnd}
</Text>
) : (
<Text style={[Styles.textMediumNormal, { color: activeTheme === 'dark' ? '#ffffff' : '#000000' }]}>
{moment(item.dateStart).format('D MMM YYYY')} {moment(item.dateEnd).format('D MMM YYYY')}
</Text>
)}
<Text numberOfLines={1} ellipsizeMode="tail" style={[Styles.textDefaultSemiBold, { color: colors.text }]}>
{item.title}
</Text>
<View style={[Styles.rowItemsCenter]}>
<View style={[Styles.villageEventBadge, { backgroundColor: palette.stick }]}>
<Text style={[Styles.textSmallSemiBold, { color: '#ffffff' }]}>
{item.type === 'calendar' ? 'Divisi' : 'Kegiatan'}
</Text>
</View>
<Text numberOfLines={1} ellipsizeMode="tail" style={[Styles.textMediumNormal, { color: activeTheme === 'dark' ? '#ffffff' : '#000000', flex: 1 }]}>
{item.type === 'calendar' ? item.divisionName : item.projectName}
</Text>
</View>
</View>
</Pressable>
);
}
export default function VillageCalendar() {
const { colors, activeTheme } = useTheme();
const { token, decryptToken } = useAuthSession();
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('village-calendar');
const [selected, setSelected] = useState<any>(new Date());
const [data, setData] = useState<EventItem[]>([]);
const [dataIndicator, setDataIndicator] = useState<{ calendar: string[], task: string[] }>({ calendar: [], task: [] });
const [month, setMonth] = useState<number>(new Date().getMonth());
const [loading, setLoading] = useState(true);
const [loadingBtn, setLoadingBtn] = useState(false);
const [refreshing, setRefreshing] = useState(false);
async function handleLoad(showLoading: boolean) {
try {
setLoading(showLoading);
const hasil = await decryptToken(String(token?.current));
const response = await apiGetVillageCalendarByDate({
user: hasil,
date: moment(selected).format("YYYY-MM-DD"),
});
setData(response.data);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
}
async function handleLoadIndicator() {
try {
setLoadingBtn(true);
const newDate = new Date(selected?.getFullYear(), month, 1);
const hasil = await decryptToken(String(token?.current));
const response = await apiGetVillageCalendarIndicator({
user: hasil,
date: moment(newDate).format("YYYY-MM-DD"),
});
setDataIndicator(response.data ?? { calendar: [], task: [] });
} catch (error) {
console.error(error);
} finally {
setTimeout(() => setLoadingBtn(false), 500);
}
}
useEffect(() => {
handleLoad(true);
}, [selected]);
useEffect(() => {
handleLoadIndicator();
}, [month]);
const handleRefresh = async () => {
setRefreshing(true);
handleLoad(false);
handleLoadIndicator();
await new Promise(resolve => setTimeout(resolve, 1500));
setRefreshing(false);
};
function handlePressEvent(item: EventItem) {
if (item.type === 'calendar') {
router.push(`/division/${item.idDivision}/calendar/${item.id}` as any);
} else {
router.push(`/project/${item.idRef}` as any);
}
}
const calendarEvents = data.filter(e => e.type === 'calendar');
const taskEvents = data.filter(e => e.type === 'task');
const components: CalendarComponents = {
Day: (day: CalendarDay) => {
const today = moment(String(day.date)).format("YYYY-MM-DD");
return (
<ItemDateCalendar
text={day.text}
isSelected={day.isSelected}
isSignCalendar={dataIndicator.calendar.includes(today)}
isSignTask={dataIndicator.task.includes(today)}
onPress={() => setSelected(new Date(today))}
/>
);
},
IconNext: (
<Pressable onPress={() => !loadingBtn && setMonth(month + 1)}>
<Feather name="chevron-right" size={20} color={loadingBtn ? 'gray' : colors.text} />
</Pressable>
),
IconPrev: (
<Pressable onPress={() => !loadingBtn && setMonth(month - 1)}>
<Feather name="chevron-left" size={20} color={loadingBtn ? 'gray' : colors.text} />
</Pressable>
),
};
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
<Stack.Screen
options={{
headerShown: true,
header: () => (
<AppHeader
title="Kalender"
showBack={true}
onPressLeft={() => router.back()}
/>
),
}}
/>
<GuideOverlay
visible={guideVisible}
steps={GUIDE_VILLAGE_CALENDAR}
onDismiss={dismissGuide}
/>
<ScrollView
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.icon}
/>
}
style={[Styles.h100]}
>
<View style={[Styles.p15]}>
{/* Calendar grid */}
<View style={[Styles.wrapPaper, Styles.p10, { backgroundColor: colors.card, borderColor: colors.background }]}>
<Datepicker
components={components}
mode="single"
date={selected}
month={month}
onMonthChange={(m) => setMonth(m)}
styles={{
selected: Styles.selectedDate,
month_label: { color: colors.text },
month_selector_label: { color: colors.text },
year_label: { color: colors.text },
year_selector_label: { color: colors.text },
day_label: { color: colors.text },
time_label: { color: colors.text },
weekday_label: { color: colors.text },
}}
/>
</View>
{/* Legend */}
<View style={[Styles.rowItemsCenter, Styles.villageEventLegendRow]}>
<View style={[Styles.rowItemsCenter, Styles.villageEventLegendItem]}>
<View style={[Styles.villageEventLegendDot, { backgroundColor: '#7886C7' }]} />
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Acara Divisi</Text>
</View>
<View style={[Styles.rowItemsCenter, Styles.villageEventLegendItem]}>
<View style={[Styles.villageEventLegendDot, { backgroundColor: '#94B4C1' }]} />
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Kegiatan</Text>
</View>
</View>
{/* Calendar events */}
<View style={[Styles.mb15, Styles.mt10]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mb05]}>Acara Divisi</Text>
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.background }]}>
{loading ? (
<>
<Skeleton width={100} height={70} borderRadius={10} widthType="percent" />
<Skeleton width={100} height={70} borderRadius={10} widthType="percent" />
</>
) : calendarEvents.length > 0 ? (
calendarEvents.map((item, index) => (
<VillageEventItem
key={index}
item={item}
onPress={() => handlePressEvent(item)}
/>
))
) : (
<Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>
Tidak ada acara
</Text>
)}
</View>
</View>
{/* Task events */}
<View style={[Styles.mb15]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mb05]}>Kegiatan</Text>
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.background }]}>
{loading ? (
<>
<Skeleton width={100} height={70} borderRadius={10} widthType="percent" />
<Skeleton width={100} height={70} borderRadius={10} widthType="percent" />
</>
) : taskEvents.length > 0 ? (
taskEvents.map((item, index) => (
<VillageEventItem
key={index}
item={item}
onPress={() => handlePressEvent(item)}
/>
))
) : (
<Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>
Tidak ada kegiatan
</Text>
)}
</View>
</View>
</View>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -46,6 +46,7 @@
"expo-modules-core": "^2.5.0",
"expo-notifications": "~0.31.3",
"expo-router": "~5.1.4",
"expo-secure-store": "~14.2.4",
"expo-sharing": "^13.1.5",
"expo-splash-screen": "~0.30.8",
"expo-status-bar": "~2.2.3",
@@ -1103,6 +1104,8 @@
"expo-router": ["expo-router@5.1.11", "", { "dependencies": { "@expo/metro-runtime": "5.0.5", "@expo/schema-utils": "^0.1.0", "@expo/server": "^0.6.3", "@radix-ui/react-slot": "1.2.0", "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/native": "^7.1.6", "@react-navigation/native-stack": "^7.3.10", "client-only": "^0.0.1", "invariant": "^2.2.4", "react-fast-compare": "^3.2.2", "react-native-is-edge-to-edge": "^1.1.6", "semver": "~7.6.3", "server-only": "^0.0.1", "shallowequal": "^1.1.0" }, "peerDependencies": { "@react-navigation/drawer": "^7.3.9", "expo": "*", "expo-constants": "*", "expo-linking": "*", "react-native-reanimated": "*", "react-native-safe-area-context": "*", "react-native-screens": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" }, "optionalPeers": ["@react-navigation/drawer", "react-native-reanimated", "react-server-dom-webpack"] }, "sha512-6YQGqQM2rviVSiU6++hrJDPMByHZ7Oiux4XmgoSaGdaHku5QOn9911f2puEUZh2H9ALKBipw5v3ZkrECBd6Zbw=="],
"expo-secure-store": ["expo-secure-store@14.2.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-ePaz4fnTitJJZjAiybaVYGfLWWyaEtepZC+vs9ZBMhQMfG5HUotIcVsDaSo3FnwpHmgwsLVPY2qFeryI6AtULw=="],
"expo-sharing": ["expo-sharing@13.1.5", "", { "peerDependencies": { "expo": "*" } }, "sha512-X/5sAEiWXL2kdoGE3NO5KmbfcmaCWuWVZXHu8OQef7Yig4ZgHFkGD11HKJ5KqDrDg+SRZe4ISd6MxE7vGUgm4w=="],
"expo-splash-screen": ["expo-splash-screen@0.30.10", "", { "dependencies": { "@expo/prebuild-config": "^9.0.10" }, "peerDependencies": { "expo": "*" } }, "sha512-Tt9va/sLENQDQYeOQ6cdLdGvTZ644KR3YG9aRlnpcs2/beYjOX1LHT510EGzVN9ljUTg+1ebEo5GGt2arYtPjw=="],

159
components/GuideOverlay.tsx Normal file
View File

@@ -0,0 +1,159 @@
import Text from '@/components/Text';
import Styles from '@/constants/Styles';
import { useTheme } from '@/providers/ThemeProvider';
import { Feather } from '@expo/vector-icons';
import React, { useEffect, useRef, useState } from 'react';
import {
Animated,
Dimensions,
Modal,
TouchableOpacity,
View,
} from 'react-native';
export type GuideStep = {
title: string;
description: string;
// posisi card: pixel absolut atau rasio layar (0.0-1.0), rasio lebih aman untuk layout dinamis
cardTop?: number;
cardTopRatio?: number;
// arrow menunjuk ke atas (elemen ada di atas card) atau ke bawah (elemen ada di bawah card)
arrowDirection?: 'up' | 'down' | 'none';
// offset horizontal arrow dari kiri card (0.0-1.0), default 0.5 = tengah
arrowOffset?: number;
};
type Props = {
visible: boolean;
steps: GuideStep[];
onDismiss: () => void;
};
const { height: SCREEN_H } = Dimensions.get('window');
const CARD_MARGIN = 24;
export default function GuideOverlay({ visible, steps, onDismiss }: Props) {
const { colors } = useTheme();
const [currentStep, setCurrentStep] = useState(0);
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(20)).current;
const step = steps[currentStep];
const isLast = currentStep === steps.length - 1;
const arrowDirection = step?.arrowDirection ?? 'none';
const arrowOffset = step?.arrowOffset ?? 0.5;
const cardTop = step?.cardTopRatio != null
? SCREEN_H * step.cardTopRatio
: (step?.cardTop ?? SCREEN_H * 0.35);
const cardPositionStyle = { top: cardTop };
useEffect(() => {
if (visible) {
setCurrentStep(0);
Animated.parallel([
Animated.timing(fadeAnim, { toValue: 1, duration: 300, useNativeDriver: true }),
Animated.timing(slideAnim, { toValue: 0, duration: 300, useNativeDriver: true }),
]).start();
} else {
fadeAnim.setValue(0);
slideAnim.setValue(20);
}
}, [visible]);
function animateStep(next: number) {
Animated.sequence([
Animated.timing(slideAnim, { toValue: 12, duration: 120, useNativeDriver: true }),
Animated.timing(slideAnim, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start();
setCurrentStep(next);
}
if (!visible) return null;
return (
<Modal transparent visible={visible} animationType="none" onRequestClose={onDismiss}>
<Animated.View style={[Styles.guideOverlay, { opacity: fadeAnim }]}>
<Animated.View
style={[
Styles.guideCard,
{ backgroundColor: colors.modalBackground, transform: [{ translateY: slideAnim }] },
cardPositionStyle,
]}
>
{/* Arrow atas — menunjuk ke elemen di atas card */}
{arrowDirection === 'up' && (
<View style={[Styles.guideArrowUp, {
left: `${arrowOffset * 100}%` as any,
borderBottomColor: colors.modalBackground,
}]} />
)}
{/* Header */}
<View style={[Styles.rowItemsCenter, Styles.rowSpaceBetween, Styles.mb10]}>
<View style={[Styles.guideBadge, { backgroundColor: colors.icon + '20' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.icon }]}>
{currentStep + 1} / {steps.length}
</Text>
</View>
<TouchableOpacity onPress={onDismiss} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Feather name="x" size={18} color={colors.dimmed} />
</TouchableOpacity>
</View>
{/* Content */}
<Text style={[Styles.textDefaultSemiBold, Styles.mb05, { color: colors.text }]}>
{step.title}
</Text>
<Text style={[Styles.textMediumNormal, { color: colors.text, lineHeight: 20 }]}>
{step.description}
</Text>
{/* Dot indicator */}
<View style={[Styles.rowItemsCenter, Styles.guideDotRow]}>
{steps.map((_, i) => (
<View
key={i}
style={[Styles.guideDot, {
backgroundColor: i === currentStep ? colors.icon : colors.icon + '30',
}]}
/>
))}
</View>
{/* Footer */}
<View style={[Styles.rowItemsCenter, Styles.rowSpaceBetween, Styles.mt10]}>
{currentStep > 0 ? (
<TouchableOpacity onPress={() => animateStep(currentStep - 1)} style={Styles.guideButtonSecondary}>
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Kembali</Text>
</TouchableOpacity>
) : (
<TouchableOpacity onPress={onDismiss}>
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Lewati</Text>
</TouchableOpacity>
)}
<TouchableOpacity
onPress={() => isLast ? onDismiss() : animateStep(currentStep + 1)}
style={[Styles.guideButtonPrimary, { backgroundColor: colors.icon }]}
>
<Text style={[Styles.textMediumSemiBold, { color: '#ffffff' }]}>
{isLast ? 'Selesai' : 'Lanjut'}
</Text>
{!isLast && <Feather name="arrow-right" size={14} color="#ffffff" style={{ marginLeft: 4 }} />}
</TouchableOpacity>
</View>
{/* Arrow bawah — menunjuk ke elemen di bawah card */}
{arrowDirection === 'down' && (
<View style={[Styles.guideArrowDown, {
left: `${arrowOffset * 100}%` as any,
borderTopColor: colors.modalBackground,
}]} />
)}
</Animated.View>
</Animated.View>
</Modal>
);
}

View File

@@ -33,13 +33,7 @@ function ApprovalStatusBadge({ status }: { status: number }) {
: { label: 'Menunggu', color: '#FFA94D' }
return (
<View style={{
backgroundColor: config.color + '20',
borderRadius: 20,
paddingHorizontal: 10,
paddingVertical: 3,
alignSelf: 'flex-start',
}}>
<View style={[Styles.approvalBadge, { backgroundColor: config.color + '20' }]}>
<Text style={[Styles.textSmallSemiBold, { color: config.color }]}>
{config.label}
</Text>
@@ -79,16 +73,10 @@ export default function ModalRiwayatApproval({ isVisible, setVisible, data, load
data.map((item, index) => (
<View
key={item.id}
style={{
borderWidth: 1,
borderColor: colors.icon + '30',
borderRadius: 10,
padding: 12,
marginBottom: 10,
}}
style={[Styles.approvalItem, { borderColor: colors.icon + '30' }]}
>
{/* Status + tanggal */}
<View style={[Styles.rowItemsCenter, { justifyContent: 'space-between', marginBottom: 8 }]}>
<View style={[Styles.rowItemsCenter, Styles.approvalItemHeader]}>
<ApprovalStatusBadge status={item.status} />
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>
{item.createdAt}
@@ -97,15 +85,15 @@ export default function ModalRiwayatApproval({ isVisible, setVisible, data, load
{/* Pengaju */}
<View style={[Styles.rowItemsCenter, Styles.mb05]}>
<MaterialCommunityIcons name="account-arrow-up-outline" size={15} color={colors.dimmed} style={{ marginRight: 6 }} />
<Text style={[Styles.textMediumSemiBold, { color: colors.dimmed }]}>Diajukan Oleh: </Text>
<MaterialCommunityIcons name="account-arrow-up-outline" size={15} color={colors.text} style={Styles.approvalIconMr} />
<Text style={[Styles.textMediumSemiBold]}>Diajukan Oleh: </Text>
<Text style={[Styles.textMediumNormal]}>{item.submitter.name}</Text>
</View>
{/* Approver */}
<View style={[Styles.rowItemsCenter, item.note ? Styles.mb05 : {}]}>
<MaterialCommunityIcons name="account-check-outline" size={15} color={colors.dimmed} style={{ marginRight: 6 }} />
<Text style={[Styles.textMediumSemiBold, { color: colors.dimmed }]}>Disetujui Oleh: </Text>
<MaterialCommunityIcons name="account-check-outline" size={15} color={colors.text} style={Styles.approvalIconMr} />
<Text style={[Styles.textMediumSemiBold]}>Disetujui Oleh: </Text>
<Text style={[Styles.textMediumNormal]}>
{item.approver?.name ?? '-'}
</Text>
@@ -113,16 +101,11 @@ export default function ModalRiwayatApproval({ isVisible, setVisible, data, load
{/* Catatan penolakan */}
{item.note && (
<View style={{
backgroundColor: colors.error + '12',
borderRadius: 8,
padding: 8,
marginTop: 4,
}}>
<Text style={[Styles.textSmallSemiBold, { color: colors.error, marginBottom: 2 }]}>
<View style={[Styles.approvalNoteBox, { backgroundColor: colors.icon + '12' }]}>
<Text style={[Styles.textSmallSemiBold, Styles.approvalNoteLabel, { color: colors.error }]}>
Alasan Penolakan
</Text>
<Text style={[Styles.textMediumNormal, { color: colors.text }]}>
<Text style={[Styles.textMediumNormal]}>
{item.note}
</Text>
</View>
@@ -130,7 +113,7 @@ export default function ModalRiwayatApproval({ isVisible, setVisible, data, load
</View>
))
) : (
<Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>
<Text style={[Styles.textDefault, Styles.approvalEmptyText, { color: colors.dimmed }]}>
Belum ada riwayat persetujuan
</Text>
)}

View File

@@ -14,11 +14,11 @@ export function ButtonFiturMenu({ onPress, icon, text }: Props) {
const { colors } = useTheme();
return (
<TouchableWithoutFeedback onPress={onPress}>
<View style={{ alignItems: 'center' }}>
<View style={{ alignItems: 'center', width: '25%' }}>
<View style={[Styles.btnFiturMenu, { backgroundColor: colors.card, borderColor: colors.icon + '20', shadowColor: colors.text }]}>
{icon}
</View>
<Text style={[Styles.mt05, { color: colors.text }]}>{text}</Text>
<Text style={[Styles.mt05, { color: colors.text, textAlign: 'center', fontSize: 12 }]}>{text}</Text>
</View>
</TouchableWithoutFeedback>
)

View File

@@ -6,19 +6,28 @@ import Text from "../Text";
type Props = {
text: string;
isSelected: boolean;
isSign: boolean;
isSign?: boolean;
isSignCalendar?: boolean;
isSignTask?: boolean;
onPress?: () => void;
}
export default function ItemDateCalendar({ text, isSelected, isSign, onPress }: Props) {
export default function ItemDateCalendar({ text, isSelected, isSign, isSignCalendar, isSignTask, onPress }: Props) {
const { colors } = useTheme()
const showMulti = isSignCalendar !== undefined || isSignTask !== undefined;
return (
<>
<Pressable style={{ alignItems: 'center' }} onPress={onPress}>
<Text style={[isSelected ? Styles.cWhite : { color: colors.text }]}>{text}</Text>
<View style={[Styles.signDate, { backgroundColor: isSign ? 'red' : 'transparent' }]}></View>
</Pressable>
</>
<Pressable style={Styles.contentItemCenter} onPress={onPress}>
<Text style={[isSelected ? Styles.cWhite : { color: colors.text }]}>{text}</Text>
{showMulti ? (
<View style={Styles.calendarDotRow}>
<View style={[Styles.calendarDot, { backgroundColor: isSignCalendar ? (isSelected ? 'white' : '#7886C7') : 'transparent' }]} />
<View style={[Styles.calendarDot, { backgroundColor: isSignTask ? (isSelected ? 'white' : '#94B4C1') : 'transparent' }]} />
</View>
) : (
<View style={[Styles.signDate, { backgroundColor: isSign ? 'red' : 'transparent' }]} />
)}
</Pressable>
)
}

View File

@@ -17,16 +17,16 @@ export default function EventItem({ category, title, user, jamAwal, jamAkhir, on
const getBackgroundColor = (cat: 'purple' | 'orange') => {
if (activeTheme === 'dark') {
return cat === 'orange' ? '#547792' : '#1D546D';
return cat === 'purple' ? '#2D2B5E' : '#1C3347';
}
return cat === 'orange' ? '#D6E6F2' : '#A9B5DF';
return cat === 'purple' ? '#A9B5DF' : '#D6E6F2';
};
const getStickColor = (cat: 'purple' | 'orange') => {
if (activeTheme === 'dark') {
return cat === 'orange' ? '#94B4C1' : '#5F9598';
return cat === 'purple' ? '#7886C7' : '#94B4C1';
}
return cat === 'orange' ? '#F5F5F5' : '#7886C7';
return cat === 'purple' ? '#7886C7' : '#94B4C1';
};
return (

View File

@@ -36,7 +36,7 @@ export default function CaraouselHome({ refreshing }: { refreshing: boolean }) {
async function handleUser() {
const hasil = await decryptToken(String(token?.current))
const response = await apiGetProfile({ id: hasil })
dispatch(setEntityUser({ role: response.data.idUserRole, admin: false, isApprover: response.data.isApprover ?? false }))
dispatch(setEntityUser({ role: response.data.idUserRole, admin: false, isApprover: response.data.isApprover ?? false, idGroup: response.data.idGroup ?? '' }))
}
useEffect(() => {

View File

@@ -59,7 +59,7 @@ export default function CaraouselHome2({ refreshing }: { refreshing: boolean })
// Sync User Role to Redux
useEffect(() => {
if (profile) {
dispatch(setEntityUser({ role: profile.idUserRole, admin: false, isApprover: profile.isApprover ?? false }))
dispatch(setEntityUser({ role: profile.idUserRole, admin: false, isApprover: profile.isApprover ?? false, idGroup: profile.idGroup ?? '' }))
}
}, [profile, dispatch])

View File

@@ -1,4 +1,5 @@
import Styles from "@/constants/Styles";
import { useTheme } from "@/providers/ThemeProvider";
import { useState } from "react";
import { Image } from "react-native";
@@ -9,12 +10,16 @@ type Props = {
onError?: (val:boolean) => void
}
export default function ImageUser({ src, size, onError }: Props) {
export default function ImageUser({ src, size, border, onError }: Props) {
const [error, setError] = useState(false)
const { colors } = useTheme()
return (
<Image
source={error ? require('../assets/images/user.jpg') : { uri: src }}
style={[size == 'xs' ? Styles.userProfileExtraSmall : size == 'lg' ? Styles.userProfileBig : Styles.userProfileSmall, Styles.borderAll]}
style={[
size == 'xs' ? Styles.userProfileExtraSmall : size == 'lg' ? Styles.userProfileBig : Styles.userProfileSmall,
border && { borderWidth: 1, borderColor: colors.icon + '40', borderRadius: 100 }
]}
onError={() => {
setError(true)
onError?.(true)

View File

@@ -39,7 +39,7 @@ type ApprovalRecord = {
createdAt: string
}
export default function SectionTanggalTugasProject({ status, member, refreshing }: { status: number | undefined, member: boolean, refreshing?: boolean }) {
export default function SectionTanggalTugasProject({ status, member, refreshing, idGroup }: { status: number | undefined, member: boolean, refreshing?: boolean, idGroup: string }) {
const { colors } = useTheme();
const entityUser = useSelector((state: any) => state.user)
const dispatch = useDispatch()
@@ -61,7 +61,7 @@ export default function SectionTanggalTugasProject({ status, member, refreshing
const [tugas, setTugas] = useState({ id: '', status: 0 })
const [showDeleteModal, setShowDeleteModal] = useState(false)
const isApprover = entityUser.isApprover || ['supadmin', 'developer'].includes(entityUser.role)
const isApprover = (entityUser.isApprover && entityUser.idGroup === idGroup) || ['supadmin', 'developer'].includes(entityUser.role)
const isAdmin = entityUser.role !== 'user' && entityUser.role !== 'coadmin'
async function handleLoad(loading: boolean) {

View File

@@ -38,7 +38,7 @@ type ApprovalRecord = {
createdAt: string
}
export default function SectionTanggalTugasTask({ refreshing, isMemberDivision, isAdminDivision, status }: { refreshing: boolean, isMemberDivision: boolean, isAdminDivision: boolean, status?: number }) {
export default function SectionTanggalTugasTask({ refreshing, isMemberDivision, isAdminDivision, status, idGroup }: { refreshing: boolean, isMemberDivision: boolean, isAdminDivision: boolean, status?: number, idGroup: string }) {
const { colors } = useTheme()
const dispatch = useDispatch()
const entityUser = useSelector((state: any) => state.user);
@@ -60,7 +60,7 @@ export default function SectionTanggalTugasTask({ refreshing, isMemberDivision,
const [tugas, setTugas] = useState({ id: '', status: 0 })
const [showDeleteModal, setShowDeleteModal] = useState(false)
const isApprover = entityUser.isApprover || ['supadmin', 'developer'].includes(entityUser.role)
const isApprover = (entityUser.isApprover && entityUser.idGroup === idGroup) || ['supadmin', 'developer'].includes(entityUser.role)
const isAdmin = entityUser.role !== 'user' && entityUser.role !== 'coadmin'
const canTakeAction = isMemberDivision || isAdmin

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
import { StyleSheet } from "react-native";
const AnnouncementStyles = StyleSheet.create({
// list (index.tsx)
announcementListContainer: { padding: 15, paddingBottom: 0 },
announcementListInner: { marginTop: 10 },
announcementListCard: { borderRadius: 10, borderWidth: 1, padding: 12 },
announcementListCardHeader: { marginBottom: 6 },
announcementListTitleRow: { flex: 1, gap: 8, marginRight: 8, flexDirection: 'row', alignItems: 'center' },
announcementListIconBox: { width: 28, height: 28, borderRadius: 8, alignItems: 'center', justifyContent: 'center' },
announcementListTitleText: { flex: 1 },
announcementListDateText: { flexShrink: 0 },
announcementListDescText: { lineHeight: 20 },
announcementListSeparator: { height: 8 },
announcementListSkeletonCard: { gap: 8 },
announcementListSkeletonHeader: { marginBottom: 4 },
announcementListSkeletonTitleRow: { gap: 8, flex: 1, flexDirection: 'row', alignItems: 'center' },
// detail ([id].tsx)
announcementDetailContainer: { padding: 15, paddingBottom: 50, gap: 12 },
announcementDetailCard: { borderRadius: 10 },
announcementDetailSkeletonGap: { gap: 8 },
announcementDetailSkeletonIconRow: { gap: 10, marginBottom: 2 },
announcementDetailTitleRow: { gap: 10, marginBottom: 10 },
announcementDetailIconBox: { width: 38, height: 38, borderRadius: 10 },
announcementDetailTitleText: { fontSize: 17, lineHeight: 24, flex: 1 },
announcementDetailSectionLabelRow: { marginBottom: 8, gap: 6 },
announcementDetailFileCardPadding: { padding: 10 },
announcementDetailFileChipList: { gap: 8 },
announcementDetailFileChip: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingHorizontal: 12, paddingVertical: 8, borderRadius: 20, borderWidth: 1 },
announcementDetailFileChipText: { maxWidth: 120 },
announcementDetailRecipientGap: { gap: 10 },
announcementDetailGroupSeparator: { marginTop: 12, paddingTop: 12, borderTopWidth: 1 },
announcementDetailGroupLabel: { marginBottom: 6 },
announcementDetailDivisionRow: { gap: 8, paddingVertical: 6 },
announcementDetailDivisionIconCircle: { width: 26, height: 26, borderRadius: 13, alignItems: 'center', justifyContent: 'center' },
});
export default AnnouncementStyles;

View File

@@ -0,0 +1,13 @@
import { StyleSheet } from "react-native";
const ApprovalStyles = StyleSheet.create({
approvalBadge: { borderRadius: 20, paddingHorizontal: 10, paddingVertical: 3, alignSelf: 'flex-start' },
approvalItem: { borderWidth: 1, borderRadius: 10, padding: 12, marginBottom: 10 },
approvalItemHeader: { justifyContent: 'space-between', marginBottom: 8 },
approvalIconMr: { marginRight: 6 },
approvalNoteBox: { borderRadius: 8, padding: 8, marginTop: 4 },
approvalNoteLabel: { marginBottom: 2 },
approvalEmptyText: { textAlign: 'center' },
});
export default ApprovalStyles;

View File

@@ -0,0 +1,17 @@
import { StyleSheet } from "react-native";
const BorderStyles = StyleSheet.create({
round05: { borderRadius: 5 },
round08: { borderRadius: 8 },
round10: { borderRadius: 10 },
round15: { borderRadius: 15 },
round20: { borderRadius: 20 },
round30: { borderRadius: 30 },
borderRight: { borderRightWidth: 1, borderRightColor: '#d6d8f6' },
borderLeft: { borderLeftWidth: 1, borderLeftColor: '#d6d8f6' },
borderBottom: { borderBottomWidth: 1, borderBottomColor: '#d6d8f6' },
borderTop: { borderTopWidth: 1, borderTopColor: '#d6d8f6' },
borderAll: { borderWidth: 1, borderColor: '#d6d8f6' },
});
export default BorderStyles;

View File

@@ -0,0 +1,44 @@
import { StyleSheet } from "react-native";
const ButtonStyles = StyleSheet.create({
btnIconHeader: { padding: 3 },
btnFiturMenu: { padding: 13, borderRadius: 15, borderWidth: 1 },
btnRound: {
backgroundColor: '#1F3C88',
borderWidth: 0,
borderColor: '#1F3C88',
alignItems: 'center',
borderRadius: 30,
marginTop: 15,
paddingVertical: 10,
},
btnTab: {
alignItems: 'center',
paddingVertical: 5,
paddingHorizontal: 15,
borderRadius: 20,
flexDirection: 'row',
justifyContent: 'center',
},
btnLainnya: {
alignSelf: 'flex-start',
backgroundColor: '#1F3C88',
paddingVertical: 5,
marginVertical: 5,
},
btnDisabled: { backgroundColor: '#d6d8f6' },
btnMenuRow: { width: '33%', alignItems: 'center' },
btnMenuRowMany: { alignItems: 'center', marginHorizontal: 10 },
wrapBtnTab: {
justifyContent: 'space-between',
flexDirection: 'row',
marginBottom: 10,
borderRadius: 20,
padding: 5,
borderWidth: 1,
},
labelStatus: { paddingHorizontal: 15, paddingVertical: 4, borderRadius: 10 },
labelStatusSmall: { paddingHorizontal: 10, paddingVertical: 3, borderRadius: 10 },
});
export default ButtonStyles;

View File

@@ -0,0 +1,159 @@
import { StyleSheet } from "react-native";
const CardStyles = StyleSheet.create({
wrapPaper: {
padding: 10,
backgroundColor: 'white',
borderRadius: 5,
shadowColor: '#171717',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.1,
shadowRadius: 5,
elevation: 2,
},
shadowBox: {
shadowColor: '#171717',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.1,
shadowRadius: 5,
elevation: 2,
},
noShadow: {
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
wrapGridContent: {
shadowColor: '#171717',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.1,
shadowRadius: 5,
elevation: 2,
borderRadius: 5,
marginBottom: 15,
},
wrapGridCaraousel: {
width: '95%',
height: 200,
shadowColor: '#171717',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.1,
shadowRadius: 5,
elevation: 2,
borderRadius: 5,
marginLeft: 5,
display: 'flex',
},
wrapHomeCarousel: {
shadowColor: '#171717',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 5,
},
headerPaperGrid: {
paddingVertical: 25,
paddingHorizontal: 20,
alignItems: 'center',
borderTopStartRadius: 5,
borderTopEndRadius: 5,
},
contentPaperGrid: {
height: 125,
borderBottomEndRadius: 5,
borderBottomStartRadius: 5,
paddingHorizontal: 20,
justifyContent: 'space-evenly',
},
contentPaperGrid2: {
height: 100,
borderBottomEndRadius: 5,
borderBottomStartRadius: 5,
paddingHorizontal: 20,
paddingVertical: 15,
justifyContent: 'flex-start',
},
wrapGridItem: {
borderWidth: 1,
borderRadius: 5,
padding: 10,
flexDirection: 'row',
alignItems: 'center',
width: '48.5%',
marginBottom: 10,
},
listItemCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'transparent',
borderRadius: 10,
borderWidth: 1,
paddingHorizontal: 12,
paddingVertical: 10,
gap: 12,
},
sectionCard: { borderRadius: 12, padding: 16, borderWidth: 1 },
sectionHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 },
sectionHeaderRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 10,
},
sectionIconBox: {
width: 30,
height: 30,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
},
sectionActionRow: { flexDirection: 'row', alignItems: 'center', gap: 10 },
sectionBadge: { borderRadius: 10, paddingHorizontal: 8, paddingVertical: 2 },
wrapBar: { height: 20, backgroundColor: '#ccc', borderRadius: 10, margin: 0, width: '100%' },
contentBar: { height: 20, backgroundColor: '#3B82F6', borderRadius: 10 },
toastContainer: {
backgroundColor: 'white',
borderRadius: 10,
padding: 10,
width: '90%',
borderWidth: 1,
borderColor: '#d6d8f6',
},
loadingCenter: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
zIndex: 999,
backgroundColor: 'rgba(0,0,0,0.3)',
},
loadingBox: {
paddingVertical: 15,
paddingHorizontal: 40,
borderRadius: 5,
alignItems: 'center',
gap: 10,
},
caraoselContent: {
flex: 1,
justifyContent: "center",
marginHorizontal: 15,
borderRadius: 15,
backgroundColor: '#19345E',
display: 'flex',
width: '92%',
resizeMode: 'stretch',
overflow: 'hidden',
},
wrapItemDiscussion: { padding: 15, borderRadius: 5, borderBottomWidth: 1 },
wrapItemBorderBottom: { padding: 10, borderBottomWidth: 1 },
wrapItemBorderAll: { padding: 10, borderWidth: 1, borderRadius: 5, marginBottom: 5 },
wrapItemBorderNone: { padding: 10, marginBottom: 5 },
});
export default CardStyles;

View File

@@ -0,0 +1,140 @@
import { StyleSheet } from "react-native";
const ComponentStyles = StyleSheet.create({
// avatar
userProfileExtraSmall: { width: 35, height: 35, borderRadius: 100 },
userProfileSmall: { width: 48, height: 48, borderRadius: 100 },
userProfileBig: { width: 100, height: 100, borderRadius: 100 },
imgListBanner: { width: 100, height: 50, borderRadius: 5 },
iconContent: { padding: 10, borderRadius: 100, backgroundColor: '#E5E7EB' },
// chip
chip: {
paddingVertical: 5,
paddingHorizontal: 15,
borderRadius: 5,
borderWidth: 1,
borderColor: "transparent",
marginRight: 10,
marginBottom: 10,
},
chipSelected: { backgroundColor: "#f2f6ffff", borderColor: "#384288", borderWidth: 1 },
chipText: { fontSize: 16, color: "#222" },
chipTextSelected: { color: "white" },
checkIcon: {
position: "absolute",
top: -6,
left: -6,
backgroundColor: "#384288",
borderRadius: 10,
padding: 2,
},
// badge & progress
badgeCol: { alignItems: 'center', gap: 6 },
progressBadge: { borderRadius: 10, paddingHorizontal: 12, paddingVertical: 5, borderWidth: 1, alignItems: 'center' },
taskCountBadge: { borderRadius: 6, paddingHorizontal: 7, paddingVertical: 2 },
positionBadge: { borderRadius: 20, paddingHorizontal: 8, paddingVertical: 3 },
textProgressPercent: { fontSize: 22, fontWeight: 'bold', lineHeight: 28 },
progressTrack: { height: 8, borderRadius: 4, overflow: 'hidden' },
progressFill: { height: '100%', borderRadius: 4 },
reportContent: { borderLeftWidth: 3, paddingLeft: 12 },
expandBtn: { flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', marginTop: 8, gap: 4 },
fileGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
fileCard: { width: '48%', borderRadius: 10, borderWidth: 1, padding: 12, flexDirection: 'row', alignItems: 'center', gap: 10 },
// calendar
signDate: { width: 20, height: 2, borderRadius: 3, marginTop: 3 },
selectedDate: { backgroundColor: '#238be6', borderRadius: 5 },
selectRangeDate: { backgroundColor: '#228be61f' },
calendarDotRow: { flexDirection: 'row', gap: 2, height: 6, marginTop: 1 },
calendarDot: { width: 5, height: 5, borderRadius: 3 },
villageEventLegendRow: { marginTop: 10, marginBottom: 4, gap: 16 },
villageEventLegendItem: { gap: 6 },
villageEventLegendDot: { width: 10, height: 10, borderRadius: 5 },
villageEventBadge: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, marginRight: 6 },
// event item
itemEvent: { padding: 10, borderRadius: 10, flexDirection: 'row', alignContent: 'stretch', marginBottom: 10 },
dividerEvent: { width: 7, borderRadius: 5, marginRight: 10 },
// member
memberAvatarRing: { borderWidth: 3, borderColor: 'rgba(255,255,255,0.4)', borderRadius: 100 },
memberBadgeRow: { flexDirection: 'row', gap: 8, marginTop: 12 },
memberBadgeApprover: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 20,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.6)',
backgroundColor: 'rgba(255,255,255,0.15)',
},
memberBadgePill: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 20 },
memberInfoRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 14 },
memberInfoIcon: { width: 36, alignItems: 'center' },
memberInfoContent: { flex: 1, marginLeft: 10 },
// discussion
discussionCard: { borderRadius: 10, borderWidth: 1, padding: 14 },
discussionIconCircle: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center', flexShrink: 0 },
discussionIconCircleLg: { width: 44, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center' },
discussionStatusPill: { alignSelf: 'flex-start', marginTop: 3, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 20, borderWidth: 1 },
discussionStatusText: { fontSize: 11, fontWeight: '600' },
discussionCardIndent: { marginLeft: 50 },
discussionSeparator: { height: 8 },
discussionCommentText: { fontSize: 12, marginLeft: 5 },
discussionDateText: { fontSize: 11 },
discussionCommentCard: { borderRadius: 10, borderWidth: 1, padding: 12, marginBottom: 8, flexDirection: 'row' },
discussionEditedText: { fontSize: 10, fontStyle: 'italic' },
discussionHeaderPadding: { paddingTop: 12 },
discussionListPadding: { paddingTop: 8 },
discussionTitleCol: { marginLeft: 10 },
discussionDescMargin: { marginBottom: 10 },
discussionEmptyText: { fontSize: 14 },
// guide overlay
guideOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', alignItems: 'center' },
guideCard: {
position: 'absolute',
left: 24,
right: 24,
borderRadius: 16,
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 12,
elevation: 8,
},
guideBadge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 20 },
guideDotRow: { marginTop: 16, gap: 6, justifyContent: 'center' },
guideDot: { width: 6, height: 6, borderRadius: 3 },
guideButtonPrimary: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 8, borderRadius: 20 },
guideButtonSecondary: { paddingHorizontal: 4, paddingVertical: 8 },
guideArrowUp: {
position: 'absolute',
top: -10,
marginLeft: -8,
width: 0,
height: 0,
borderLeftWidth: 8,
borderRightWidth: 8,
borderBottomWidth: 10,
borderLeftColor: 'transparent',
borderRightColor: 'transparent',
},
guideArrowDown: {
position: 'absolute',
bottom: -10,
marginLeft: -8,
width: 0,
height: 0,
borderLeftWidth: 8,
borderRightWidth: 8,
borderTopWidth: 10,
borderLeftColor: 'transparent',
borderRightColor: 'transparent',
},
});
export default ComponentStyles;

View File

@@ -0,0 +1,23 @@
import { StyleSheet } from "react-native";
const HeaderStyles = StyleSheet.create({
headerContainer: { backgroundColor: '#19345E' },
headerApp: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
},
headerTitle: { color: '#fff', fontSize: 18, fontWeight: '600' },
headerSide: { width: 40, alignItems: 'center' },
wrapHeadViewMember: {
backgroundColor: '#19345E',
paddingVertical: 30,
paddingHorizontal: 15,
alignItems: 'center',
borderBottomLeftRadius: 25,
borderBottomRightRadius: 25,
},
});
export default HeaderStyles;

32
constants/styles/index.ts Normal file
View File

@@ -0,0 +1,32 @@
import { StyleSheet } from "react-native";
import SpacingStyles from './spacing.styles';
import TypographyStyles from './typography.styles';
import LayoutStyles from './layout.styles';
import BorderStyles from './border.styles';
import ButtonStyles from './button.styles';
import InputStyles from './input.styles';
import CardStyles from './card.styles';
import ModalStyles from './modal.styles';
import HeaderStyles from './header.styles';
import ComponentStyles from './component.styles';
import NotificationStyles from './notification.styles';
import ApprovalStyles from './approval.styles';
import AnnouncementStyles from './announcement.styles';
const Styles = StyleSheet.create({
...SpacingStyles,
...TypographyStyles,
...LayoutStyles,
...BorderStyles,
...ButtonStyles,
...InputStyles,
...CardStyles,
...ModalStyles,
...HeaderStyles,
...ComponentStyles,
...NotificationStyles,
...ApprovalStyles,
...AnnouncementStyles,
});
export default Styles;

View File

@@ -0,0 +1,35 @@
import { StyleSheet } from "react-native";
const InputStyles = StyleSheet.create({
inputRoundForm: {
borderRadius: 5,
borderColor: '#d6d8f6',
borderWidth: 1,
paddingVertical: 10,
paddingHorizontal: 15,
},
inputRoundFormLeft: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 0,
},
inputRoundFormRight: {
flexDirection: 'row-reverse',
alignItems: 'center',
paddingVertical: 0,
justifyContent: 'space-between',
},
verificationCell: {
width: 50,
height: 50,
lineHeight: 45,
fontSize: 24,
borderWidth: 1,
borderRadius: 15,
borderColor: 'gray',
textAlign: 'center',
},
verificationFocusCell: { borderColor: '#19345E' },
});
export default InputStyles;

View File

@@ -0,0 +1,52 @@
import { StyleSheet } from "react-native";
const LayoutStyles = StyleSheet.create({
wrapLogin: { flex: 1, justifyContent: "flex-start", alignItems: "stretch", padding: 20 },
flex1: { flex: 1 },
flex2: { flex: 2 },
flexColumn: { flexDirection: 'column' },
rowOnly: { flexDirection: 'row' },
rowSpaceBetween: { justifyContent: 'space-between', flexDirection: 'row' },
rowSpaceBetweenReverse: { justifyContent: 'space-between', flexDirection: 'row-reverse' },
rowItemsCenter: { flexDirection: 'row', alignItems: 'center' },
justifySpaceBetween: { justifyContent: 'space-between' },
justifyCenter: { justifyContent: 'center' },
alignCenter: { alignItems: 'center' },
itemsCenter: { alignItems: 'center' },
alignStart: { alignItems: 'flex-start' },
contentItemCenter: { justifyContent: 'center', alignItems: 'center' },
h100: { height: '100%' },
w30: { width: '30%' },
w40: { width: '40%' },
w45: { width: '45%' },
w48: { width: '48%' },
w50: { width: '50%' },
w60: { width: '60%' },
w70: { width: '70%' },
w80: { width: '80%' },
w90: { width: '90%' },
w95: { width: '95%' },
w100: { width: '100%' },
posAbsolute: { position: 'absolute' },
absolute0: { position: 'absolute', bottom: 0 },
absoluteIcon: { top: 18, left: 20, position: 'absolute' },
absoluteIconPicker: {
backgroundColor: '#384288',
padding: 5,
borderRadius: 100,
bottom: 5,
right: 5,
position: 'absolute',
},
hidden: { position: 'absolute', opacity: 0, zIndex: -1 },
zIndex1: { zIndex: 1 },
zIndexMinus1: { zIndex: -1 },
resizeContain: { resizeMode: 'contain' },
resizeCover: { resizeMode: 'cover' },
resizeStretch: { resizeMode: 'stretch' },
animatedView: { width: '100%', overflow: 'hidden' },
wrapperAccordion: { width: '100%', position: 'absolute', display: 'flex', alignItems: 'center' },
bottomMenuSelectDocument: { paddingVertical: 10, position: 'absolute', width: '100%', bottom: 0 },
});
export default LayoutStyles;

View File

@@ -0,0 +1,140 @@
import { StyleSheet } from "react-native";
const ModalStyles = StyleSheet.create({
modalBgTransparant: { backgroundColor: 'rgba(0, 0, 0, 0.3)', flex: 1 },
modalContent: {
width: '100%',
paddingBottom: 20,
backgroundColor: 'white',
borderTopRightRadius: 18,
borderTopLeftRadius: 18,
position: 'absolute',
bottom: 0,
},
modalContentNew: {
width: '100%',
backgroundColor: 'white',
borderTopRightRadius: 18,
borderTopLeftRadius: 18,
paddingTop: 5,
paddingBottom: 5,
paddingHorizontal: 20,
},
modalFloatContent: {
backgroundColor: 'white',
borderRadius: 18,
paddingTop: 5,
paddingBottom: 10,
paddingHorizontal: 20,
},
titleContainer: {
backgroundColor: 'white',
borderTopRightRadius: 10,
borderTopLeftRadius: 10,
paddingHorizontal: 20,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 10,
},
titleContainerNew: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 10,
},
titleContainerModalFloat: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 10,
},
contentContainer: { height: '90%' },
itemSelectModal: {
padding: 10,
flexDirection: 'row',
justifyContent: 'space-between',
borderBottomWidth: 1,
alignItems: 'center',
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
justifyContent: 'center',
alignItems: 'center',
},
modalConfirmContainer: {
width: '80%',
borderRadius: 14,
overflow: 'hidden',
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
},
modalConfirmContent: { padding: 20, alignItems: 'center' },
modalConfirmTitle: { fontSize: 18, fontWeight: 'bold', marginBottom: 8, textAlign: 'center' },
modalConfirmMessage: { fontSize: 14, textAlign: 'center', lineHeight: 20 },
modalConfirmDivider: { height: 1, width: '100%' },
modalConfirmFooter: { flexDirection: 'row', height: 50 },
modalConfirmButton: { flex: 1, justifyContent: 'center', alignItems: 'center' },
modalConfirmButtonText: { fontSize: 16 },
modalConfirmVerticalDivider: { width: 1, height: '100%' },
modalUpdateContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 30,
overflow: 'hidden',
},
modalUpdateDecorativeCircle1: {
position: 'absolute',
width: 300,
height: 300,
borderRadius: 150,
backgroundColor: 'rgba(255, 255, 255, 0.05)',
top: -50,
right: -50,
},
modalUpdateDecorativeCircle2: {
position: 'absolute',
width: 200,
height: 200,
borderRadius: 100,
backgroundColor: 'rgba(255, 255, 255, 0.03)',
bottom: -30,
left: -30,
},
modalUpdateContent: { width: '100%', alignItems: 'flex-start', zIndex: 1 },
modalUpdateLogo: { width: 200, height: 100, marginBottom: 40, alignSelf: 'center' },
modalUpdateTextContainer: { marginBottom: 40 },
modalUpdateTitle: { fontSize: 32, fontWeight: 'bold', color: 'white', marginBottom: 20, lineHeight: 38 },
modalUpdateDescription: { fontSize: 16, color: 'white', lineHeight: 24 },
modalUpdateButtonContainer: { width: '100%', alignItems: 'center' },
modalUpdatePrimaryButton: {
width: '100%',
paddingVertical: 15,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 15,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
},
modalUpdatePrimaryButtonText: { fontSize: 16, fontWeight: 'bold' },
modalUpdateSecondaryButton: { paddingVertical: 10 },
modalUpdateSecondaryButtonText: { fontSize: 16, color: 'white', fontWeight: '500' },
headerModalViewImg: {
paddingTop: 50,
paddingHorizontal: 16,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
});
export default ModalStyles;

View File

@@ -0,0 +1,29 @@
import { StyleSheet } from "react-native";
const NotificationStyles = StyleSheet.create({
notifContainer: { paddingTop: 10 },
notifHeaderRow: { marginTop: 16, marginBottom: 8 },
notifDateSeparator: { flex: 1, height: 1, marginLeft: 8 },
notifDateText: { fontSize: 11, fontWeight: '600', letterSpacing: 0.6, textTransform: 'uppercase' },
notifItemRow: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 10,
borderWidth: 1,
paddingHorizontal: 12,
paddingVertical: 10,
marginBottom: 6,
},
notifIconContainer: {
width: 42,
height: 42,
borderRadius: 21,
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
},
notifContent: { marginLeft: 10 },
notifMarkReadText: { fontSize: 11, color: '#3B82F6', fontWeight: '600' },
});
export default NotificationStyles;

View File

@@ -0,0 +1,60 @@
import { StyleSheet } from "react-native";
const SpacingStyles = StyleSheet.create({
mb05: { marginBottom: 5 },
mb08: { marginBottom: 8 },
mb10: { marginBottom: 10 },
mb12: { marginBottom: 12 },
mb13: { marginBottom: 13 },
mb15: { marginBottom: 15 },
mb20: { marginBottom: 20 },
mb30: { marginBottom: 30 },
mb50: { marginBottom: 50 },
mb100: { marginBottom: 100 },
mt02: { marginTop: 2 },
mt05: { marginTop: 5 },
mt10: { marginTop: 10 },
mt15: { marginTop: 15 },
mt30: { marginTop: 30 },
mr05: { marginRight: 5 },
mr10: { marginRight: 10 },
ml05: { marginLeft: 5 },
ml10: { marginLeft: 10 },
ml15: { marginLeft: 15 },
ml20: { marginLeft: 20 },
ml25: { marginLeft: 25 },
mv05: { marginVertical: 5 },
mv10: { marginVertical: 10 },
mv15: { marginVertical: 15 },
mv50: { marginVertical: 50 },
mh03: { marginHorizontal: 3 },
mh05: { marginHorizontal: 5 },
mh10: { marginHorizontal: 10 },
mh15: { marginHorizontal: 15 },
p0: { padding: 0 },
p05: { padding: 5 },
p10: { padding: 10 },
p15: { padding: 15 },
p20: { padding: 20 },
pb05: { paddingBottom: 5 },
pb07: { paddingBottom: 7 },
pb10: { paddingBottom: 10 },
pb13: { paddingBottom: 13 },
pb15: { paddingBottom: 15 },
pb20: { paddingBottom: 20 },
pb50: { paddingBottom: 50 },
pb100: { paddingBottom: 100 },
ph05: { paddingHorizontal: 5 },
ph10: { paddingHorizontal: 10 },
ph15: { paddingHorizontal: 15 },
ph16: { paddingHorizontal: 16 },
ph20: { paddingHorizontal: 20 },
pv03: { paddingVertical: 3 },
pv05: { paddingVertical: 5 },
pv10: { paddingVertical: 10 },
pv14: { paddingVertical: 14 },
pv15: { paddingVertical: 15 },
pv20: { paddingVertical: 20 },
});
export default SpacingStyles;

View File

@@ -0,0 +1,28 @@
import { StyleSheet } from "react-native";
const TypographyStyles = StyleSheet.create({
textTitle: { fontSize: 32, fontWeight: 'bold', lineHeight: 32 },
textSubtitle: { fontSize: 20, fontWeight: 'bold' },
textSubtitle2: { fontSize: 20 },
textHeaderHome: { fontSize: 18, fontWeight: 'bold', flex: 1, color: 'white' },
textDefault: { fontSize: 15, lineHeight: 24 },
textDefaultSemiBold: { fontSize: 15, lineHeight: 24, fontWeight: '600' },
textMediumNormal: { fontSize: 13, lineHeight: 24 },
textMediumSemiBold: { fontSize: 13, lineHeight: 24, fontWeight: '600' },
textSmallSemiBold: { fontSize: 10, fontWeight: '600' },
textInformation: { fontSize: 12, fontWeight: 'light' },
textLink: { fontSize: 14, color: '#0a7ea4' },
textCenter: { textAlign: 'center' },
textWhite: { color: 'white' },
font16: { fontSize: 16 },
font26: { fontSize: 26 },
cError: { color: '#DB1514' },
cGray: { color: 'gray' },
cWhite: { color: 'white' },
cWhiteDimmed: { color: 'rgba(255,255,255,0.7)' },
cBlack: { color: 'black' },
cDefault: { color: '#19345E' },
cFolder: { color: '#f9cc40' },
});
export default TypographyStyles;

134
docs/FILE-HEALTH.md Normal file
View File

@@ -0,0 +1,134 @@
# FILE-HEALTH — Aturan Ukuran & Struktur File
Aturan ini berlaku untuk semua file dalam project ini.
Tujuan: menjaga file tetap kecil, kohesif, dan mudah diproses oleh AI maupun manusia.
---
## Batas Ukuran File
| Tipe File | Maks Baris | Maks Karakter | Keterangan |
|-----------|-----------|---------------|------------|
| Route handler | 150 | 6.000 | Satu file = satu resource |
| Service / use-case | 300 | 12.000 | Satu file = satu domain logic |
| Repository / query | 250 | 10.000 | Pisah per entity |
| Schema / validation | 200 | 8.000 | Pisah per domain |
| Types / interfaces | 300 | 10.000 | Boleh agregat, tapi per modul |
| Utility / helper | 200 | 8.000 | Satu concern per file |
| Config | 100 | 4.000 | Tidak ada logic bisnis |
| Test file | 400 | 16.000 | Satu file test per satu unit |
> **Hard limit global:** Tidak ada file yang boleh melebihi **500 baris** atau **20.000 karakter**,
> kecuali file yang di-generate otomatis (migration, seed, generated types).
---
## Aturan Wajib
### 1. Satu File, Satu Tanggung Jawab
- Setiap file harus bisa dijelaskan dalam satu kalimat pendek.
- Jika penjelasannya butuh kata "dan" lebih dari sekali → **pecah file-nya**.
### 2. Tidak Ada "God File"
- Dilarang menaruh lebih dari satu route group dalam satu file handler.
- Dilarang mencampur business logic dengan transport layer (HTTP, WS, queue).
- Dilarang mencampur type definition dengan implementation dalam satu file yang panjang.
### 3. Penamaan File Harus Eksplisit
- Nama file harus mencerminkan isi secara tepat.
- Hindari nama generik: `utils.ts`, `helpers.ts`, `common.ts`, `misc.ts`.
- Gunakan pola: `[domain].[layer].ts` → contoh: `user.service.ts`, `payment.repository.ts`.
### 4. Index File Hanya Untuk Re-export
- File `index.ts` hanya boleh berisi re-export, **bukan** implementasi.
- Maksimal 50 baris untuk file index.
### 5. Tidak Ada Barrel Import yang Dalam
- Hindari barrel yang mengimpor dari barrel lain lebih dari 2 level.
- Ini membuat AI sulit trace dependency dengan akurat.
---
## Kapan Harus Pecah File
Pecah file segera jika salah satu kondisi ini terpenuhi:
- [ ] File melebihi batas karakter/baris di tabel di atas
- [ ] Ada dua fungsi/class yang tidak saling bergantung dalam satu file
- [ ] File mengandung lebih dari 3 exported symbol utama
- [ ] File sulit diberi nama yang spesifik tanpa kata "dan"
- [ ] Edit di satu bagian file sering menyebabkan konflik di bagian lain
---
## Pola Pemecahan File yang Dianjurkan
### Service yang Terlalu Besar
```
// SEBELUM: user.service.ts (600 baris)
// SESUDAH:
user.service.ts // orchestration, max 150 baris
user.query.service.ts // read operations
user.command.service.ts // write operations
user.notification.service.ts // side effects
```
### Handler yang Terlalu Besar
```
// SEBELUM: user.route.ts (400 baris)
// SESUDAH:
user.route.ts // route registration only
user.handler.ts // handler functions
user.middleware.ts // route-specific middleware
```
### Types yang Terlalu Besar
```
// SEBELUM: types.ts (500 baris)
// SESUDAH:
types/user.types.ts
types/payment.types.ts
types/shared.types.ts
```
---
## Instruksi Khusus untuk AI
Ketika bekerja dalam project ini, **Claude wajib**:
1. **Menolak menambah kode** ke file yang sudah mendekati atau melebihi batas,
kecuali penambahannya memang sangat kecil (< 10 baris) dan kohesif.
2. **Proaktif menyarankan refactor** saat mendeteksi file yang tumbuh tidak sehat,
sebelum menambahkan fitur baru ke file tersebut.
3. **Tidak membuat "helper dump"** setiap helper harus punya file sendiri
yang namanya spesifik, bukan ditumpuk ke file utils yang ada.
4. **Selalu buat file baru** jika implementasi baru tidak secara alami masuk
ke salah satu file yang sudah ada.
5. **Periksa ukuran file saat ini** sebelum mengedit jika sudah > 80% dari
batas, sarankan pecah terlebih dahulu.
---
## Pengecualian
File berikut **dikecualikan** dari aturan batas ukuran:
- `*.generated.ts` — file hasil code generation (Prisma, tRPC, dll)
- `*.migration.ts` / `*_migration.sql` — file migrasi database
- `*.seed.ts` — file seeding data
- File di folder `__fixtures__/` atau `__mocks__/`
Pengecualian **tidak berlaku** untuk file konfigurasi runtime seperti
`elysia.config.ts`, `app.ts`, atau `server.ts` — file ini tetap harus ringkas.
---
*Letakkan file ini di root project atau sertakan referensinya di `CLAUDE.md`.*

View File

@@ -318,6 +318,8 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- ExpoSecureStore (14.2.4):
- ExpoModulesCore
- ExpoSharing (13.1.5):
- ExpoModulesCore
- ExpoSplashScreen (0.30.10):
@@ -2247,6 +2249,7 @@ DEPENDENCIES:
- ExpoLinking (from `../node_modules/expo-linking/ios`)
- ExpoMediaLibrary (from `../node_modules/expo-media-library/ios`)
- ExpoModulesCore (from `../node_modules/expo-modules-core`)
- ExpoSecureStore (from `../node_modules/expo-secure-store/ios`)
- ExpoSharing (from `../node_modules/expo-sharing/ios`)
- ExpoSplashScreen (from `../node_modules/expo-splash-screen/ios`)
- ExpoSymbols (from `../node_modules/expo-symbols/ios`)
@@ -2420,6 +2423,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/expo-media-library/ios"
ExpoModulesCore:
:path: "../node_modules/expo-modules-core"
ExpoSecureStore:
:path: "../node_modules/expo-secure-store/ios"
ExpoSharing:
:path: "../node_modules/expo-sharing/ios"
ExpoSplashScreen:
@@ -2630,6 +2635,7 @@ SPEC CHECKSUMS:
ExpoLinking: d5c183998ca6ada66ff45e407e0f965b398a8902
ExpoMediaLibrary: 0daf5e811e00daa47690f5da2989e71ff7de56e0
ExpoModulesCore: 272bc6c06ddd9c4bee2048acc57891cab3700627
ExpoSecureStore: 3f1b632d6d40bcc62b4983ef9199cd079592a50a
ExpoSharing: b0377be82430d07398c6a4cd60b5a15696accbd3
ExpoSplashScreen: 1c22c5d37647106e42d4ae1582bb6d0dda3b2385
ExpoSymbols: c5612a90fb9179cdaebcd19bea9d8c69e5d3b859

View File

@@ -1,870 +1 @@
import axios, { AxiosError } from 'axios';
import Constants from 'expo-constants';
import { logError } from '@/lib/errorLogger';
const api = axios.create({
baseURL: Constants?.expoConfig?.extra?.URL_API
});
api.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
const status = error.response?.status;
const url = error.config?.url ?? 'unknown endpoint';
const description = `API error ${status ?? 'network'} on ${url}`;
logError(description, error);
return Promise.reject(error);
}
);
export const apiCheckPhoneLogin = async (body: { phone: string }) => {
const response = await api.post('/auth/login', body)
return response.data;
}
export const apiSendOtp = async (body: { phone: string, otp: number }) => {
const message = "Desa+\nMasukkan kode ini " + body.otp + " pada aplikasi Desa+ anda. Jangan berikan pada siapapun."
const textFix = encodeURIComponent(message)
const res = await fetch(
`${Constants.expoConfig?.extra?.URL_OTP}/api/wa/send-text`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${Constants.expoConfig?.extra?.WA_SERVER_TOKEN}`,
},
body: JSON.stringify({
number: body.phone,
text: message
})
}
);
return res.status
}
export const apiGetProfile = async ({ id }: { id: string }) => {
const response = await api.get(`mobile/user/${id}`);
return response.data;
};
export const apiEditProfile = async (data: FormData) => {
const response = await api.put(`/mobile/user/profile`, data, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data;
};
export const apiGetSearch = async ({ text, user }: { text: string, user: string }) => {
const response = await api.get(`mobile/home/search?search=${text}&user=${user}`);
return response.data;
};
export const apiGetBanner = async ({ user }: { user: string }) => {
const response = await api.get(`mobile/banner?user=${user}`);
return response.data;
};
export const apiCreateBanner = async (data: FormData) => {
const response = await api.post('mobile/banner', data,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
)
return response.data;
};
export const apiDeleteBanner = async (data: { user: string }, id: string) => {
const response = await api.delete(`mobile/banner/${id}`, { data })
return response.data;
};
export const apiGetBannerOne = async ({ user, id }: { user: string, id: string }) => {
const response = await api.get(`mobile/banner/${id}?user=${user}`);
return response.data;
};
export const apiEditBanner = async (data: FormData, id: string) => {
const response = await api.put(`mobile/banner/${id}`, data,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
)
return response.data;
};
export const apiGetDataHome = async ({ cat, user }: { cat: 'kegiatan' | 'division' | 'progress' | 'dokumen' | 'event' | 'discussion' | 'header' | 'check-late-project', user: string }) => {
const response = await api.get(`mobile/home?user=${user}&cat=${cat}`);
return response.data;
};
export const apiGetGroup = async ({ user, active, search }: { user: string, active: string, search: string }) => {
const response = await api.get(`mobile/group?user=${user}&active=${active}&search=${search}`);
return response.data;
};
export const apiCreateGroup = async (data: { user: string, name: string }) => {
const response = await api.post('mobile/group', data);
return response.data;
};
export const apiEditGroup = async (data: { user: string, name: string }, id: string) => {
const response = await api.put(`mobile/group/${id}`, data);
return response.data;
};
export const apiDeleteGroup = async (data: { user: string, isActive: boolean }, id: string) => {
const response = await api.delete(`mobile/group/${id}`, { data });
return response.data;
};
export const apiGetPosition = async ({ user, active, search, group }: { user: string, active: string, search: string, group?: string }) => {
const response = await api.get(`mobile/position?user=${user}&active=${active}&group=${group}&search=${search}`);
return response.data;
};
export const apiCreatePosition = async (data: { user: string, name: string, idGroup: string }) => {
const response = await api.post('mobile/position', data);
return response.data;
};
export const apiDeletePosition = async (data: { user: string, isActive: boolean }, id: string) => {
const response = await api.delete(`mobile/position/${id}`, { data });
return response.data;
};
export const apiEditPosition = async (data: { user: string, name: string, idGroup: string }, id: string) => {
const response = await api.put(`mobile/position/${id}`, data)
return response.data
};
export const apiGetUser = async ({ user, active, search, group, page }: { user: string, active: string, search: string, group?: string, page?: number }) => {
const response = await api.get(`mobile/user?user=${user}&active=${active}&group=${group}&search=${search}&page=${page}`);
return response.data;
};
export const apiCreateUser = async ({ data }: { data: FormData }) => {
const response = await api.post('/mobile/user', data, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data;
};
export const apiDeleteUser = async (data: { user: string, isActive: boolean }, id: string) => {
const response = await api.delete(`mobile/user/${id}`, { data })
return response.data
};
export const apiToggleApprover = async (data: { user: string, isApprover: boolean }, id: string) => {
const response = await api.patch(`mobile/user/${id}`, data)
return response.data
};
export const apiEditUser = async (data: FormData, id: string) => {
const response = await api.put(`/mobile/user/${id}`, data, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data;
};
export const apiGetDiscussionGeneral = async ({ user, active, search, group, page }: { user: string, active: string, search: string, group?: string, page?: number }) => {
const response = await api.get(`mobile/discussion-general?user=${user}&active=${active}&group=${group}&search=${search}&page=${page}`);
return response.data;
};
export const apiGetDiscussionGeneralOne = async ({ id, user, cat }: { id: string, user: string, cat: string }) => {
const response = await api.get(`mobile/discussion-general/${id}?user=${user}&cat=${cat}`);
return response.data;
};
export const apiSendDiscussionGeneralCommentar = async ({ id, data }: { id: string, data: { desc: string, user: string } }) => {
const response = await api.post(`/mobile/discussion-general/${id}/comment`, data)
return response.data;
};
export const apiDeleteDiscussionGeneralCommentar = async ({ id, data }: { id: string, data: { user: string } }) => {
const response = await api.delete(`/mobile/discussion-general/${id}/comment`, { data })
return response.data;
};
export const apiUpdateDiscussionGeneralCommentar = async ({ id, data }: { id: string, data: { desc: string, user: string } }) => {
const response = await api.put(`/mobile/discussion-general/${id}/comment`, data)
return response.data;
};
export const apiDeleteMemberDiscussionGeneral = async (data: { user: string, idUser: string }, id: string) => {
const response = await api.delete(`mobile/discussion-general/${id}/member`, { data });
return response.data;
};
export const apiUpdateStatusDiscussionGeneral = async ({ id, data }: { id: string, data: { status: number, user: string } }) => {
const response = await api.post(`/mobile/discussion-general/${id}`, data)
return response.data;
};
export const apiDeleteDiscussionGeneral = async (data: { user: string, active: boolean }, id: string) => {
const response = await api.delete(`mobile/discussion-general/${id}`, { data });
return response.data;
};
export const apiEditDiscussionGeneral = async (data: FormData, id: string) => {
const response = await api.put(`/mobile/discussion-general/${id}`, data, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data;
};
// export const apiCreateDiscussionGeneral = async ({ data }: { data: { idGroup: string, title: string, desc: string, user: string, member: [] } }) => {
// const response = await api.post(`/mobile/discussion-general`, data)
// return response.data;
// };
export const apiCreateDiscussionGeneral = async (data: FormData) => {
// const response = await api.post(`/mobile/discussion-general`, data)
const response = await api.post(`/mobile/discussion-general`, data, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data;
};
export const apiAddMemberDiscussionGeneral = async ({ data, id }: { data: { user: string, member: any[] }, id: string }) => {
const response = await api.post(`/mobile/discussion-general/${id}/member`, data)
return response.data;
};
export const apiGetAnnouncement = async ({ user, search, page }: { user: string, search: string, page?: number }) => {
const response = await api.get(`mobile/announcement?user=${user}&search=${search}&page=${page}`);
return response.data;
};
export const apiGetDivisionGroup = async ({ user }: { user: string }) => {
const response = await api.get(`mobile/group/get-division?user=${user}`);
return response.data;
};
// export const apiCreateAnnouncement = async ({ data }: { data: { title: string, desc: string, user: string, groups: any[] } }) => {
// const response = await api.post(`/mobile/announcement`, data)
// return response.data;
// };
export const apiCreateAnnouncement = async (data: FormData) => {
const response = await api.post(`/mobile/announcement`, data, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data;
};
export const apiGetAnnouncementOne = async ({ user, id }: { user: string, id: string }) => {
const response = await api.get(`mobile/announcement/${id}?user=${user}`);
return response.data;
};
// export const apiEditAnnouncement = async (data: { title: string, desc: string, user: string, groups: any[], oldFile: any[] }, id: string) => {
// const response = await api.put(`/mobile/announcement/${id}`, data)
// return response.data;
// };
export const apiEditAnnouncement = async (data: FormData, id: string) => {
const response = await api.put(`/mobile/announcement/${id}`, data, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data;
};
export const apiDeleteAnnouncement = async (data: { user: string }, id: string) => {
const response = await api.delete(`mobile/announcement/${id}`, { data })
return response.data
};
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;
};
export const apiGetProjectOne = async ({ user, cat, id }: { user: string, cat: 'data' | 'progress' | 'task' | 'file' | 'member' | 'link', id: string }) => {
const response = await api.get(`mobile/project/${id}?user=${user}&cat=${cat}`);
return response.data;
};
export const apiEditProject = async (data: { name: string, user: string }, id: string) => {
const response = await api.put(`/mobile/project/${id}`, data)
return response.data;
};
export const apiReportProject = async (data: { report: string, user: string }, id: string) => {
const response = await api.put(`/mobile/project/${id}/lainnya`, data)
return response.data;
};
export const apiCreateProjectTask = async ({ data, id }: { data: { name: string, dateStart: string, user: string, dateEnd: string, dataDetail: any[] }, id: string }) => {
const response = await api.post(`/mobile/project/${id}`, data)
return response.data;
};
export const apiCancelProject = async (data: { user: string, reason: string }, id: string) => {
const response = await api.delete(`mobile/project/${id}`, { data })
return response.data
};
export const apiUpdateStatusProjectTask = async (data: { user: string, status: number, idProject: string }, id: string) => {
const response = await api.put(`mobile/project/detail/${id}`, data)
return response.data
};
export const apiDeleteProjectTask = async (data: { user: string, idProject: string }, id: string) => {
const response = await api.delete(`mobile/project/detail/${id}`, { data })
return response.data
};
export const apiGetProjectTask = async ({ user, id, cat }: { user: string, id: string, cat?: string }) => {
const response = await api.get(`mobile/project/detail/${id}?user=${user}${cat ? `&cat=${cat}` : ""}`);
return response.data;
};
export const apiEditProjectTask = async ({ data, id }: { data: { title: string, dateStart: string, user: string, dateEnd: string, dataDetail: any[] }, id: string }) => {
const response = await api.post(`/mobile/project/detail/${id}`, data)
return response.data;
};
export const apiDeleteProjectMember = async (data: { user: string, idUser: string }, id: string) => {
const response = await api.delete(`mobile/project/${id}/member`, { data })
return response.data
};
export const apiGetProjectTaskFile = async ({ user, id }: { user: string, id: string }) => {
const response = await api.get(`/mobile/project/task/file/${id}`, { params: { user } })
return response.data;
};
export const apiAddProjectTaskFile = async ({ data, id }: { data: FormData, id: string }) => {
const response = await api.post(`/mobile/project/task/file/${id}`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data;
};
export const apiLinkProjectTaskFile = async ({ user, idFile, id }: { user: string, idFile: string, id: string }) => {
const response = await api.patch(`/mobile/project/task/file/${id}`, { user, idFile })
return response.data;
};
export const apiDeleteProjectTaskFile = async (data: { user: string }, id: string) => {
const response = await api.delete(`/mobile/project/task/file/${id}`, { data })
return response.data;
};
export const apiGetProjectTaskApprovals = async ({ user, id }: { user: string, id: string }) => {
const response = await api.get(`/mobile/project/task/${id}/approval`, { params: { user } })
return response.data;
};
export const apiSubmitProjectTask = async ({ user, id }: { user: string, id: string }) => {
const response = await api.post(`/mobile/project/task/${id}/approval`, { user })
return response.data;
};
export const apiApproveRejectProjectTask = async ({ user, id, action, note }: { user: string, id: string, action: 'approve' | 'reject', note?: string }) => {
const response = await api.put(`/mobile/project/task/${id}/approval`, { user, action, note })
return response.data;
};
export const apiAddMemberProject = async ({ data, id }: { data: { user: string, member: any[] }, id: string }) => {
const response = await api.post(`/mobile/project/${id}/member`, data)
return response.data;
};
export const apiCreateProject = async (data: FormData) => {
const response = await api.post(`/mobile/project`, data, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data;
};
export const apiDeleteProject = async (data: { user: string }, id: string) => {
const response = await api.delete(`/mobile/project/${id}/lainnya`, { data })
return response.data;
};
export const apiAddLinkProject = async (data: { user: string, link: string }, id: string) => {
const response = await api.post(`/mobile/project/${id}/link`, data)
return response.data;
};
export const apiAddFileProject = async ({ data, id }: { data: FormData, id: string }) => {
const response = await api.post(`/mobile/project/file/${id}`, data,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
)
return response.data;
};
export const apiCheckFileProject = async ({ data, id }: { data: FormData, id: string }) => {
const response = await api.put(`/mobile/project/file/${id}`, data,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
)
return response.data;
};
export const apiDeleteFileProject = async (data: { user: string }, id: string) => {
const response = await api.delete(`/mobile/project/file/${id}`, { data })
return response.data;
};
export const apiDeleteLinkProject = async (data: { idLink: string, user: string }, id: string) => {
const response = await api.delete(`/mobile/project/${id}/link`, { data })
return response.data;
};
export const apiGetDivision = async ({ user, search, group, kategori, active, page }: { user: string, search: string, group?: string, kategori?: string, active?: string, page?: number }) => {
const response = await api.get(`mobile/division?user=${user}&active=${active}&group=${group}&search=${search}&cat=${kategori}&page=${page}`);
return response.data;
};
export const apiGetDivisionReport = async ({ user, cat, date, dateEnd, division, group }: { user: string, cat: 'table-progress' | 'lainnya', date: string, dateEnd: string, division: string, group?: string }) => {
const response = await api.get(`mobile/division/report?user=${user}&cat=${cat}&date=${date}&date-end=${dateEnd}&division=${division}&group=${group}`);
return response.data;
};
export const apiGetDivisionOneFeature = async ({ user, cat, id }: { user: string, cat: 'jumlah' | 'today-task' | 'new-file' | 'new-discussion' | 'check-member' | 'check-admin', id: string }) => {
const response = await api.get(`mobile/division/${id}/detail?user=${user}&cat=${cat}`);
return response.data;
};
export const apiGetDivisionOneDetail = async ({ user, id }: { user: string, id: string }) => {
const response = await api.get(`mobile/division/${id}?user=${user}`);
return response.data;
};
export const apiUpdateStatusAdminDivision = async (data: { user: string, id: string, isAdmin: boolean }, id: string) => {
const response = await api.put(`mobile/division/${id}/detail`, data)
return response.data
};
export const apiDeleteMemberDivision = async (data: { user: string, id: string }, id: string) => {
const response = await api.delete(`/mobile/division/${id}/detail`, { data })
return response.data;
};
export const apiAddMemberDivision = async ({ data, id }: { data: { user: string, member: any[] }, id: string }) => {
const response = await api.post(`/mobile/division/${id}/detail`, data)
return response.data;
};
export const apiEditDivision = async (data: { user: string, name: string, desc: string }, id: string) => {
const response = await api.put(`mobile/division/${id}`, data)
return response.data
};
export const apiUpdateStatusDivision = async ({ data, id }: { data: { user: string, isActive: boolean }, id: string }) => {
const response = await api.post(`/mobile/division/${id}/status`, data)
return response.data;
};
export const apiGetDivisionMember = async ({ user, id, search }: { user: string, id: string, search: string }) => {
const response = await api.get(`mobile/division/${id}/member?user=${user}&search=${search}`);
return response.data;
};
export const apiGetListDivisionByIdDivision = async ({ user, search, division }: { user: string, search: string, division: string }) => {
const response = await api.get(`mobile/division/more?user=${user}&search=${search}&division=${division}`);
return response.data;
};
export const apiCreateDivision = async (data: { data: { idGroup: string, name: string, desc: string }, member: [], admin: string[], user: string }) => {
const response = await api.post(`/mobile/division`, data)
return response.data;
};
export const apiCheckDivisionName = async (data: { data: { idGroup: string, name: string, desc: string }, user: string }) => {
const response = await api.put(`/mobile/division`, data)
return response.data;
};
export const apiGetDiscussion = async ({ user, search, division, active, page }: { user: string, search: string, division: string, active?: string, page?: number }) => {
const response = await api.get(`mobile/discussion?user=${user}&active=${active}&search=${search}&division=${division}&page=${page}`);
return response.data;
};
export const apiGetDiscussionOne = async ({ id, user, cat }: { id: string, user: string, cat: 'data' | 'comment' | 'file' }) => {
const response = await api.get(`mobile/discussion/${id}?user=${user}&cat=${cat}`);
return response.data;
};
export const apiSendDiscussionCommentar = async ({ data, id }: { data: { user: string, comment: string }, id: string }) => {
const response = await api.post(`/mobile/discussion/${id}/comment`, data)
return response.data;
};
export const apiEditDiscussionCommentar = async ({ data, id }: { data: { user: string, comment: string }, id: string }) => {
const response = await api.put(`/mobile/discussion/${id}/comment`, data)
return response.data;
};
export const apiDeleteDiscussionCommentar = async ({ data, id }: { data: { user: string }, id: string }) => {
const response = await api.delete(`/mobile/discussion/${id}/comment`, { data })
return response.data;
};
// export const apiEditDiscussion = async ({ data, id }: { data: { user: string, desc: string }, id: string }) => {
// const response = await api.post(`/mobile/discussion/${id}`, data)
// return response.data;
// };
export const apiEditDiscussion = async (data: FormData, id: string) => {
const response = await api.post(`/mobile/discussion/${id}`, data, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data;
};
export const apiArchiveDiscussion = async (data: { user: string, active: boolean }, id: string) => {
const response = await api.put(`mobile/discussion/${id}`, data)
return response.data
};
export const apiOpenCloseDiscussion = async (data: { user: string, status: number }, id: string) => {
const response = await api.delete(`mobile/discussion/${id}`, { data })
return response.data
};
// export const apiCreateDiscussion = async ({ data }: { data: { user: string, desc: string, idDivision: string } }) => {
// const response = await api.post(`/mobile/discussion`, data)
// return response.data;
// };
export const apiCreateDiscussion = async (data: FormData) => {
const response = await api.post(`/mobile/discussion`, data, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data;
};
export const apiGetCalendarByDateDivision = async ({ user, date, division }: { user: string, date: string, division: string, }) => {
const response = await api.get(`mobile/calendar?user=${user}&date=${date}&division=${division}`);
return response.data;
};
export const apiGetIndicatorCalendar = async ({ user, date, division }: { user: string, date: string, division: string, }) => {
const response = await api.get(`mobile/calendar/indicator?user=${user}&date=${date}&division=${division}`);
return response.data;
};
export const apiGetCalendarOne = async ({ user, id, cat }: { user: string, id: string, cat: 'data' | 'member' }) => {
const response = await api.get(`mobile/calendar/${id}?user=${user}&cat=${cat}`);
return response.data;
};
export const apiDeleteCalendarMember = async (data: { user: string, idUser: string }, id: string) => {
const response = await api.delete(`/mobile/calendar/${id}/member`, { data })
return response.data
};
export const apiAddMemberCalendar = async ({ data, id }: { data: { user: string, member: any[] }, id: string }) => {
const response = await api.post(`/mobile/calendar/${id}/member`, data)
return response.data;
};
export const apiDeleteCalendar = async (data: { user: string }, id: string) => {
const response = await api.delete(`/mobile/calendar/${id}`, { data })
return response.data
};
export const apiGetCalendarHistory = async ({ user, search, division, page }: { user: string, search: string, division: string, page?: number }) => {
const response = await api.get(`mobile/calendar/history?user=${user}&search=${search}&division=${division}&page=${page}`);
return response.data;
};
export const apiCreateCalendar = async ({ data }: { data: { idDivision: string, title: string, desc: string, timeStart: string, timeEnd: string, dateStart: string, repeatEventTyper: string, repeatValue: string, linkMeet: string, member: any[], user: string } }) => {
const response = await api.post(`/mobile/calendar`, data)
return response.data;
};
export const apiUpdateCalendar = async ({ data, id }: { data: { title: string, desc: string, timeStart: string, timeEnd: string, dateStart: string, repeatEventTyper: string, repeatValue: number, linkMeet: string, user: string }, id: string }) => {
const response = await api.put(`/mobile/calendar/${id}`, data)
return response.data;
};
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;
};
export const apiGetTaskOne = async ({ user, cat, id }: { user: string, cat: 'data' | 'progress' | 'task' | 'file' | 'member' | 'link', id: string }) => {
const response = await api.get(`mobile/task/${id}?user=${user}&cat=${cat}`);
return response.data;
};
export const apiUpdateStatusTaskDivision = async (data: { user: string, status: number, idProject: string }, id: string) => {
const response = await api.put(`mobile/task/detail/${id}`, data)
return response.data
};
export const apiGetTaskTugas = async ({ user, id, cat }: { user: string, id: string, cat?: string }) => {
const response = await api.get(`mobile/task/detail/${id}?user=${user}${cat ? `&cat=${cat}` : ""}`);
return response.data;
};
export const apiEditTaskTugas = async ({ data, id }: { data: { title: string, dateStart: string, user: string, dateEnd: string, dataDetail: any[] }, id: string }) => {
const response = await api.post(`/mobile/task/detail/${id}`, data)
return response.data;
};
export const apiDeleteTaskTugas = async (data: { user: string, idProject: string }, id: string) => {
const response = await api.delete(`mobile/task/detail/${id}`, { data })
return response.data
};
export const apiDeleteFileTask = async (data: { user: string }, id: string) => {
const response = await api.delete(`/mobile/task/file/${id}`, { data })
return response.data;
};
export const apiDeleteLinkTask = async (data: { user: string, idLink: string }, id: string) => {
const response = await api.delete(`/mobile/task/${id}/link`, { data })
return response.data;
};
export const apiDeleteTaskMember = async (data: { user: string, idUser: string }, id: string) => {
const response = await api.delete(`mobile/task/${id}/member`, { data })
return response.data
};
export const apiCreateTaskTugas = async ({ data, id }: { data: { title: string, dateStart: string, user: string, dateEnd: string, idDivision: string, dataDetail: any[] }, id: string }) => {
const response = await api.post(`/mobile/task/${id}`, data)
return response.data;
};
export const apiCheckFileTask = async ({ data, id }: { data: FormData, id: string }) => {
const response = await api.put(`/mobile/task/file/${id}`, data,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
)
return response.data;
};
export const apiAddFileTask = async ({ data, id }: { data: FormData, id: string }) => {
const response = await api.post(`/mobile/task/file/${id}`, data,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
)
return response.data;
};
export const apiGetTaskTugasApprovals = async ({ user, id }: { user: string, id: string }) => {
const response = await api.get(`/mobile/task/tugas/${id}/approval`, { params: { user } })
return response.data;
};
export const apiSubmitTaskTugas = async ({ user, id }: { user: string, id: string }) => {
const response = await api.post(`/mobile/task/tugas/${id}/approval`, { user })
return response.data;
};
export const apiApproveRejectTaskTugas = async ({ user, id, action, note }: { user: string, id: string, action: 'approve' | 'reject', note?: string }) => {
const response = await api.put(`/mobile/task/tugas/${id}/approval`, { user, action, note })
return response.data;
};
export const apiGetTugasTaskFile = async ({ user, id }: { user: string, id: string }) => {
const response = await api.get(`/mobile/task/tugas/file/${id}`, { params: { user } })
return response.data;
};
export const apiAddTugasTaskFile = async ({ data, id }: { data: FormData, id: string }) => {
const response = await api.post(`/mobile/task/tugas/file/${id}`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data;
};
export const apiLinkTugasTaskFile = async ({ user, idFile, id }: { user: string, idFile: string, id: string }) => {
const response = await api.patch(`/mobile/task/tugas/file/${id}`, { user, idFile })
return response.data;
};
export const apiDeleteTugasTaskFile = async (data: { user: string }, id: string) => {
const response = await api.delete(`/mobile/task/tugas/file/${id}`, { data })
return response.data;
};
export const apiEditTask = async (data: { title: string, user: string }, id: string) => {
const response = await api.put(`/mobile/task/${id}`, data)
return response.data;
};
export const apiReportTask = async (data: { report: string, user: string }, id: string) => {
const response = await api.put(`/mobile/task/${id}/lainnya`, data)
return response.data;
};
export const apiCancelTask = async (data: { user: string, reason: string }, id: string) => {
const response = await api.delete(`mobile/task/${id}`, { data })
return response.data
};
export const apiAddMemberTask = async ({ data, id }: { data: { user: string, member: any[], idDivision: string }, id: string }) => {
const response = await api.post(`/mobile/task/${id}/member`, data)
return response.data;
};
export const apiCreateTask = async (data: FormData) => {
const response = await api.post(`/mobile/task`, data, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data;
};
export const apiDeleteTask = async (data: { user: string }, id: string) => {
const response = await api.delete(`/mobile/task/${id}/lainnya`, { data })
return response.data;
};
export const apiAddLinkTask = async (data: { user: string, link: string, idDivision: string }, id: string) => {
const response = await api.post(`/mobile/task/${id}/link`, data)
return response.data;
};
export const apiGetDocument = async ({ user, path, division, category }: { user: string, path: string, division: string, category: 'all' | 'folder' }) => {
const response = await api.get(`mobile/document?user=${user}&path=${path}&division=${division}&category=${category}`);
return response.data;
};
export const apiGetDocumentInformasi = async ({ user, item, cat }: { user: string, item: string, cat: 'share' | 'lainnya' }) => {
const response = await api.get(`mobile/document/more?user=${user}&item=${item}&cat=${cat}`);
return response.data;
};
export const apiDocumentRename = async (data: { name: string, user: string, id: string, path: string, idDivision: string, extension: string }) => {
const response = await api.put(`/mobile/document`, data)
return response.data;
};
export const apiDocumentDelete = async (data: { user: string, data: any[] }) => {
const response = await api.delete(`/mobile/document`, { data })
return response.data
};
export const apiCreateFolderDocument = async ({ data }: { data: { name: string, path: string, idDivision: string, user: string } }) => {
const response = await api.post(`/mobile/document`, data)
return response.data;
};
export const apiUploadFileDocument = async ({ data }: { data: FormData }) => {
const response = await api.post(`/mobile/document/upload`, data,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
)
return response.data;
};
export const apiMoveDocument = async (data: { path: string, dataItem: any[], user: string }) => {
const response = await api.post(`/mobile/document/more`, data)
return response.data;
};
export const apiCopyDocument = async (data: { path: string, dataItem: any[], user: string, idDivision: string }) => {
const response = await api.put(`/mobile/document/more`, data)
return response.data;
};
export const apiShareDocument = async (data: { dataDivision: any[], dataItem: any[], user: string }) => {
const response = await api.delete(`/mobile/document/more`, { data })
return response.data;
};
export const apiRegisteredToken = async (data: { user: string, token: string, category?: string }) => {
const response = await api.post(`/mobile/auth-token`, data)
return response.data;
};
export const apiUnregisteredToken = async (data: { user: string, token: string, category?: string }) => {
const response = await api.put(`/mobile/auth-token`, data)
return response.data;
};
export const apiGetCheckToken = async (data: { user: string, token: string }) => {
const response = await api.post(`mobile/auth-token/check`, data);
return response.data;
};
export const apiGetNotification = async ({ user, page }: { user: string, page?: number }) => {
const response = await api.get(`mobile/home/notification?user=${user}&page=${page}`);
return response.data;
};
export const apiReadOneNotification = async (data: { user: string, id: string }) => {
const response = await api.put(`/mobile/home/notification`, data)
return response.data;
};
export const apiReadAllNotification = async (data: { user: string }) => {
const response = await api.post(`/mobile/home/notification`, data)
return response.data;
};
export const apiGetVersion = async () => {
const response = await api.get(`mobile/version`);
return response.data;
};
export * from './api/index';

View File

@@ -0,0 +1,30 @@
import api from './client';
export const apiGetAnnouncement = async ({ user, search, page }: { user: string, search: string, page?: number }) => {
const response = await api.get(`mobile/announcement?user=${user}&search=${search}&page=${page}`);
return response.data;
};
export const apiCreateAnnouncement = async (data: FormData) => {
const response = await api.post(`/mobile/announcement`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data;
};
export const apiGetAnnouncementOne = async ({ user, id }: { user: string, id: string }) => {
const response = await api.get(`mobile/announcement/${id}?user=${user}`);
return response.data;
};
export const apiEditAnnouncement = async (data: FormData, id: string) => {
const response = await api.put(`/mobile/announcement/${id}`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data;
};
export const apiDeleteAnnouncement = async (data: { user: string }, id: string) => {
const response = await api.delete(`mobile/announcement/${id}`, { data })
return response.data
};

46
lib/api/auth.api.ts Normal file
View File

@@ -0,0 +1,46 @@
import Constants from 'expo-constants';
import api from './client';
export const apiCheckPhoneLogin = async (body: { phone: string }) => {
const response = await api.post('/auth/login', body)
return response.data;
}
export const apiSendOtp = async (body: { phone: string, otp: number }) => {
const message = "Desa+\nMasukkan kode ini " + body.otp + " pada aplikasi Desa+ anda. Jangan berikan pada siapapun."
const res = await fetch(
`${Constants.expoConfig?.extra?.URL_OTP}/api/wa/send-text`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${Constants.expoConfig?.extra?.WA_SERVER_TOKEN}`,
},
body: JSON.stringify({
number: body.phone,
text: message
})
}
);
return res.status
}
export const apiRegisteredToken = async (data: { user: string, token: string, category?: string }) => {
const response = await api.post(`/mobile/auth-token`, data)
return response.data;
};
export const apiUnregisteredToken = async (data: { user: string, token: string, category?: string }) => {
const response = await api.put(`/mobile/auth-token`, data)
return response.data;
};
export const apiGetCheckToken = async (data: { user: string, token: string }) => {
const response = await api.post(`mobile/auth-token/check`, data);
return response.data;
};
export const apiGetVersion = async () => {
const response = await api.get(`mobile/version`);
return response.data;
};

30
lib/api/banner.api.ts Normal file
View File

@@ -0,0 +1,30 @@
import api from './client';
export const apiGetBanner = async ({ user }: { user: string }) => {
const response = await api.get(`mobile/banner?user=${user}`);
return response.data;
};
export const apiCreateBanner = async (data: FormData) => {
const response = await api.post('mobile/banner', data, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data;
};
export const apiDeleteBanner = async (data: { user: string }, id: string) => {
const response = await api.delete(`mobile/banner/${id}`, { data })
return response.data;
};
export const apiGetBannerOne = async ({ user, id }: { user: string, id: string }) => {
const response = await api.get(`mobile/banner/${id}?user=${user}`);
return response.data;
};
export const apiEditBanner = async (data: FormData, id: string) => {
const response = await api.put(`mobile/banner/${id}`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data;
};

56
lib/api/calendar.api.ts Normal file
View File

@@ -0,0 +1,56 @@
import api from './client';
export const apiGetCalendarByDateDivision = async ({ user, date, division }: { user: string, date: string, division: string }) => {
const response = await api.get(`mobile/calendar?user=${user}&date=${date}&division=${division}`);
return response.data;
};
export const apiGetIndicatorCalendar = async ({ user, date, division }: { user: string, date: string, division: string }) => {
const response = await api.get(`mobile/calendar/indicator?user=${user}&date=${date}&division=${division}`);
return response.data;
};
export const apiGetCalendarOne = async ({ user, id, cat }: { user: string, id: string, cat: 'data' | 'member' }) => {
const response = await api.get(`mobile/calendar/${id}?user=${user}&cat=${cat}`);
return response.data;
};
export const apiGetCalendarHistory = async ({ user, search, division, page }: { user: string, search: string, division: string, page?: number }) => {
const response = await api.get(`mobile/calendar/history?user=${user}&search=${search}&division=${division}&page=${page}`);
return response.data;
};
export const apiCreateCalendar = async ({ data }: { data: { idDivision: string, title: string, desc: string, timeStart: string, timeEnd: string, dateStart: string, repeatEventTyper: string, repeatValue: string, linkMeet: string, member: any[], user: string } }) => {
const response = await api.post(`/mobile/calendar`, data)
return response.data;
};
export const apiUpdateCalendar = async ({ data, id }: { data: { title: string, desc: string, timeStart: string, timeEnd: string, dateStart: string, repeatEventTyper: string, repeatValue: number, linkMeet: string, user: string }, id: string }) => {
const response = await api.put(`/mobile/calendar/${id}`, data)
return response.data;
};
export const apiDeleteCalendar = async (data: { user: string }, id: string) => {
const response = await api.delete(`/mobile/calendar/${id}`, { data })
return response.data
};
export const apiAddMemberCalendar = async ({ data, id }: { data: { user: string, member: any[] }, id: string }) => {
const response = await api.post(`/mobile/calendar/${id}/member`, data)
return response.data;
};
export const apiDeleteCalendarMember = async (data: { user: string, idUser: string }, id: string) => {
const response = await api.delete(`/mobile/calendar/${id}/member`, { data })
return response.data
};
export const apiGetVillageCalendarByDate = async ({ user, date }: { user: string, date: string }) => {
const response = await api.get(`mobile/village-calendar?user=${user}&date=${date}`);
return response.data;
};
export const apiGetVillageCalendarIndicator = async ({ user, date }: { user: string, date: string }) => {
const response = await api.get(`mobile/village-calendar/indicator?user=${user}&date=${date}`);
return response.data;
};

20
lib/api/client.ts Normal file
View File

@@ -0,0 +1,20 @@
import axios, { AxiosError } from 'axios';
import Constants from 'expo-constants';
import { logError } from '@/lib/errorLogger';
const api = axios.create({
baseURL: Constants?.expoConfig?.extra?.URL_API
});
api.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
const status = error.response?.status;
const url = error.config?.url ?? 'unknown endpoint';
const description = `API error ${status ?? 'network'} on ${url}`;
logError(description, error);
return Promise.reject(error);
}
);
export default api;

109
lib/api/discussion.api.ts Normal file
View File

@@ -0,0 +1,109 @@
import api from './client';
export const apiGetDiscussionGeneral = async ({ user, active, search, group, page }: { user: string, active: string, search: string, group?: string, page?: number }) => {
const response = await api.get(`mobile/discussion-general?user=${user}&active=${active}&group=${group}&search=${search}&page=${page}`);
return response.data;
};
export const apiGetDiscussionGeneralOne = async ({ id, user, cat }: { id: string, user: string, cat: string }) => {
const response = await api.get(`mobile/discussion-general/${id}?user=${user}&cat=${cat}`);
return response.data;
};
export const apiCreateDiscussionGeneral = async (data: FormData) => {
const response = await api.post(`/mobile/discussion-general`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data;
};
export const apiEditDiscussionGeneral = async (data: FormData, id: string) => {
const response = await api.put(`/mobile/discussion-general/${id}`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data;
};
export const apiDeleteDiscussionGeneral = async (data: { user: string, active: boolean }, id: string) => {
const response = await api.delete(`mobile/discussion-general/${id}`, { data });
return response.data;
};
export const apiUpdateStatusDiscussionGeneral = async ({ id, data }: { id: string, data: { status: number, user: string } }) => {
const response = await api.post(`/mobile/discussion-general/${id}`, data)
return response.data;
};
export const apiSendDiscussionGeneralCommentar = async ({ id, data }: { id: string, data: { desc: string, user: string } }) => {
const response = await api.post(`/mobile/discussion-general/${id}/comment`, data)
return response.data;
};
export const apiUpdateDiscussionGeneralCommentar = async ({ id, data }: { id: string, data: { desc: string, user: string } }) => {
const response = await api.put(`/mobile/discussion-general/${id}/comment`, data)
return response.data;
};
export const apiDeleteDiscussionGeneralCommentar = async ({ id, data }: { id: string, data: { user: string } }) => {
const response = await api.delete(`/mobile/discussion-general/${id}/comment`, { data })
return response.data;
};
export const apiAddMemberDiscussionGeneral = async ({ data, id }: { data: { user: string, member: any[] }, id: string }) => {
const response = await api.post(`/mobile/discussion-general/${id}/member`, data)
return response.data;
};
export const apiDeleteMemberDiscussionGeneral = async (data: { user: string, idUser: string }, id: string) => {
const response = await api.delete(`mobile/discussion-general/${id}/member`, { data });
return response.data;
};
export const apiGetDiscussion = async ({ user, search, division, active, page }: { user: string, search: string, division: string, active?: string, page?: number }) => {
const response = await api.get(`mobile/discussion?user=${user}&active=${active}&search=${search}&division=${division}&page=${page}`);
return response.data;
};
export const apiGetDiscussionOne = async ({ id, user, cat }: { id: string, user: string, cat: 'data' | 'comment' | 'file' }) => {
const response = await api.get(`mobile/discussion/${id}?user=${user}&cat=${cat}`);
return response.data;
};
export const apiCreateDiscussion = async (data: FormData) => {
const response = await api.post(`/mobile/discussion`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data;
};
export const apiEditDiscussion = async (data: FormData, id: string) => {
const response = await api.post(`/mobile/discussion/${id}`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data;
};
export const apiArchiveDiscussion = async (data: { user: string, active: boolean }, id: string) => {
const response = await api.put(`mobile/discussion/${id}`, data)
return response.data
};
export const apiOpenCloseDiscussion = async (data: { user: string, status: number }, id: string) => {
const response = await api.delete(`mobile/discussion/${id}`, { data })
return response.data
};
export const apiSendDiscussionCommentar = async ({ data, id }: { data: { user: string, comment: string }, id: string }) => {
const response = await api.post(`/mobile/discussion/${id}/comment`, data)
return response.data;
};
export const apiEditDiscussionCommentar = async ({ data, id }: { data: { user: string, comment: string }, id: string }) => {
const response = await api.put(`/mobile/discussion/${id}/comment`, data)
return response.data;
};
export const apiDeleteDiscussionCommentar = async ({ data, id }: { data: { user: string }, id: string }) => {
const response = await api.delete(`/mobile/discussion/${id}/comment`, { data })
return response.data;
};

66
lib/api/division.api.ts Normal file
View File

@@ -0,0 +1,66 @@
import api from './client';
export const apiGetDivision = async ({ user, search, group, kategori, active, page }: { user: string, search: string, group?: string, kategori?: string, active?: string, page?: number }) => {
const response = await api.get(`mobile/division?user=${user}&active=${active}&group=${group}&search=${search}&cat=${kategori}&page=${page}`);
return response.data;
};
export const apiGetDivisionReport = async ({ user, cat, date, dateEnd, division, group }: { user: string, cat: 'table-progress' | 'lainnya', date: string, dateEnd: string, division: string, group?: string }) => {
const response = await api.get(`mobile/division/report?user=${user}&cat=${cat}&date=${date}&date-end=${dateEnd}&division=${division}&group=${group}`);
return response.data;
};
export const apiGetDivisionOneFeature = async ({ user, cat, id }: { user: string, cat: 'jumlah' | 'today-task' | 'new-file' | 'new-discussion' | 'check-member' | 'check-admin', id: string }) => {
const response = await api.get(`mobile/division/${id}/detail?user=${user}&cat=${cat}`);
return response.data;
};
export const apiGetDivisionOneDetail = async ({ user, id }: { user: string, id: string }) => {
const response = await api.get(`mobile/division/${id}?user=${user}`);
return response.data;
};
export const apiCreateDivision = async (data: { data: { idGroup: string, name: string, desc: string }, member: [], admin: string[], user: string }) => {
const response = await api.post(`/mobile/division`, data)
return response.data;
};
export const apiCheckDivisionName = async (data: { data: { idGroup: string, name: string, desc: string }, user: string }) => {
const response = await api.put(`/mobile/division`, data)
return response.data;
};
export const apiEditDivision = async (data: { user: string, name: string, desc: string }, id: string) => {
const response = await api.put(`mobile/division/${id}`, data)
return response.data
};
export const apiUpdateStatusDivision = async ({ data, id }: { data: { user: string, isActive: boolean }, id: string }) => {
const response = await api.post(`/mobile/division/${id}/status`, data)
return response.data;
};
export const apiGetDivisionMember = async ({ user, id, search }: { user: string, id: string, search: string }) => {
const response = await api.get(`mobile/division/${id}/member?user=${user}&search=${search}`);
return response.data;
};
export const apiGetListDivisionByIdDivision = async ({ user, search, division }: { user: string, search: string, division: string }) => {
const response = await api.get(`mobile/division/more?user=${user}&search=${search}&division=${division}`);
return response.data;
};
export const apiUpdateStatusAdminDivision = async (data: { user: string, id: string, isAdmin: boolean }, id: string) => {
const response = await api.put(`mobile/division/${id}/detail`, data)
return response.data
};
export const apiDeleteMemberDivision = async (data: { user: string, id: string }, id: string) => {
const response = await api.delete(`/mobile/division/${id}/detail`, { data })
return response.data;
};
export const apiAddMemberDivision = async ({ data, id }: { data: { user: string, member: any[] }, id: string }) => {
const response = await api.post(`/mobile/division/${id}/detail`, data)
return response.data;
};

48
lib/api/document.api.ts Normal file
View File

@@ -0,0 +1,48 @@
import api from './client';
export const apiGetDocument = async ({ user, path, division, category }: { user: string, path: string, division: string, category: 'all' | 'folder' }) => {
const response = await api.get(`mobile/document?user=${user}&path=${path}&division=${division}&category=${category}`);
return response.data;
};
export const apiGetDocumentInformasi = async ({ user, item, cat }: { user: string, item: string, cat: 'share' | 'lainnya' }) => {
const response = await api.get(`mobile/document/more?user=${user}&item=${item}&cat=${cat}`);
return response.data;
};
export const apiDocumentRename = async (data: { name: string, user: string, id: string, path: string, idDivision: string, extension: string }) => {
const response = await api.put(`/mobile/document`, data)
return response.data;
};
export const apiDocumentDelete = async (data: { user: string, data: any[] }) => {
const response = await api.delete(`/mobile/document`, { data })
return response.data
};
export const apiCreateFolderDocument = async ({ data }: { data: { name: string, path: string, idDivision: string, user: string } }) => {
const response = await api.post(`/mobile/document`, data)
return response.data;
};
export const apiUploadFileDocument = async ({ data }: { data: FormData }) => {
const response = await api.post(`/mobile/document/upload`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data;
};
export const apiMoveDocument = async (data: { path: string, dataItem: any[], user: string }) => {
const response = await api.post(`/mobile/document/more`, data)
return response.data;
};
export const apiCopyDocument = async (data: { path: string, dataItem: any[], user: string, idDivision: string }) => {
const response = await api.put(`/mobile/document/more`, data)
return response.data;
};
export const apiShareDocument = async (data: { dataDivision: any[], dataItem: any[], user: string }) => {
const response = await api.delete(`/mobile/document/more`, { data })
return response.data;
};

46
lib/api/group.api.ts Normal file
View File

@@ -0,0 +1,46 @@
import api from './client';
export const apiGetGroup = async ({ user, active, search }: { user: string, active: string, search: string }) => {
const response = await api.get(`mobile/group?user=${user}&active=${active}&search=${search}`);
return response.data;
};
export const apiCreateGroup = async (data: { user: string, name: string }) => {
const response = await api.post('mobile/group', data);
return response.data;
};
export const apiEditGroup = async (data: { user: string, name: string }, id: string) => {
const response = await api.put(`mobile/group/${id}`, data);
return response.data;
};
export const apiDeleteGroup = async (data: { user: string, isActive: boolean }, id: string) => {
const response = await api.delete(`mobile/group/${id}`, { data });
return response.data;
};
export const apiGetDivisionGroup = async ({ user }: { user: string }) => {
const response = await api.get(`mobile/group/get-division?user=${user}`);
return response.data;
};
export const apiGetPosition = async ({ user, active, search, group }: { user: string, active: string, search: string, group?: string }) => {
const response = await api.get(`mobile/position?user=${user}&active=${active}&group=${group}&search=${search}`);
return response.data;
};
export const apiCreatePosition = async (data: { user: string, name: string, idGroup: string }) => {
const response = await api.post('mobile/position', data);
return response.data;
};
export const apiDeletePosition = async (data: { user: string, isActive: boolean }, id: string) => {
const response = await api.delete(`mobile/position/${id}`, { data });
return response.data;
};
export const apiEditPosition = async (data: { user: string, name: string, idGroup: string }, id: string) => {
const response = await api.put(`mobile/position/${id}`, data)
return response.data
};

26
lib/api/home.api.ts Normal file
View File

@@ -0,0 +1,26 @@
import api from './client';
export const apiGetDataHome = async ({ cat, user }: { cat: 'kegiatan' | 'division' | 'progress' | 'dokumen' | 'event' | 'discussion' | 'header' | 'check-late-project', user: string }) => {
const response = await api.get(`mobile/home?user=${user}&cat=${cat}`);
return response.data;
};
export const apiGetSearch = async ({ text, user }: { text: string, user: string }) => {
const response = await api.get(`mobile/home/search?search=${text}&user=${user}`);
return response.data;
};
export const apiGetNotification = async ({ user, page }: { user: string, page?: number }) => {
const response = await api.get(`mobile/home/notification?user=${user}&page=${page}`);
return response.data;
};
export const apiReadOneNotification = async (data: { user: string, id: string }) => {
const response = await api.put(`/mobile/home/notification`, data)
return response.data;
};
export const apiReadAllNotification = async (data: { user: string }) => {
const response = await api.post(`/mobile/home/notification`, data)
return response.data;
};

12
lib/api/index.ts Normal file
View File

@@ -0,0 +1,12 @@
export * from './auth.api';
export * from './user.api';
export * from './group.api';
export * from './banner.api';
export * from './home.api';
export * from './announcement.api';
export * from './project.api';
export * from './division.api';
export * from './discussion.api';
export * from './calendar.api';
export * from './task.api';
export * from './document.api';

144
lib/api/project.api.ts Normal file
View File

@@ -0,0 +1,144 @@
import api from './client';
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;
};
export const apiGetProjectOne = async ({ user, cat, id }: { user: string, cat: 'data' | 'progress' | 'task' | 'file' | 'member' | 'link', id: string }) => {
const response = await api.get(`mobile/project/${id}?user=${user}&cat=${cat}`);
return response.data;
};
export const apiCreateProject = async (data: FormData) => {
const response = await api.post(`/mobile/project`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data;
};
export const apiEditProject = async (data: { name: string, user: string }, id: string) => {
const response = await api.put(`/mobile/project/${id}`, data)
return response.data;
};
export const apiReportProject = async (data: { report: string, user: string }, id: string) => {
const response = await api.put(`/mobile/project/${id}/lainnya`, data)
return response.data;
};
export const apiCancelProject = async (data: { user: string, reason: string }, id: string) => {
const response = await api.delete(`mobile/project/${id}`, { data })
return response.data
};
export const apiDeleteProject = async (data: { user: string }, id: string) => {
const response = await api.delete(`/mobile/project/${id}/lainnya`, { data })
return response.data;
};
export const apiAddMemberProject = async ({ data, id }: { data: { user: string, member: any[] }, id: string }) => {
const response = await api.post(`/mobile/project/${id}/member`, data)
return response.data;
};
export const apiDeleteProjectMember = async (data: { user: string, idUser: string }, id: string) => {
const response = await api.delete(`mobile/project/${id}/member`, { data })
return response.data
};
export const apiAddLinkProject = async (data: { user: string, link: string }, id: string) => {
const response = await api.post(`/mobile/project/${id}/link`, data)
return response.data;
};
export const apiDeleteLinkProject = async (data: { idLink: string, user: string }, id: string) => {
const response = await api.delete(`/mobile/project/${id}/link`, { data })
return response.data;
};
export const apiAddFileProject = async ({ data, id }: { data: FormData, id: string }) => {
const response = await api.post(`/mobile/project/file/${id}`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data;
};
export const apiCheckFileProject = async ({ data, id }: { data: FormData, id: string }) => {
const response = await api.put(`/mobile/project/file/${id}`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data;
};
export const apiDeleteFileProject = async (data: { user: string }, id: string) => {
const response = await api.delete(`/mobile/project/file/${id}`, { data })
return response.data;
};
export const apiCreateProjectTask = async ({ data, id }: { data: { name: string, dateStart: string, user: string, dateEnd: string, dataDetail: any[] }, id: string }) => {
const response = await api.post(`/mobile/project/${id}`, data)
return response.data;
};
export const apiUpdateStatusProjectTask = async (data: { user: string, status: number, idProject: string }, id: string) => {
const response = await api.put(`mobile/project/detail/${id}`, data)
return response.data
};
export const apiDeleteProjectTask = async (data: { user: string, idProject: string }, id: string) => {
const response = await api.delete(`mobile/project/detail/${id}`, { data })
return response.data
};
export const apiGetProjectTask = async ({ user, id, cat }: { user: string, id: string, cat?: string }) => {
const response = await api.get(`mobile/project/detail/${id}?user=${user}${cat ? `&cat=${cat}` : ""}`);
return response.data;
};
export const apiEditProjectTask = async ({ data, id }: { data: { title: string, dateStart: string, user: string, dateEnd: string, dataDetail: any[] }, id: string }) => {
const response = await api.post(`/mobile/project/detail/${id}`, data)
return response.data;
};
export const apiGetProjectTaskFile = async ({ user, id }: { user: string, id: string }) => {
const response = await api.get(`/mobile/project/task/file/${id}`, { params: { user } })
return response.data;
};
export const apiAddProjectTaskFile = async ({ data, id }: { data: FormData, id: string }) => {
const response = await api.post(`/mobile/project/task/file/${id}`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data;
};
export const apiLinkProjectTaskFile = async ({ user, idFile, id }: { user: string, idFile: string, id: string }) => {
const response = await api.patch(`/mobile/project/task/file/${id}`, { user, idFile })
return response.data;
};
export const apiDeleteProjectTaskFile = async (data: { user: string }, id: string) => {
const response = await api.delete(`/mobile/project/task/file/${id}`, { data })
return response.data;
};
export const apiGetProjectTaskApprovals = async ({ user, id }: { user: string, id: string }) => {
const response = await api.get(`/mobile/project/task/${id}/approval`, { params: { user } })
return response.data;
};
export const apiSubmitProjectTask = async ({ user, id }: { user: string, id: string }) => {
const response = await api.post(`/mobile/project/task/${id}/approval`, { user })
return response.data;
};
export const apiApproveRejectProjectTask = async ({ user, id, action, note }: { user: string, id: string, action: 'approve' | 'reject', note?: string }) => {
const response = await api.put(`/mobile/project/task/${id}/approval`, { user, action, note })
return response.data;
};

144
lib/api/task.api.ts Normal file
View File

@@ -0,0 +1,144 @@
import api from './client';
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;
};
export const apiGetTaskOne = async ({ user, cat, id }: { user: string, cat: 'data' | 'progress' | 'task' | 'file' | 'member' | 'link', id: string }) => {
const response = await api.get(`mobile/task/${id}?user=${user}&cat=${cat}`);
return response.data;
};
export const apiCreateTask = async (data: FormData) => {
const response = await api.post(`/mobile/task`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data;
};
export const apiEditTask = async (data: { title: string, user: string }, id: string) => {
const response = await api.put(`/mobile/task/${id}`, data)
return response.data;
};
export const apiReportTask = async (data: { report: string, user: string }, id: string) => {
const response = await api.put(`/mobile/task/${id}/lainnya`, data)
return response.data;
};
export const apiCancelTask = async (data: { user: string, reason: string }, id: string) => {
const response = await api.delete(`mobile/task/${id}`, { data })
return response.data
};
export const apiDeleteTask = async (data: { user: string }, id: string) => {
const response = await api.delete(`/mobile/task/${id}/lainnya`, { data })
return response.data;
};
export const apiAddMemberTask = async ({ data, id }: { data: { user: string, member: any[], idDivision: string }, id: string }) => {
const response = await api.post(`/mobile/task/${id}/member`, data)
return response.data;
};
export const apiDeleteTaskMember = async (data: { user: string, idUser: string }, id: string) => {
const response = await api.delete(`mobile/task/${id}/member`, { data })
return response.data
};
export const apiAddLinkTask = async (data: { user: string, link: string, idDivision: string }, id: string) => {
const response = await api.post(`/mobile/task/${id}/link`, data)
return response.data;
};
export const apiDeleteLinkTask = async (data: { user: string, idLink: string }, id: string) => {
const response = await api.delete(`/mobile/task/${id}/link`, { data })
return response.data;
};
export const apiAddFileTask = async ({ data, id }: { data: FormData, id: string }) => {
const response = await api.post(`/mobile/task/file/${id}`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data;
};
export const apiCheckFileTask = async ({ data, id }: { data: FormData, id: string }) => {
const response = await api.put(`/mobile/task/file/${id}`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data;
};
export const apiDeleteFileTask = async (data: { user: string }, id: string) => {
const response = await api.delete(`/mobile/task/file/${id}`, { data })
return response.data;
};
export const apiUpdateStatusTaskDivision = async (data: { user: string, status: number, idProject: string }, id: string) => {
const response = await api.put(`mobile/task/detail/${id}`, data)
return response.data
};
export const apiGetTaskTugas = async ({ user, id, cat }: { user: string, id: string, cat?: string }) => {
const response = await api.get(`mobile/task/detail/${id}?user=${user}${cat ? `&cat=${cat}` : ""}`);
return response.data;
};
export const apiCreateTaskTugas = async ({ data, id }: { data: { title: string, dateStart: string, user: string, dateEnd: string, idDivision: string, dataDetail: any[] }, id: string }) => {
const response = await api.post(`/mobile/task/${id}`, data)
return response.data;
};
export const apiEditTaskTugas = async ({ data, id }: { data: { title: string, dateStart: string, user: string, dateEnd: string, dataDetail: any[] }, id: string }) => {
const response = await api.post(`/mobile/task/detail/${id}`, data)
return response.data;
};
export const apiDeleteTaskTugas = async (data: { user: string, idProject: string }, id: string) => {
const response = await api.delete(`mobile/task/detail/${id}`, { data })
return response.data
};
export const apiGetTugasTaskFile = async ({ user, id }: { user: string, id: string }) => {
const response = await api.get(`/mobile/task/tugas/file/${id}`, { params: { user } })
return response.data;
};
export const apiAddTugasTaskFile = async ({ data, id }: { data: FormData, id: string }) => {
const response = await api.post(`/mobile/task/tugas/file/${id}`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data;
};
export const apiLinkTugasTaskFile = async ({ user, idFile, id }: { user: string, idFile: string, id: string }) => {
const response = await api.patch(`/mobile/task/tugas/file/${id}`, { user, idFile })
return response.data;
};
export const apiDeleteTugasTaskFile = async (data: { user: string }, id: string) => {
const response = await api.delete(`/mobile/task/tugas/file/${id}`, { data })
return response.data;
};
export const apiGetTaskTugasApprovals = async ({ user, id }: { user: string, id: string }) => {
const response = await api.get(`/mobile/task/tugas/${id}/approval`, { params: { user } })
return response.data;
};
export const apiSubmitTaskTugas = async ({ user, id }: { user: string, id: string }) => {
const response = await api.post(`/mobile/task/tugas/${id}/approval`, { user })
return response.data;
};
export const apiApproveRejectTaskTugas = async ({ user, id, action, note }: { user: string, id: string, action: 'approve' | 'reject', note?: string }) => {
const response = await api.put(`/mobile/task/tugas/${id}/approval`, { user, action, note })
return response.data;
};

42
lib/api/user.api.ts Normal file
View File

@@ -0,0 +1,42 @@
import api from './client';
export const apiGetProfile = async ({ id }: { id: string }) => {
const response = await api.get(`mobile/user/${id}`);
return response.data;
};
export const apiEditProfile = async (data: FormData) => {
const response = await api.put(`/mobile/user/profile`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data;
};
export const apiGetUser = async ({ user, active, search, group, page }: { user: string, active: string, search: string, group?: string, page?: number }) => {
const response = await api.get(`mobile/user?user=${user}&active=${active}&group=${group}&search=${search}&page=${page}`);
return response.data;
};
export const apiCreateUser = async ({ data }: { data: FormData }) => {
const response = await api.post('/mobile/user', data, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data;
};
export const apiDeleteUser = async (data: { user: string, isActive: boolean }, id: string) => {
const response = await api.delete(`mobile/user/${id}`, { data })
return response.data
};
export const apiToggleApprover = async (data: { user: string, isApprover: boolean }, id: string) => {
const response = await api.patch(`mobile/user/${id}`, data)
return response.data
};
export const apiEditUser = async (data: FormData, id: string) => {
const response = await api.put(`/mobile/user/${id}`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data;
};

217
lib/guideSteps.ts Normal file
View File

@@ -0,0 +1,217 @@
import { GuideStep } from "@/components/GuideOverlay";
export const GUIDE_VILLAGE_CALENDAR: GuideStep[] = [
{
title: 'Kalender Umum',
description: 'Halaman ini menampilkan semua acara divisi dan kegiatan yang ada di desa kamu dalam satu kalender.',
cardTopRatio: 0.58,
arrowDirection: 'up',
arrowOffset: 0.5,
},
{
title: 'Pilih Tanggal',
description: 'Ketuk tanggal pada kalender untuk melihat acara dan kegiatan yang berlangsung pada hari tersebut.',
cardTopRatio: 0.58,
arrowDirection: 'up',
arrowOffset: 0.5,
},
{
title: 'Indikator Dot',
description: 'Titik ungu menandakan ada acara divisi, titik biru-abu menandakan ada kegiatan pada tanggal tersebut.',
cardTopRatio: 0.63,
arrowDirection: 'up',
arrowOffset: 0.12,
},
{
title: 'Detail Event',
description: 'Ketuk salah satu acara atau kegiatan di bawah untuk melihat detail lengkapnya.',
cardTopRatio: 0.35,
arrowDirection: 'down',
arrowOffset: 0.5,
},
];
export const GUIDE_DIVISION_DISCUSSION: GuideStep[] = [
{
title: 'Diskusi Divisi',
description: 'Halaman ini menampilkan semua topik diskusi dalam divisi. Setiap card menunjukkan pembuat, deskripsi singkat, jumlah komentar, dan status diskusi.',
cardTopRatio: 0.35,
arrowDirection: 'none',
},
{
title: 'Buka Diskusi',
description: 'Ketuk salah satu diskusi untuk membaca detail dan ikut berkomentar.',
cardTopRatio: 0.35,
arrowDirection: 'none',
},
];
export const GUIDE_DIVISION_DOCUMENT: GuideStep[] = [
{
title: 'Dokumen Divisi',
description: 'Halaman ini menampilkan semua file dan folder dokumen milik divisi kamu.',
cardTopRatio: 0.35,
arrowDirection: 'none',
},
{
title: 'Buka File atau Folder',
description: 'Ketuk folder untuk masuk ke dalamnya, atau ketuk file untuk langsung membukanya.',
cardTopRatio: 0.35,
arrowDirection: 'none',
},
{
title: 'Pilih & Kelola',
description: 'Centang file atau folder untuk memilihnya, lalu gunakan menu di bawah untuk menghapus, mengganti nama, atau membagikan ke divisi lain.',
cardTopRatio: 0.35,
arrowDirection: 'none',
},
];
export const GUIDE_DIVISION_CALENDAR: GuideStep[] = [
{
title: 'Kalender Divisi',
description: 'Halaman ini menampilkan semua acara yang ada di divisi kamu dalam satu kalender.',
cardTopRatio: 0.50,
arrowDirection: 'none',
arrowOffset: 0.5,
},
{
title: 'Indikator Acara',
description: 'Garis di bawah tanggal menandakan ada acara pada hari tersebut. Ketuk tanggal untuk melihat daftarnya.',
cardTopRatio: 0.50,
arrowDirection: 'none',
arrowOffset: 0.55,
},
{
title: 'Detail Acara',
description: 'Ketuk salah satu acara di bawah kalender untuk melihat detail lengkapnya.',
cardTopRatio: 0.35,
arrowDirection: 'down',
arrowOffset: 0.55,
},
];
export const GUIDE_DISCUSSION: GuideStep[] = [
{
title: 'Daftar Diskusi',
description: 'Halaman ini menampilkan semua topik diskusi. Setiap card menunjukkan judul, deskripsi singkat, jumlah komentar, dan status diskusi.',
cardTopRatio: 0.35,
arrowDirection: 'none',
},
{
title: 'Buka Diskusi',
description: 'Ketuk salah satu diskusi untuk membaca detail dan ikut berkomentar.',
cardTopRatio: 0.35,
arrowDirection: 'none',
},
];
export const GUIDE_ANNOUNCEMENT: GuideStep[] = [
{
title: 'Daftar Pengumuman',
description: 'Halaman ini menampilkan semua pengumuman yang ada di desamu.',
cardTopRatio: 0.35,
arrowDirection: 'none',
},
{
title: 'Detail Pengumuman',
description: 'Ketuk salah satu pengumuman untuk membaca isi lengkapnya.',
cardTopRatio: 0.35,
arrowDirection: 'none',
},
];
export const GUIDE_MEMBER: GuideStep[] = [
{
title: 'Daftar Anggota',
description: 'Halaman ini menampilkan semua anggota desa. Gunakan tab untuk memfilter anggota aktif atau tidak aktif.',
cardTopRatio: 0.35,
arrowDirection: 'none',
},
{
title: 'Detail Anggota',
description: 'Ketuk salah satu anggota untuk melihat informasi lengkapnya seperti NIK, jabatan, lembaga, dan kontak.',
cardTopRatio: 0.35,
arrowDirection: 'none',
},
];
export const GUIDE_POSITION: GuideStep[] = [
{
title: 'Daftar Jabatan',
description: 'Halaman ini menampilkan semua jabatan yang terdaftar. Gunakan tab untuk memfilter jabatan aktif atau tidak aktif.',
cardTopRatio: 0.35,
arrowDirection: 'none',
},
{
title: 'Menu Aksi',
description: 'Ketuk salah satu jabatan untuk membuka menu aksi — kamu bisa mengaktifkan/menonaktifkan atau mengedit nama jabatan.',
cardTopRatio: 0.35,
arrowDirection: 'none',
},
];
export const GUIDE_GROUP: GuideStep[] = [
{
title: 'Daftar Lembaga Desa',
description: 'Halaman ini menampilkan semua lembaga desa yang terdaftar. Gunakan tab untuk memfilter yang aktif atau tidak aktif.',
cardTopRatio: 0.35,
arrowDirection: 'none',
},
{
title: 'Menu Aksi',
description: 'Ketuk salah satu lembaga untuk membuka menu aksi — kamu bisa mengaktifkan/menonaktifkan atau mengedit nama lembaga.',
cardTopRatio: 0.35,
arrowDirection: 'none',
},
];
export const GUIDE_BANNER: GuideStep[] = [
{
title: 'Daftar Banner',
description: 'Halaman ini menampilkan semua banner yang ada di desa kamu.',
cardTopRatio: 0.35,
arrowDirection: 'none',
},
{
title: 'Buka Menu Aksi',
description: 'Ketuk salah satu banner untuk membuka menu aksi — kamu bisa melihat, mengedit, atau menghapus banner.',
cardTopRatio: 0.35,
arrowDirection: 'none',
},
{
title: 'Tambah Banner',
description: 'Ketuk tombol tambah di pojok kanan atas untuk menambahkan banner baru.',
cardTopRatio: 0.15,
arrowDirection: 'up',
arrowOffset: 1.05,
},
];
export const GUIDE_PROJECT_DETAIL: GuideStep[] = [
{
title: 'Detail Kegiatan',
description: 'Halaman ini menampilkan informasi lengkap sebuah kegiatan, mulai dari progress, tugas, file, hingga anggota.',
cardTopRatio: 0.35,
arrowDirection: 'none',
},
{
title: 'Progress Kegiatan',
description: 'Bar ini menunjukkan seberapa banyak tugas yang sudah diselesaikan dari total tugas yang ada.',
cardTopRatio: 0.28,
arrowDirection: 'up',
arrowOffset: 0.5,
},
{
title: 'Daftar Tugas',
description: 'Semua tugas dalam kegiatan ini ditampilkan di sini. Ketuk tugas untuk melihat detail atau mengubah statusnya.',
cardTopRatio: 0.35,
arrowDirection: 'none',
},
{
title: 'File & Tautan',
description: 'File dan tautan pendukung kegiatan tersedia di bagian bawah. Scroll ke bawah untuk mengaksesnya.',
cardTopRatio: 0.35,
arrowDirection: 'none',
},
];

28
lib/useGuide.ts Normal file
View File

@@ -0,0 +1,28 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useEffect, useState } from 'react';
const KEY_PREFIX = '@guide:';
export function useGuide(featureKey: string) {
const [visible, setVisible] = useState(false);
const [checked, setChecked] = useState(false);
useEffect(() => {
AsyncStorage.getItem(KEY_PREFIX + featureKey).then((val) => {
if (!val) setVisible(true);
setChecked(true);
});
}, [featureKey]);
async function dismiss() {
setVisible(false);
await AsyncStorage.setItem(KEY_PREFIX + featureKey, 'done');
}
async function reset() {
await AsyncStorage.removeItem(KEY_PREFIX + featureKey);
setVisible(true);
}
return { visible: checked && visible, dismiss, reset };
}

View File

@@ -59,6 +59,7 @@
"expo-modules-core": "^2.5.0",
"expo-notifications": "~0.31.3",
"expo-router": "~5.1.4",
"expo-secure-store": "~14.2.4",
"expo-sharing": "^13.1.5",
"expo-splash-screen": "~0.30.8",
"expo-status-bar": "~2.2.3",