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..860057f 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,77 @@ 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]]) + let skipped = 0 + for (const asset of result.assets) { + if (!asset.uri) continue + if (fileForm.some(f => f.name === asset.name)) { + skipped++ + } else { + setFileForm(prev => [...prev, asset]) } } + if (skipped > 0) Toast.show({ type: 'small', text1: 'Beberapa file sudah ditambahkan' }) } }; function deleteFile(index: number) { - setFileForm([...fileForm.filter((val, i) => i !== index)]) + setFileForm(fileForm.filter((_, i) => i !== index)) setModalFile(false) } @@ -138,26 +124,6 @@ export default function CreateAnnouncement() { ( - // { - // 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 +145,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 +289,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..3f106e2 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,56 @@ export default function EditAnnouncement() { const hasil = await decryptToken(String(token?.current)) const fd = new FormData() for (let i = 0; i < fileForm.length; i++) { - fd.append(`file${i}`, { - uri: fileForm[i].uri, - type: 'application/octet-stream', - name: fileForm[i].name, - } as any); + fd.append(`file${i}`, { uri: fileForm[i].uri, type: 'application/octet-stream', name: fileForm[i].name } as any); } - - fd.append("data", JSON.stringify( - { - ...dataForm, user: hasil, groups: dataMember, oldFile: dataFile - } - )) - + fd.append("data", JSON.stringify({ ...dataForm, user: hasil, groups: dataMember, oldFile: dataFile })) const response = await apiEditAnnouncement(fd, id); if (response.success) { dispatch(setUpdateAnnouncement(!update)) - Toast.show({ type: 'small', text1: 'Berhasil mengubah data', }) + Toast.show({ type: 'small', text1: 'Berhasil mengubah data' }) router.back(); } else { - Toast.show({ type: 'small', text1: 'Gagal mengubah data', }) + Toast.show({ type: 'small', text1: 'Gagal mengubah data' }) } } catch (error: any) { console.error(error); - const message = error?.response?.data?.message || "Gagal mengubah data" - - Toast.show({ type: 'small', text1: message }) + Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal mengubah data" }) } finally { setLoading(false) } } const pickDocumentAsync = async () => { - let result = await DocumentPicker.getDocumentAsync({ - type: ["*/*"], - multiple: true - }); + const result = await DocumentPicker.getDocumentAsync({ type: ["*/*"], multiple: true }); if (!result.canceled) { - for (let i = 0; i < result.assets?.length; i++) { - if (result.assets[i].uri) { - setFileForm((prev) => [...prev, result.assets[i]]) + let skipped = 0 + for (const asset of result.assets) { + if (!asset.uri) continue + const isDup = fileForm.some(f => f.name === asset.name) || + visibleOldFiles.some(f => `${f.name}.${f.extension}` === asset.name) + if (isDup) { + skipped++ + } else { + setFileForm(prev => [...prev, asset]) } } + if (skipped > 0) Toast.show({ type: 'small', text1: 'Beberapa file sudah ditambahkan' }) } }; function deleteFile(index: number | string, cat: "newFile" | "oldFile" | null) { - if (cat == "newFile") { - setFileForm([...fileForm.filter((val, i) => i !== index)]) + if (cat === "newFile") { + setFileForm(fileForm.filter((_, i) => i !== index)) } else { - setDataFile(prev => - prev.map(item => - item.id === index - ? { ...item, delete: true } - : item - ) - ); + setDataFile(prev => prev.map(item => item.id === index ? { ...item, delete: true } : item)) } setModalFile(false) } return ( - + ( - // { - // 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 +180,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 +347,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..05bb835 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"; @@ -12,14 +11,14 @@ import { useTheme } from "@/providers/ThemeProvider"; import { MaterialIcons } from "@expo/vector-icons"; import { useInfiniteQuery } from "@tanstack/react-query"; import { router } from "expo-router"; -import { useEffect, useMemo, useState } from "react"; -import { RefreshControl, View, VirtualizedList } from "react-native"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Pressable, RefreshControl, View, VirtualizedList } from "react-native"; import { useSelector } from "react-redux"; type Props = { - id: string, - title: string, - desc: string, + id: string + title: string + desc: string createdAt: string } @@ -28,10 +27,20 @@ export default function Announcement() { const { colors } = useTheme(); const [search, setSearch] = useState('') const update = useSelector((state: any) => state.announcementUpdate) + const isFirstRender = useRef(true) const { visible: guideVisible, dismiss: dismissGuide } = useGuide('announcement') - const arrSkeleton = Array.from({ length: 5 }, (_, index) => index) + const arrSkeleton = Array.from({ length: 5 }, (_, i) => i) + + const themed = { + background: { backgroundColor: colors.background }, + card: { backgroundColor: colors.card, borderColor: colors.icon + '18' }, + iconBox: { backgroundColor: colors.icon + '18' }, + title: { color: colors.text }, + desc: { color: colors.dimmed }, + date: { color: colors.dimmed }, + cardPressed: { backgroundColor: colors.icon + '08' }, + } - // TanStack Query Infinite Query const { data, fetchNextPage, @@ -44,11 +53,7 @@ export default function Announcement() { queryKey: ['announcements', search], queryFn: async ({ pageParam = 1 }) => { const hasil = await decryptToken(String(token?.current)) - const response = await apiGetAnnouncement({ - user: hasil, - search: search, - page: pageParam - }) + const response = await apiGetAnnouncement({ user: hasil, search, page: pageParam }) return response.data }, initialPageParam: 1, @@ -57,21 +62,12 @@ export default function Announcement() { }, }) - // Trigger refetch when Redux state 'update' changes useEffect(() => { + if (isFirstRender.current) { isFirstRender.current = false; return } refetch() - }, [update, refetch]) + }, [update]) - // Flatten data from pages - const flattenedData = useMemo(() => { - return data?.pages.flat() || [] - }, [data]) - - const loadMoreData = () => { - if (hasNextPage && !isFetchingNextPage) { - fetchNextPage() - } - }; + const flattenedData = useMemo(() => data?.pages.flat() || [], [data]) const getItem = (_data: unknown, index: number): Props => ({ id: flattenedData[index].id, @@ -80,59 +76,80 @@ export default function Announcement() { createdAt: flattenedData[index].createdAt, }) - return ( - - - - + 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/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)} /> diff --git a/app/(application)/discussion/index.tsx b/app/(application)/discussion/index.tsx index e1f296d..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" && ( {(entityUser.role == "supadmin" || entityUser.role == "developer") && ( - - Filter: + + Filter : )} {/* 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/[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 +} 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/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/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 : 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) 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/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' }, 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;