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;