From fcd3dc7537f13033b295d398ae38b188c0bcb558 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 19 May 2026 11:55:58 +0800 Subject: [PATCH 1/8] fix: samakan jarak dan style label filter di discussion, division, member, position --- app/(application)/discussion/index.tsx | 4 ++-- app/(application)/division/index.tsx | 2 +- app/(application)/member/index.tsx | 2 +- app/(application)/position/index.tsx | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/(application)/discussion/index.tsx b/app/(application)/discussion/index.tsx index e1f296d..8320d0e 100644 --- a/app/(application)/discussion/index.tsx +++ b/app/(application)/discussion/index.tsx @@ -114,8 +114,8 @@ export default function Discussion() { )} {(entityUser.role == "supadmin" || entityUser.role == "developer") && ( - - Filter: + + Filter : )} diff --git a/app/(application)/division/index.tsx b/app/(application)/division/index.tsx index 05a6afd..08ea5a6 100644 --- a/app/(application)/division/index.tsx +++ b/app/(application)/division/index.tsx @@ -206,7 +206,7 @@ export default function ListDivision() { {(entityUser.role == "supadmin" || entityUser.role == "developer") && ( - + Filter : diff --git a/app/(application)/member/index.tsx b/app/(application)/member/index.tsx index a40be09..587463d 100644 --- a/app/(application)/member/index.tsx +++ b/app/(application)/member/index.tsx @@ -142,7 +142,7 @@ export default function Index() { { (entityUser.role == "supadmin" || entityUser.role == "developer") && - + Filter : diff --git a/app/(application)/position/index.tsx b/app/(application)/position/index.tsx index fc7cd65..f6cc17f 100644 --- a/app/(application)/position/index.tsx +++ b/app/(application)/position/index.tsx @@ -185,7 +185,7 @@ export default function Index() { { (entityUser.role == "supadmin" || entityUser.role == "developer") && - + Filter : From 2bacc47d75b729abfd5fd97b21a980fa05de27ec Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 19 May 2026 12:06:12 +0800 Subject: [PATCH 2/8] fix: sesuaikan warna border dengan tema pada halaman pilih anggota dan admin divisi --- app/(application)/division/create/add-admin-division.tsx | 2 +- app/(application)/division/create/add-member.tsx | 2 +- components/imageNew.tsx | 9 +++++++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/(application)/division/create/add-admin-division.tsx b/app/(application)/division/create/add-admin-division.tsx index 7c7950d..defdb82 100644 --- a/app/(application)/division/create/add-admin-division.tsx +++ b/app/(application)/division/create/add-admin-division.tsx @@ -117,7 +117,7 @@ export default function CreateDivisionAddAdmin() { return ( { !found && onChoose(item.idUser) }} diff --git a/app/(application)/division/create/add-member.tsx b/app/(application)/division/create/add-member.tsx index 920b29c..4af4c87 100644 --- a/app/(application)/division/create/add-member.tsx +++ b/app/(application)/division/create/add-member.tsx @@ -121,7 +121,7 @@ export default function CreateDivisionAddMember() { return ( { !found && onChoose(item.id, item.name, item.img) }} diff --git a/components/imageNew.tsx b/components/imageNew.tsx index 903a7ad..b741e56 100644 --- a/components/imageNew.tsx +++ b/components/imageNew.tsx @@ -1,4 +1,5 @@ import Styles from "@/constants/Styles"; +import { useTheme } from "@/providers/ThemeProvider"; import { useState } from "react"; import { Image } from "react-native"; @@ -9,12 +10,16 @@ type Props = { onError?: (val:boolean) => void } -export default function ImageUser({ src, size, onError }: Props) { +export default function ImageUser({ src, size, border, onError }: Props) { const [error, setError] = useState(false) + const { colors } = useTheme() return ( { setError(true) onError?.(true) From 0cb085caa8b4ffcc98cd3116d3106815db0d679d Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 19 May 2026 14:27:29 +0800 Subject: [PATCH 3/8] 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 --- app/(application)/announcement/[id].tsx | 331 ++++++++-------- app/(application)/announcement/create.tsx | 301 ++++++++------- app/(application)/announcement/edit/[id].tsx | 373 +++++++++---------- app/(application)/announcement/index.tsx | 169 +++++---- constants/styles/announcement.styles.ts | 39 ++ constants/styles/button.styles.ts | 4 +- constants/styles/index.ts | 2 + 7 files changed, 609 insertions(+), 610 deletions(-) create mode 100644 constants/styles/announcement.styles.ts diff --git a/app/(application)/announcement/[id].tsx b/app/(application)/announcement/[id].tsx index 096f612..12ef184 100644 --- a/app/(application)/announcement/[id].tsx +++ b/app/(application)/announcement/[id].tsx @@ -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([]) 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() 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 ( - + { router.back() }} />, - headerTitle: 'Pengumuman', - headerTitleAlign: 'center', - // headerRight: () => entityUser.role != 'user' && entityUser.role != 'coadmin' ? : <>, header: () => ( - router.back()} - right={entityUser.role != 'user' && entityUser.role != 'coadmin' ? : <>} + right={entityUser.role != 'user' && entityUser.role != 'coadmin' + ? + : <> + } /> ) }} /> handleRefresh()} - tintColor={colors.icon} - /> + } > {isError && !loading ? ( - + ) : ( - - - { - loading ? - - - - - - - - - - - - : - <> - - - {data?.title} - - - { - hasHtmlTags(data?.desc) ? - - : - {data?.desc} - } - - - } + - - { - dataFile.length > 0 && ( - - - File + {/* Title + Description */} + + {loading ? ( + + + + - {dataFile.map((item, index) => ( - } - title={item.name + '.' + item.extension} - titleWeight="normal" - onPress={() => { - isImageFile(item.extension) ? - handleChooseFile(item) - : openFile(item) - }} - /> - ))} + + + - ) - } - - { - loading ? - arrSkeleton.map((item, index) => { - return ( - - - - - - ) - }) - : - Object.keys(dataMember).map((v: any, i: any) => { - return ( - - {dataMember[v]?.[0].group} - { - dataMember[v].map((item: any, x: any) => { - return ( - - - {item.division} - - ) - }) - } - - - ) - }) - } + ) : ( + <> + + + + + + {data.title} + + + {hasHtmlTags(data.desc) + ? + : {data.desc} + } + + )} + + {/* Files */} + {dataFile.length > 0 && ( + + + + + Lampiran ({dataFile.length}) + + + + + + {dataFile.map((item, index) => ( + isImageFile(item.extension) ? handleChooseFile(item) : openFile(item)} + style={({ pressed }) => [Styles.announcementDetailFileChip, themed.fileChipBorder, + pressed ? themed.fileChipPressed : themed.background]} + > + + + {item.name}.{item.extension} + + + ))} + + + + + )} + + {/* Recipients */} + + + + + Ditujukan Kepada + + + + {loading ? ( + + + + + + + ) : ( + Object.keys(dataMember).map((v, i) => ( + 0 ? [Styles.announcementDetailGroupSeparator, themed.groupSeparator] : undefined}> + + {dataMember[v]?.[0].group} + + + {dataMember[v].map((item, x) => ( + + + + + + {item.division} + + + ))} + + + )) + )} + + + )} @@ -323,38 +302,26 @@ export default function DetailAnnouncement() { visible={preview} onRequestClose={() => setPreview(false)} doubleTapToZoomEnabled - HeaderComponent={({ imageIndex }) => ( - - {/* CLOSE */} - setPreview(false)} - accessibilityRole="button" - accessibilityLabel="Close image viewer" - > + HeaderComponent={() => ( + + setPreview(false)} accessibilityRole="button"> - - {/* MENU */} chooseFile && openFile(chooseFile)} accessibilityRole="button" - accessibilityLabel="Download or share image" disabled={loadingOpen} > - + )} - FooterComponent={({ imageIndex }) => ( - + FooterComponent={() => ( + {chooseFile?.name}.{chooseFile?.extension} )} /> ) -} \ No newline at end of file +} diff --git a/app/(application)/announcement/create.tsx b/app/(application)/announcement/create.tsx index 3114d06..f487e4f 100644 --- a/app/(application)/announcement/create.tsx +++ b/app/(application)/announcement/create.tsx @@ -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,70 @@ export default function CreateAnnouncement() { const { colors } = useTheme(); const [disableBtn, setDisableBtn] = useState(true); const [modalDivisi, setModalDivisi] = useState(false); - const [divisionMember, setDivisionMember] = useState([]) + const [divisionMember, setDivisionMember] = useState([]) const [loading, setLoading] = useState(false) const [fileForm, setFileForm] = useState([]) const [isModalFile, setModalFile] = useState(false) const [indexDelFile, setIndexDelFile] = useState(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]]) - } + for (const asset of result.assets) { + if (asset.uri) setFileForm(prev => [...prev, asset]) } } }; function deleteFile(index: number) { - setFileForm([...fileForm.filter((val, i) => i !== index)]) + setFileForm(fileForm.filter((_, i) => i !== index)) setModalFile(false) } @@ -138,26 +117,6 @@ export default function CreateAnnouncement() { ( - // { - // router.back(); - // }} - // /> - // ), - headerTitle: "Tambah Pengumuman", - headerTitleAlign: "center", - // headerRight: () => ( - // { - // divisionMember.length == 0 - // ? Toast.show({ type: 'small', text1: "Anda belum memilih divisi", }) - // : handleCreate(); - // }} - // /> - // ), header: () => ( router.back()} right={ { - 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 +138,9 @@ export default function CreateAnnouncement() { }} /> {loading && } - - + + + validationForm("title", val)} /> + validationForm("desc", val)} multiline /> - - { - fileForm.length > 0 - && - <> - - File - {fileForm.length} file + + {/* File */} + + 0 ? 12 : 0 }]} + > + + - - { - fileForm.map((item, index) => ( - + File + {fileForm.length === 0 && ( + Opsional — ketuk untuk upload + )} + + {fileForm.length > 0 && ( + + {fileForm.length} file + + )} + + + {fileForm.length > 0 && ( + + {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 ( + } - title={item.name} - bgColor="transparent" - titleWeight="normal" onPress={() => { setIndexDelFile(index); setModalFile(true) }} - /> - )) - } - - - } - - { - setModalDivisi(true) - }} - /> - - { - divisionMember.length > 0 - && - <> - - Divisi - - - { - divisionMember.map((item: { name: any; Division: any }, index: any) => { - return ( - - {item.name} - { - item.Division.map((division: any, i: any) => ( - - - {division.name} - - )) - } + style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]} + > + + - ) - }) - } + + {baseName} + {ext.toUpperCase()} + + + ) + })} - - } + )} + + + {/* Divisi Penerima */} + + setModalDivisi(true)} + style={[Styles.sectionActionRow, { marginBottom: divisionMember.length > 0 ? 12 : 0 }]} + > + + + + + Divisi Penerima + {divisionMember.length === 0 && ( + Belum ada divisi dipilih + )} + + {divisionMember.length > 0 && ( + + {totalDivisi} divisi + + )} + + + {divisionMember.length > 0 && ( + + {divisionMember.map((item: any, index: number) => ( + + + {item.name} + + + {item.Division.map((division: any, i: number) => ( + + + + + + {division.name} + + + ))} + + + ))} + + )} + + @@ -287,12 +282,10 @@ export default function CreateAnnouncement() { } title="Hapus" - onPress={() => { deleteFile(indexDelFile) }} + onPress={() => deleteFile(indexDelFile)} /> ); } - - diff --git a/app/(application)/announcement/edit/[id].tsx b/app/(application)/announcement/edit/[id].tsx index 60c850d..ff90f89 100644 --- a/app/(application)/announcement/edit/[id].tsx +++ b/app/(application)/announcement/edit/[id].tsx @@ -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([]); + const [dataMember, setDataMember] = useState([]); const [fileForm, setFileForm] = useState([]) 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,47 @@ 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]]) - } + for (const asset of result.assets) { + if (asset.uri) setFileForm(prev => [...prev, asset]) } } }; 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 ( - + ( - // { - // router.back(); - // }} - // /> - // ), - headerTitle: "Edit Pengumuman", - headerTitleAlign: "center", - // headerRight: () => ( - // { - // dataMember.length == 0 - // ? Toast.show({ type: 'small', text1: "Anda belum memilih divisi", }) - // : handleEdit(); - // }} - // /> - // ), header: () => ( router.back()} right={ { - 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 +171,9 @@ export default function EditAnnouncement() { }} /> {loading && } - - + + + validationForm("title", val)} value={dataForm.title} /> + - - { - (fileForm.length > 0 || dataFile.filter((val) => !val.delete).length > 0) - && - <> - - File - {fileForm.length + dataFile.filter((val) => !val.delete).length} file + + {/* File */} + + 0 ? 12 : 0 }]} + > + + - - { - dataFile.filter((val) => !val.delete).map((item, index) => ( - !val.delete).length - 1 == index && fileForm.length == 0 ? "none" : "bottom"} - icon={} - title={item.name + '.' + item.extension} - titleWeight="normal" - bgColor="transparent" + + File + {totalFiles === 0 && ( + Opsional — ketuk untuk upload + )} + + {totalFiles > 0 && ( + + {totalFiles} file + + )} + + + {totalFiles > 0 && ( + + {visibleOldFiles.map((item, index) => { + const ext = item.extension.toLowerCase() + const iconName = getFileIcon(ext) + const iconColor = getFileColor(ext) + return ( + { setIndexDelFile({ id: item.id, cat: "oldFile" }); setModalFile(true) }} - /> - )) - } - { - fileForm.map((item, index) => ( - } - title={item.name} - titleWeight="normal" - bgColor="transparent" - onPress={() => { setIndexDelFile({ id: index, cat: "newFile" }); setModalFile(true) }} - /> - )) - } - - - } - { - setModalDivisi(true) - }} - /> - { - dataMember.length > 0 - && - <> - - Divisi - - - { - dataMember.map((item: { name: any; Division: any }, index: any) => { - return ( - - {item.name} - { - item.Division.map((division: any, i: any) => ( - - - {division.name} - - )) - } + style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]} + > + + - ) - }) - } + + {item.name} + {ext.toUpperCase()} + + + ) + })} + {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 ( + { setIndexDelFile({ id: index, cat: "newFile" }); setModalFile(true) }} + style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]} + > + + + + + {baseName} + {ext.toUpperCase()} + + + ) + })} - - } + )} + + + {/* Divisi Penerima */} + + setModalDivisi(true)} + style={[Styles.sectionActionRow, { marginBottom: dataMember.length > 0 ? 12 : 0 }]} + > + + + + + Divisi Penerima + {dataMember.length === 0 && ( + Belum ada divisi dipilih + )} + + {dataMember.length > 0 && ( + + {totalDivisi} divisi + + )} + + + {dataMember.length > 0 && ( + + {dataMember.map((item, index) => ( + + + {item.name} + + + {item.Division.map((division, i) => ( + + + + + + {division.name} + + + ))} + + + ))} + + )} + + @@ -353,7 +338,7 @@ export default function EditAnnouncement() { } title="Hapus" - onPress={() => { deleteFile(indexDelFile.id, indexDelFile.cat) }} + onPress={() => deleteFile(indexDelFile.id, indexDelFile.cat)} /> diff --git a/app/(application)/announcement/index.tsx b/app/(application)/announcement/index.tsx index 0963911..23389e6 100644 --- a/app/(application)/announcement/index.tsx +++ b/app/(application)/announcement/index.tsx @@ -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"; @@ -13,13 +12,13 @@ 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 { 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 } @@ -29,9 +28,18 @@ export default function Announcement() { const [search, setSearch] = useState('') const update = useSelector((state: any) => state.announcementUpdate) 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 +52,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 +61,9 @@ export default function Announcement() { }, }) - // Trigger refetch when Redux state 'update' changes - useEffect(() => { - refetch() - }, [update, refetch]) + useEffect(() => { refetch() }, [update, refetch]) - // 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 +72,80 @@ export default function Announcement() { createdAt: flattenedData[index].createdAt, }) - return ( - - - - + const renderSkeleton = () => ( + + + + + + + - - { - isLoading && !flattenedData.length ? - arrSkeleton.map((item, index) => { - return ( - - ) - }) - : - flattenedData.length > 0 - ? - flattenedData.length} - getItem={getItem} - renderItem={({ item, index }: { item: Props, index: number }) => { - return ( - { router.push(`/announcement/${item.id}`) }} - borderType="bottom" - bgColor="transparent" - icon={ - - } - 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={ - - } + + + + ) + + const renderItem = ({ item }: { item: Props }) => ( + router.push(`/announcement/${item.id}`)} + style={({ pressed }) => [Styles.announcementListCard, themed.card, pressed && themed.cardPressed]} + > + + + + + + + {item.title} + + + + {item.createdAt} + + + + {item.desc.replace(/<[^>]*>?/gm, '').replace(/\r?\n|\r/g, ' ')} + + + ) + + return ( + + + + + {isLoading && !flattenedData.length ? ( + arrSkeleton.map((_, i) => ( + + {renderSkeleton()} + + )) + ) : flattenedData.length > 0 ? ( + flattenedData.length} + getItem={getItem} + renderItem={renderItem} + keyExtractor={(item, index) => String(item.id || index)} + onEndReached={() => { if (hasNextPage && !isFetchingNextPage) fetchNextPage() }} + onEndReachedThreshold={0.5} + showsVerticalScrollIndicator={false} + ItemSeparatorComponent={() => } + refreshControl={ + - : - Tidak ada pengumuman - } + } + /> + ) : ( + + Tidak ada pengumuman + + )} ) -} \ No newline at end of file +} diff --git a/constants/styles/announcement.styles.ts b/constants/styles/announcement.styles.ts new file mode 100644 index 0000000..a8274dd --- /dev/null +++ b/constants/styles/announcement.styles.ts @@ -0,0 +1,39 @@ +import { StyleSheet } from "react-native"; + +const AnnouncementStyles = StyleSheet.create({ + // list (index.tsx) + announcementListContainer: { padding: 15, paddingBottom: 0 }, + announcementListInner: { marginTop: 10 }, + announcementListCard: { borderRadius: 10, borderWidth: 1, padding: 12 }, + announcementListCardHeader: { marginBottom: 6 }, + announcementListTitleRow: { flex: 1, gap: 8, marginRight: 8, flexDirection: 'row', alignItems: 'center' }, + announcementListIconBox: { width: 28, height: 28, borderRadius: 8, alignItems: 'center', justifyContent: 'center' }, + announcementListTitleText: { flex: 1 }, + announcementListDateText: { flexShrink: 0 }, + announcementListDescText: { lineHeight: 20 }, + announcementListSeparator: { height: 8 }, + announcementListSkeletonCard: { gap: 8 }, + announcementListSkeletonHeader: { marginBottom: 4 }, + announcementListSkeletonTitleRow: { gap: 8, flex: 1, flexDirection: 'row', alignItems: 'center' }, + + // detail ([id].tsx) + announcementDetailContainer: { padding: 15, paddingBottom: 50, gap: 12 }, + announcementDetailCard: { borderRadius: 10 }, + announcementDetailSkeletonGap: { gap: 8 }, + announcementDetailSkeletonIconRow: { gap: 10, marginBottom: 2 }, + announcementDetailTitleRow: { gap: 10, marginBottom: 10 }, + announcementDetailIconBox: { width: 38, height: 38, borderRadius: 10 }, + announcementDetailTitleText: { fontSize: 17, lineHeight: 24, flex: 1 }, + announcementDetailSectionLabelRow: { marginBottom: 8, gap: 6 }, + announcementDetailFileCardPadding: { padding: 10 }, + announcementDetailFileChipList: { gap: 8 }, + announcementDetailFileChip: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingHorizontal: 12, paddingVertical: 8, borderRadius: 20, borderWidth: 1 }, + announcementDetailFileChipText: { maxWidth: 120 }, + announcementDetailRecipientGap: { gap: 10 }, + announcementDetailGroupSeparator: { marginTop: 12, paddingTop: 12, borderTopWidth: 1 }, + announcementDetailGroupLabel: { marginBottom: 6 }, + announcementDetailDivisionRow: { gap: 8, paddingVertical: 6 }, + announcementDetailDivisionIconCircle: { width: 26, height: 26, borderRadius: 13, alignItems: 'center', justifyContent: 'center' }, +}); + +export default AnnouncementStyles; diff --git a/constants/styles/button.styles.ts b/constants/styles/button.styles.ts index b21a560..e3466cd 100644 --- a/constants/styles/button.styles.ts +++ b/constants/styles/button.styles.ts @@ -37,8 +37,8 @@ const ButtonStyles = StyleSheet.create({ padding: 5, borderWidth: 1, }, - labelStatus: { paddingHorizontal: 15, borderRadius: 10 }, - labelStatusSmall: { paddingHorizontal: 10, borderRadius: 10 }, + labelStatus: { paddingHorizontal: 15, paddingVertical: 4, borderRadius: 10 }, + labelStatusSmall: { paddingHorizontal: 10, paddingVertical: 3, borderRadius: 10 }, }); export default ButtonStyles; diff --git a/constants/styles/index.ts b/constants/styles/index.ts index 226f46c..b60e8ca 100644 --- a/constants/styles/index.ts +++ b/constants/styles/index.ts @@ -11,6 +11,7 @@ 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, @@ -25,6 +26,7 @@ const Styles = StyleSheet.create({ ...ComponentStyles, ...NotificationStyles, ...ApprovalStyles, + ...AnnouncementStyles, }); export default Styles; From 165f423798ce03fb15e6ed4c640c8348744849a5 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 19 May 2026 14:39:23 +0800 Subject: [PATCH 4/8] 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. --- app/(application)/announcement/create.tsx | 9 ++++++++- app/(application)/announcement/edit/[id].tsx | 11 ++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/app/(application)/announcement/create.tsx b/app/(application)/announcement/create.tsx index f487e4f..860057f 100644 --- a/app/(application)/announcement/create.tsx +++ b/app/(application)/announcement/create.tsx @@ -102,9 +102,16 @@ export default function CreateAnnouncement() { 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) setFileForm(prev => [...prev, asset]) + 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' }) } }; diff --git a/app/(application)/announcement/edit/[id].tsx b/app/(application)/announcement/edit/[id].tsx index ff90f89..3f106e2 100644 --- a/app/(application)/announcement/edit/[id].tsx +++ b/app/(application)/announcement/edit/[id].tsx @@ -131,9 +131,18 @@ export default function EditAnnouncement() { 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) setFileForm(prev => [...prev, asset]) + 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' }) } }; From 6d0203cc7d8e5ccae85b7d817b242d86016477fb Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 19 May 2026 14:41:39 +0800 Subject: [PATCH 5/8] 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. --- app/(application)/announcement/index.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/(application)/announcement/index.tsx b/app/(application)/announcement/index.tsx index 23389e6..05bb835 100644 --- a/app/(application)/announcement/index.tsx +++ b/app/(application)/announcement/index.tsx @@ -11,7 +11,7 @@ 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 { useEffect, useMemo, useRef, useState } from "react"; import { Pressable, RefreshControl, View, VirtualizedList } from "react-native"; import { useSelector } from "react-redux"; @@ -27,6 +27,7 @@ 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 }, (_, i) => i) @@ -61,7 +62,10 @@ export default function Announcement() { }, }) - useEffect(() => { refetch() }, [update, refetch]) + useEffect(() => { + if (isFirstRender.current) { isFirstRender.current = false; return } + refetch() + }, [update]) const flattenedData = useMemo(() => data?.pages.flat() || [], [data]) From d272b96e539dddf58ca1beaee0bad18647cabb74 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 19 May 2026 14:47:37 +0800 Subject: [PATCH 6/8] 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) --- app/(application)/discussion/index.tsx | 43 ++++++++++++------- .../(fitur-division)/discussion/index.tsx | 37 ++++++++++------ constants/styles/component.styles.ts | 5 +++ 3 files changed, 57 insertions(+), 28 deletions(-) diff --git a/app/(application)/discussion/index.tsx b/app/(application)/discussion/index.tsx index 8320d0e..fe1a002 100644 --- a/app/(application)/discussion/index.tsx +++ b/app/(application)/discussion/index.tsx @@ -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 ( - + {/* Header controls */} - + {entityUser.role != "user" && entityUser.role != "coadmin" && ( {/* List */} - + {isLoading ? ( [0, 1, 2, 3, 4].map((_, i) => ) ) : flatData.length === 0 ? ( - + Tidak ada diskusi @@ -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 */} {/* Discussion icon */} - + {/* Title + status badge */} - - + + {item.title} {status !== "false" && ( - - + + {isOpen(item) ? 'Buka' : 'Tutup'} @@ -185,7 +196,7 @@ export default function Discussion() { {/* Description */} {item.desc ? ( {item.desc.replace(/<[^>]*>?/gm, ' ').replace(/\r?\n|\r/g, ' ')} @@ -196,11 +207,11 @@ export default function Discussion() { - + {item.total_komentar} Komentar - + {item.createdAt} diff --git a/app/(application)/division/[id]/(fitur-division)/discussion/index.tsx b/app/(application)/division/[id]/(fitur-division)/discussion/index.tsx index 41f7445..a8faaa2 100644 --- a/app/(application)/division/[id]/(fitur-division)/discussion/index.tsx +++ b/app/(application)/division/[id]/(fitur-division)/discussion/index.tsx @@ -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 ( - + {((entityUser.role != "user" && entityUser.role != "coadmin") || isAdminDivision) && ( - + )} - + {loading ? ( arrSkeleton.map((_, i) => ) ) : data.length === 0 ? ( - + Tidak ada diskusi @@ -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, ]} > - - + + {item.user_name} {status === "true" && ( - - + + {isOpen(item) ? 'Buka' : 'Tutup'} @@ -171,7 +184,7 @@ export default function DiscussionDivision() { {item.desc ? ( - + {item.desc} ) : null} @@ -179,11 +192,11 @@ export default function DiscussionDivision() { - + {item.total_komentar} Komentar - + {item.createdAt} diff --git a/constants/styles/component.styles.ts b/constants/styles/component.styles.ts index 1fc0369..4851fa9 100644 --- a/constants/styles/component.styles.ts +++ b/constants/styles/component.styles.ts @@ -86,6 +86,11 @@ const ComponentStyles = StyleSheet.create({ discussionDateText: { fontSize: 11 }, discussionCommentCard: { borderRadius: 10, borderWidth: 1, padding: 12, marginBottom: 8, flexDirection: 'row' }, discussionEditedText: { fontSize: 10, fontStyle: 'italic' }, + discussionHeaderPadding: { paddingTop: 12 }, + discussionListPadding: { paddingTop: 8 }, + discussionTitleCol: { marginLeft: 10 }, + discussionDescMargin: { marginBottom: 10 }, + discussionEmptyText: { fontSize: 14 }, // guide overlay guideOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', alignItems: 'center' }, From 2be59b5ac6f2d9156df806a7daeecc6dc4155da1 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 19 May 2026 15:10:18 +0800 Subject: [PATCH 7/8] 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 --- app/(application)/discussion/create.tsx | 364 ++++++++++----------- app/(application)/discussion/edit/[id].tsx | 273 ++++++++-------- 2 files changed, 302 insertions(+), 335 deletions(-) diff --git a/app/(application)/discussion/create.tsx b/app/(application)/discussion/create.tsx index 421c1ca..6a0fa7a 100644 --- a/app/(application)/discussion/create.tsx +++ b/app/(application)/discussion/create.tsx @@ -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([]) const [isModalFile, setModalFile] = useState(false) const [indexDelFile, setIndexDelFile] = useState(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() { ( - // { handleBack() }} - // /> - // ), - headerTitle: "Tambah Diskusi", - headerTitleAlign: "center", - // headerRight: () => ( - // { - // entitiesMember.length == 0 - // ? Toast.show({ type: 'small', text1: 'Anda belum memilih anggota', }) - // : handleCreate() - // }} - // /> - // ), header: () => ( router.back()} + onPressLeft={() => { dispatch(setMemberChoose([])); router.back() }} right={ { - 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 && } - { - (entityUser.role == "supadmin" || - entityUser.role == "developer") && ( - { - setValChoose(chooseGroup.val); - setValSelect("group"); - setSelect(true); - }} - error={error.group} - errorText="Lembaga Desa tidak boleh kosong" - /> - ) - } + + {(entityUser.role === "supadmin" || entityUser.role === "developer") && ( + { setValChoose(chooseGroup.val); setValSelect("group"); setSelect(true) }} + error={error.group} + errorText="Lembaga Desa tidak boleh kosong" + /> + )} + { validationForm("title", val) }} + onChange={(val) => validationForm("title", val)} /> + { validationForm("desc", val) }} + onChange={(val) => validationForm("desc", val)} multiline /> - - { - fileForm.length > 0 - && - <> - - File - {fileForm.length} file + + {/* File */} + + 0 ? 12 : 0 }]} + > + + - - { - fileForm.map((item, index) => ( - + File + {fileForm.length === 0 && ( + Opsional — ketuk untuk upload + )} + + {fileForm.length > 0 && ( + + {fileForm.length} file + + )} + + + {fileForm.length > 0 && ( + + {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 ( + } - title={item.name} - bgColor="transparent" - titleWeight="normal" onPress={() => { setIndexDelFile(index); setModalFile(true) }} - /> - )) - } + style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]} + > + + + + + {baseName} + {ext.toUpperCase()} + + + ) + })} - - } - { - 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"); - } + )} + - }} - /> - { - entitiesMember.length > 0 && - - - Anggota - {entitiesMember.length} Anggota + {/* Anggota */} + + 0 ? 12 : 0 }]} + > + + + + Anggota + {entitiesMember.length === 0 && ( + Belum ada anggota dipilih + )} + + {entitiesMember.length > 0 && ( + + {entitiesMember.length} anggota + + )} + + + {entitiesMember.length > 0 && ( + + {entitiesMember.map((item: any, index: number) => ( + + + + {item.name} + + + ))} + + )} + - - { - entitiesMember.map((item: { img: any; name: any; }, index: any) => { - return ( - - } - title={item.name} - bgColor="transparent" - /> - ) - }) - } - - - } { - 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() { } title="Hapus" - onPress={() => { deleteFile(indexDelFile) }} + onPress={() => deleteFile(indexDelFile)} /> diff --git a/app/(application)/discussion/edit/[id].tsx b/app/(application)/discussion/edit/[id].tsx index 5d1641e..dbee704 100644 --- a/app/(application)/discussion/edit/[id].tsx +++ b/app/(application)/discussion/edit/[id].tsx @@ -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() { ( - // { - // router.back(); - // }} - // /> - // ), - headerTitle: "Edit Diskusi", - headerTitleAlign: "center", - // headerRight: () => ( - // { handleEdit() }} - // /> - // ), header: () => ( router.back()} right={ { handleEdit() }} + onPress={() => handleEdit()} /> } /> @@ -205,7 +162,8 @@ export default function EditDiscussionGeneral() { /> {loading && } - + + validationForm("title", val)} /> + validationForm("desc", val)} multiline /> - - { - (fileForm.length > 0 || dataFile.filter((val) => !val.delete).length > 0) - && - <> - - File - {fileForm.length + dataFile.filter((val) => !val.delete).length} file + + {/* File */} + + 0 ? 12 : 0 }]} + > + + - - { - dataFile.filter((val) => !val.delete).map((item, index) => ( - !val.delete).length - 1 == index && fileForm.length == 0 ? "none" : "bottom"} - icon={} - title={item.name + '.' + item.extension} - titleWeight="normal" - bgColor="transparent" + + File + {totalFiles === 0 && ( + Opsional — ketuk untuk upload + )} + + {totalFiles > 0 && ( + + {totalFiles} file + + )} + + + {totalFiles > 0 && ( + + {visibleOldFiles.map((item, index) => { + const ext = item.extension.toLowerCase() + const iconName = getFileIcon(ext) + const iconColor = getFileColor(ext) + return ( + { setIndexDelFile({ id: item.id, cat: "oldFile" }); setModalFile(true) }} - /> - )) - } - { - fileForm.map((item, index) => ( - } - title={item.name} - titleWeight="normal" - bgColor="transparent" + style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]} + > + + + + + {item.name} + {ext.toUpperCase()} + + + ) + })} + {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 ( + { setIndexDelFile({ id: index, cat: "newFile" }); setModalFile(true) }} - /> - )) - } + style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]} + > + + + + + {baseName} + {ext.toUpperCase()} + + + ) + })} - - } + )} + + @@ -276,7 +267,7 @@ export default function EditDiscussionGeneral() { } title="Hapus" - onPress={() => { deleteFile(indexDelFile.id, indexDelFile.cat) }} + onPress={() => deleteFile(indexDelFile.id, indexDelFile.cat)} /> From a61c194ece1a2df333c462b2582592b735f12ba1 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 19 May 2026 15:17:07 +0800 Subject: [PATCH 8/8] feat: redesign halaman tambah dan edit diskusi divisi dengan pola sectionCard dan fileGrid --- .../discussion/[detail]/edit.tsx | 258 +++++++++--------- .../(fitur-division)/discussion/create.tsx | 161 ++++++----- 2 files changed, 219 insertions(+), 200 deletions(-) diff --git a/app/(application)/division/[id]/(fitur-division)/discussion/[detail]/edit.tsx b/app/(application)/division/[id]/(fitur-division)/discussion/[detail]/edit.tsx index 360a9d3..5bddeb3 100644 --- a/app/(application)/division/[id]/(fitur-division)/discussion/[detail]/edit.tsx +++ b/app/(application)/division/[id]/(fitur-division)/discussion/[detail]/edit.tsx @@ -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 ( - + ( - // { - // router.back(); - // }} - // /> - // ), - headerTitle: "Edit Diskusi", - headerTitleAlign: "center", - // headerRight: () => ( - // { - // handleUpdate(); - // }} - // /> - // ), header: () => ( { - handleUpdate(); - }} + onPress={() => handleUpdate()} /> } /> @@ -171,8 +141,8 @@ export default function DiscussionDivisionEdit() { }} /> {loading && } - - + + - - { - (fileForm.length > 0 || dataFile.filter((val) => !val.delete).length > 0) - && - <> - - File - {fileForm.length + dataFile.filter((val) => !val.delete).length} file + {/* File */} + + 0 ? 12 : 0 }]} + > + + - - { - dataFile.filter((val) => !val.delete).map((item, index) => ( - !val.delete).length - 1 == index && fileForm.length == 0 ? "none" : "bottom"} - icon={} - title={item.name + '.' + item.extension} - titleWeight="normal" + + File + {totalFiles === 0 && ( + Opsional — ketuk untuk upload + )} + + {totalFiles > 0 && ( + + {totalFiles} file + + )} + + + {totalFiles > 0 && ( + + {visibleOldFiles.map((item, index) => { + const ext = item.extension.toLowerCase() + const iconName = getFileIcon(ext) + const iconColor = getFileColor(ext) + return ( + { setIndexDelFile({ id: item.id, cat: "oldFile" }); setModalFile(true) }} - /> - )) - } - { - fileForm.map((item, index) => ( - } - title={item.name} - titleWeight="normal" + style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]} + > + + + + + {item.name} + {ext.toUpperCase()} + + + ) + })} + {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 ( + { setIndexDelFile({ id: index, cat: "newFile" }); setModalFile(true) }} - /> - )) - } + style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]} + > + + + + + {baseName} + {ext.toUpperCase()} + + + ) + })} - - } + )} + - } title="Hapus" - onPress={() => { deleteFile(indexDelFile.id, indexDelFile.cat) }} + onPress={() => deleteFile(indexDelFile.id, indexDelFile.cat)} /> - ); } diff --git a/app/(application)/division/[id]/(fitur-division)/discussion/create.tsx b/app/(application)/division/[id]/(fitur-division)/discussion/create.tsx index 61d1b0a..4d5d487 100644 --- a/app/(application)/division/[id]/(fitur-division)/discussion/create.tsx +++ b/app/(application)/division/[id]/(fitur-division)/discussion/create.tsx @@ -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(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 ( - + { router.back() }} />, - headerTitle: 'Tambah Diskusi', - headerTitleAlign: 'center', - // headerRight: () => { - // handleCreate() - // }} /> header: () => ( { - handleCreate() - }} /> + onPress={() => handleCreate()} + /> } /> ) }} /> {loading && } - + - - { - fileForm.length > 0 - && - <> - - File - {fileForm.length} file - - - { - fileForm.map((item, index) => ( - } - title={item.name} - titleWeight="normal" - onPress={() => { setIndexDelFile(index); setModalFile(true) }} - /> - )) - } - - - } + {/* File */} + + 0 ? 12 : 0 }]} + > + + + + + File + {fileForm.length === 0 && ( + Opsional — ketuk untuk upload + )} + + {fileForm.length > 0 && ( + + {fileForm.length} file + + )} + + + {fileForm.length > 0 && ( + + {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 ( + { setIndexDelFile(index); setModalFile(true) }} + style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]} + > + + + + + {baseName} + {ext.toUpperCase()} + + + ) + })} + + )} + @@ -167,10 +186,10 @@ export default function CreateDiscussionDivision() { } title="Hapus" - onPress={() => { deleteFile(indexDelFile) }} + onPress={() => deleteFile(indexDelFile)} /> ) -} \ No newline at end of file +}