35 Commits

Author SHA1 Message Date
e135c9dc11 chore: bump versionCode 18 → 19 2026-06-02 16:56:54 +08:00
144db584d0 fix: paksa hapus READ_MEDIA_IMAGES dan READ_MEDIA_VIDEO dari merged manifest
expo-media-library (dependency transitif) menyuntikkan kedua permission ini
saat build. tools:node="remove" memastikan permission dihapus dari hasil merge.
2026-06-02 16:55:00 +08:00
90551399a5 fix: hapus permission READ_MEDIA_IMAGES dan READ_MEDIA_VIDEO untuk comply Google Play policy
versionCode bump 17 → 18
2026-06-02 16:24:26 +08:00
0d46d76c70 Merge pull request 'amalia/21-mei-26' (#52) from amalia/21-mei-26 into join
Reviewed-on: #52
2026-05-21 17:25:17 +08:00
e3d2752a4c style: kurangi borderRadius card dari 12 ke 8 agar tampilan lebih tegas 2026-05-21 14:04:25 +08:00
4dcfcbb7a7 style: standardisasi borderRadius approval dan notification — cards 10→12, notifIconContainer 21→100 2026-05-21 13:56:35 +08:00
f27707eb74 style: standardisasi borderRadius announcement — cards 10→12, iconBox 10→8, divisionCircle 13→100 2026-05-21 13:56:14 +08:00
e4e8b44c45 style: standardisasi borderRadius component — cards 10→12, icon circles 20/22→100, chip 5→20, guideCard 16→12 2026-05-21 13:55:49 +08:00
79c9c9046c style: standardisasi borderRadius modal — bottom sheet 18→20, confirm 14→12, decorativeCircle 150→100 2026-05-21 13:54:48 +08:00
f6122fb35a style: standardisasi borderRadius input — inputRoundForm 5→12, verificationCell 15→12 2026-05-21 13:54:18 +08:00
2ae88ff2e3 style: standardisasi borderRadius button — btnFiturMenu 15→20, labelStatus 10→20 2026-05-21 13:53:58 +08:00
fbf25bdd59 style: standardisasi borderRadius card — semua card/container ke 12, sectionBadge ke 20 2026-05-21 13:53:41 +08:00
600218cb11 style: tambah token borderRadius (round04, round12, roundFull) dan update round05 5→8 2026-05-21 13:52:30 +08:00
18eea92cfd feat: bump version ke 2.2.0 dan set default border ImageUser
- Versi app naik dari 2.1.0 ke 2.2.0 (versionCode 16→17, buildNumber 9→10)
- ImageUser: border default true agar avatar selalu tampil dengan border
2026-05-21 13:47:36 +08:00
1c3aa308d1 Merge pull request 'amalia/20-mei-26' (#51) from amalia/20-mei-26 into join
Reviewed-on: #51
2026-05-20 17:21:40 +08:00
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
67 changed files with 3340 additions and 3701 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

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

View File

@@ -1,9 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/>

View File

@@ -4,7 +4,7 @@ export default {
expo: {
name: "Desa+",
slug: "mobile-darmasaba",
version: "2.1.0", // Versi aplikasi (App Store)
version: "2.2.0", // Versi aplikasi (App Store)
jsEngine: "jsc",
orientation: "portrait",
icon: "./assets/images/logo-icon-small.png",
@@ -14,7 +14,7 @@ export default {
ios: {
supportsTablet: true,
bundleIdentifier: "mobiledarmasaba.app",
buildNumber: "9",
buildNumber: "10",
infoPlist: {
ITSAppUsesNonExemptEncryption: false,
CFBundleDisplayName: "Desa+"
@@ -23,7 +23,7 @@ export default {
},
android: {
package: "mobiledarmasaba.app",
versionCode: 16,
versionCode: 19,
adaptiveIcon: {
foregroundImage: "./assets/images/logo-icon-small.png",
backgroundColor: "#ffffff"
@@ -32,9 +32,7 @@ export default {
permissions: [
"READ_EXTERNAL_STORAGE",
"WRITE_EXTERNAL_STORAGE",
"READ_MEDIA_IMAGES", // Android 13+
"READ_MEDIA_VIDEO", // Android 13+
"READ_MEDIA_AUDIO" // Android 13+
"READ_MEDIA_AUDIO"
]
},
web: {

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,7 +1,6 @@
import GuideOverlay from "@/components/GuideOverlay";
import BorderBottomItem from "@/components/borderBottomItem";
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";
@@ -12,14 +11,14 @@ 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
}
@@ -28,10 +27,20 @@ export default function Announcement() {
const { colors } = useTheme();
const [search, setSearch] = useState('')
const update = useSelector((state: any) => state.announcementUpdate)
const isFirstRender = useRef(true)
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('announcement')
const arrSkeleton = Array.from({ length: 5 }, (_, index) => index)
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,
@@ -44,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,
@@ -57,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,
@@ -80,59 +76,80 @@ export default function Announcement() {
createdAt: flattenedData[index].createdAt,
})
return (
<View style={[Styles.p15, Styles.flex1, { backgroundColor: colors.background }]}>
<GuideOverlay visible={guideVisible} steps={GUIDE_ANNOUNCEMENT} onDismiss={dismissGuide} />
<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,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

@@ -87,11 +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
@@ -114,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>
@@ -154,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>
@@ -185,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, ' ')}
@@ -196,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,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

@@ -98,11 +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"}
@@ -125,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>
@@ -151,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>
@@ -171,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}
@@ -179,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

@@ -26,6 +26,7 @@ type Props = {
reason: string
status: number
isActive: boolean
idGroup: string
}
export default function DetailTaskDivision() {
@@ -159,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

@@ -142,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

@@ -185,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

@@ -150,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

@@ -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

@@ -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 = true, 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: 8, 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: 8 },
announcementDetailSkeletonGap: { gap: 8 },
announcementDetailSkeletonIconRow: { gap: 10, marginBottom: 2 },
announcementDetailTitleRow: { gap: 10, marginBottom: 10 },
announcementDetailIconBox: { width: 38, height: 38, borderRadius: 8 },
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: 100, 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: 8, 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,20 @@
import { StyleSheet } from "react-native";
const BorderStyles = StyleSheet.create({
round04: { borderRadius: 4 },
round05: { borderRadius: 8 },
round08: { borderRadius: 8 },
round10: { borderRadius: 10 },
round12: { borderRadius: 12 },
round15: { borderRadius: 15 },
round20: { borderRadius: 20 },
round30: { borderRadius: 30 },
roundFull: { borderRadius: 100 },
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: 20, 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: 20 },
labelStatusSmall: { paddingHorizontal: 10, paddingVertical: 3, borderRadius: 20 },
});
export default ButtonStyles;

View File

@@ -0,0 +1,159 @@
import { StyleSheet } from "react-native";
const CardStyles = StyleSheet.create({
wrapPaper: {
padding: 10,
backgroundColor: 'white',
borderRadius: 8,
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: 8,
marginBottom: 15,
},
wrapGridCaraousel: {
width: '95%',
height: 200,
shadowColor: '#171717',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.1,
shadowRadius: 5,
elevation: 2,
borderRadius: 8,
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: 8,
borderTopEndRadius: 8,
},
contentPaperGrid: {
height: 125,
borderBottomEndRadius: 8,
borderBottomStartRadius: 8,
paddingHorizontal: 20,
justifyContent: 'space-evenly',
},
contentPaperGrid2: {
height: 100,
borderBottomEndRadius: 8,
borderBottomStartRadius: 8,
paddingHorizontal: 20,
paddingVertical: 15,
justifyContent: 'flex-start',
},
wrapGridItem: {
borderWidth: 1,
borderRadius: 8,
padding: 10,
flexDirection: 'row',
alignItems: 'center',
width: '48.5%',
marginBottom: 10,
},
listItemCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'transparent',
borderRadius: 8,
borderWidth: 1,
paddingHorizontal: 12,
paddingVertical: 10,
gap: 12,
},
sectionCard: { borderRadius: 8, 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: 20, 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: 8,
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: 8,
alignItems: 'center',
gap: 10,
},
caraoselContent: {
flex: 1,
justifyContent: "center",
marginHorizontal: 15,
borderRadius: 8,
backgroundColor: '#19345E',
display: 'flex',
width: '92%',
resizeMode: 'stretch',
overflow: 'hidden',
},
wrapItemDiscussion: { padding: 15, borderRadius: 8, borderBottomWidth: 1 },
wrapItemBorderBottom: { padding: 10, borderBottomWidth: 1 },
wrapItemBorderAll: { padding: 10, borderWidth: 1, borderRadius: 8, 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: 8 },
iconContent: { padding: 10, borderRadius: 100, backgroundColor: '#E5E7EB' },
// chip
chip: {
paddingVertical: 5,
paddingHorizontal: 15,
borderRadius: 20,
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: 8,
padding: 2,
},
// badge & progress
badgeCol: { alignItems: 'center', gap: 6 },
progressBadge: { borderRadius: 8, paddingHorizontal: 12, paddingVertical: 5, borderWidth: 1, alignItems: 'center' },
taskCountBadge: { borderRadius: 8, 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: 8, borderWidth: 1, padding: 12, flexDirection: 'row', alignItems: 'center', gap: 10 },
// calendar
signDate: { width: 20, height: 2, borderRadius: 4, marginTop: 3 },
selectedDate: { backgroundColor: '#238be6', borderRadius: 4 },
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: 8, 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: 8, borderWidth: 1, padding: 14 },
discussionIconCircle: { width: 40, height: 40, borderRadius: 100, alignItems: 'center', justifyContent: 'center', flexShrink: 0 },
discussionIconCircleLg: { width: 44, height: 44, borderRadius: 100, 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: 8, 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: 8,
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: 12,
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: 12,
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: 20,
borderTopLeftRadius: 20,
position: 'absolute',
bottom: 0,
},
modalContentNew: {
width: '100%',
backgroundColor: 'white',
borderTopRightRadius: 20,
borderTopLeftRadius: 20,
paddingTop: 5,
paddingBottom: 5,
paddingHorizontal: 20,
},
modalFloatContent: {
backgroundColor: 'white',
borderRadius: 20,
paddingTop: 5,
paddingBottom: 10,
paddingHorizontal: 20,
},
titleContainer: {
backgroundColor: 'white',
borderTopRightRadius: 20,
borderTopLeftRadius: 20,
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: 8,
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: 100,
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: 8,
borderWidth: 1,
paddingHorizontal: 12,
paddingVertical: 10,
marginBottom: 6,
},
notifIconContainer: {
width: 42,
height: 42,
borderRadius: 100,
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

@@ -394,7 +394,7 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = mobiledarmasaba.app;
PRODUCT_NAME = Desa;
PRODUCT_NAME = "Desa";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Desa/Desa-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -429,7 +429,7 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = mobiledarmasaba.app;
PRODUCT_NAME = Desa;
PRODUCT_NAME = "Desa";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Desa/Desa-Bridging-Header.h";
SWIFT_VERSION = 5.0;

View File

@@ -19,7 +19,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>2.1.0</string>
<string>2.2.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@@ -39,7 +39,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>9</string>
<string>10</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSMinimumSystemVersion</key>

View File

@@ -1,880 +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 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;
};
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;
};