From 2be59b5ac6f2d9156df806a7daeecc6dc4155da1 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 19 May 2026 15:10:18 +0800 Subject: [PATCH] feat: redesign halaman tambah dan edit diskusi - Ganti ButtonSelect dan BorderBottomItem dengan pola sectionCard + fileGrid - Tambah getFileIcon/getFileColor helper dan ikon berwarna per tipe file - Bagian anggota pada create menggunakan listItemCard dengan avatar ImageUser - Terapkan deduplication file berdasarkan nama dengan toast notifikasi - Bersihkan komentar lama dan sederhanakan logic validasi --- app/(application)/discussion/create.tsx | 364 ++++++++++----------- app/(application)/discussion/edit/[id].tsx | 273 ++++++++-------- 2 files changed, 302 insertions(+), 335 deletions(-) diff --git a/app/(application)/discussion/create.tsx b/app/(application)/discussion/create.tsx index 421c1ca..6a0fa7a 100644 --- a/app/(application)/discussion/create.tsx +++ b/app/(application)/discussion/create.tsx @@ -1,7 +1,5 @@ import AppHeader from "@/components/AppHeader"; -import BorderBottomItem from "@/components/borderBottomItem"; import ButtonSaveHeader from "@/components/buttonSaveHeader"; -import ButtonSelect from "@/components/buttonSelect"; import DrawerBottom from "@/components/drawerBottom"; import ImageUser from "@/components/imageNew"; import { InputForm } from "@/components/inputForm"; @@ -17,14 +15,33 @@ import { setUpdateDiscussionGeneralDetail } from "@/lib/discussionGeneralDetail" import { setMemberChoose } from "@/lib/memberChoose"; import { useAuthSession } from "@/providers/AuthProvider"; import { useTheme } from "@/providers/ThemeProvider"; -import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; +import { Ionicons, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons"; import * as DocumentPicker from "expo-document-picker"; import { router, Stack } from "expo-router"; import { useEffect, useState } from "react"; -import { SafeAreaView, ScrollView, View } from "react-native"; +import { Pressable, SafeAreaView, ScrollView, View } from "react-native"; import Toast from "react-native-toast-message"; import { useDispatch, useSelector } from "react-redux"; +function getFileIcon(ext: string): keyof typeof MaterialCommunityIcons.glyphMap { + if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return 'image-outline' + if (ext === 'pdf') return 'file-pdf-box' + if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline' + if (['doc', 'docx'].includes(ext)) return 'file-word-outline' + if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline' + if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline' + return 'file-outline' +} + +function getFileColor(ext: string): string { + if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return '#339AF0' + if (ext === 'pdf') return '#F03E3E' + if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return '#AE3EC9' + if (['doc', 'docx'].includes(ext)) return '#1C7ED6' + if (['xls', 'xlsx'].includes(ext)) return '#2F9E44' + if (['zip', 'rar', '7z'].includes(ext)) return '#E8590C' + return '#868E96' +} export default function CreateDiscussionGeneral() { const { token, decryptToken } = useAuthSession() @@ -43,84 +60,67 @@ export default function CreateDiscussionGeneral() { const [fileForm, setFileForm] = useState([]) const [isModalFile, setModalFile] = useState(false) const [indexDelFile, setIndexDelFile] = useState(0) - const [dataForm, setDataForm] = useState({ - idGroup: "", - title: "", - desc: "", - }); - const [error, setError] = useState({ - group: false, - title: false, - desc: false, - }); + const [dataForm, setDataForm] = useState({ idGroup: "", title: "", desc: "" }); + const [error, setError] = useState({ group: false, title: false, desc: false }); function validationForm(cat: string, val: any, label?: string) { - if (cat == "group") { + if (cat === "group") { setChooseGroup({ val, label: String(label) }); dispatch(setMemberChoose([])) setDataForm({ ...dataForm, idGroup: val }); - if (val == "" || val == "null") { - setError({ ...error, group: true }); - } else { - setError({ ...error, group: false }); - } - } else if (cat == "title") { + setError({ ...error, group: val === "" || val === "null" }); + } else if (cat === "title") { setDataForm({ ...dataForm, title: val }); - if (val == "" || val == "null") { - setError({ ...error, title: true }); - } else { - setError({ ...error, title: false }); - } - } else if (cat == "desc") { + setError({ ...error, title: val === "" || val === "null" }); + } else if (cat === "desc") { setDataForm({ ...dataForm, desc: val }); - if (val == "" || val == "null") { - setError({ ...error, desc: true }); - } else { - setError({ ...error, desc: false }); - } + setError({ ...error, desc: val === "" || val === "null" }); } } function checkForm() { - if ( - Object.values(error).some((v) => v == true) || - Object.values(dataForm).some((v) => v == "") - ) { - setDisableBtn(true); + const hasError = Object.values(error).some(v => v) + const hasEmpty = Object.values(dataForm).some(v => v === "") + setDisableBtn(hasError || hasEmpty); + } + + useEffect(() => { checkForm() }, [error, dataForm]); + useEffect(() => { dispatch(setMemberChoose([])) }, []) + + function handleOpenMemberPicker() { + if (entityUser.role === "supadmin" || entityUser.role === "developer") { + if (chooseGroup.val !== "") { + setSelect(true); + setValSelect("member"); + } else { + Toast.show({ type: 'small', text1: 'Pilih Lembaga Desa terlebih dahulu' }) + } } else { - setDisableBtn(false); + validationForm('group', userLogin.idGroup, userLogin.group); + setValChoose(userLogin.idGroup) + setSelect(true); + setValSelect("member"); } } - useEffect(() => { - checkForm(); - }, [error, dataForm]); - - useEffect(() => { - dispatch(setMemberChoose([])) - }, []) - - function handleBack() { - dispatch(setMemberChoose([])) - router.back() - } - const pickDocumentAsync = async () => { - let result = await DocumentPicker.getDocumentAsync({ - type: ["*/*"], - multiple: true - }); + const result = await DocumentPicker.getDocumentAsync({ type: ["*/*"], multiple: true }); if (!result.canceled) { - for (let i = 0; i < result.assets?.length; i++) { - if (result.assets[i].uri) { - setFileForm((prev) => [...prev, result.assets[i]]) + let skipped = 0 + for (const asset of result.assets) { + if (!asset.uri) continue + if (fileForm.some(f => f.name === asset.name)) { + skipped++ + } else { + setFileForm(prev => [...prev, asset]) } } + if (skipped > 0) Toast.show({ type: 'small', text1: 'Beberapa file sudah ditambahkan' }) } }; function deleteFile(index: number) { - setFileForm([...fileForm.filter((val, i) => i !== index)]) + setFileForm(fileForm.filter((_, i) => i !== index)) setModalFile(false) } @@ -129,38 +129,22 @@ export default function CreateDiscussionGeneral() { setLoading(true) const hasil = await decryptToken(String(token?.current)) const fd = new FormData() - for (let i = 0; i < fileForm.length; i++) { - fd.append(`file${i}`, { - uri: fileForm[i].uri, - type: 'application/octet-stream', - name: fileForm[i].name, - } as any); + fd.append(`file${i}`, { uri: fileForm[i].uri, type: 'application/octet-stream', name: fileForm[i].name } as any); } - - fd.append("data", JSON.stringify( - { ...dataForm, user: hasil, member: entitiesMember } - )) - + fd.append("data", JSON.stringify({ ...dataForm, user: hasil, member: entitiesMember })) const response = await apiCreateDiscussionGeneral(fd) - - // const response = await apiCreateDiscussionGeneral({ - // data: { ...dataForm, user: hasil, member: entitiesMember }, - // }) - if (response.success) { dispatch(setMemberChoose([])) dispatch(setUpdateDiscussionGeneralDetail(!update)) - Toast.show({ type: 'small', text1: 'Berhasil menambahkan data', }) + Toast.show({ type: 'small', text1: 'Berhasil menambahkan data' }) router.back() } else { - Toast.show({ type: 'small', text1: response.message, }) + Toast.show({ type: 'small', text1: response.message }) } } catch (error: any) { console.error(error); - const message = error?.response?.data?.message || "Gagal menambahkan data" - - Toast.show({ type: 'small', text1: message }) + Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menambahkan data" }) } finally { setLoading(false) } @@ -170,36 +154,18 @@ export default function CreateDiscussionGeneral() { ( - // { handleBack() }} - // /> - // ), - headerTitle: "Tambah Diskusi", - headerTitleAlign: "center", - // headerRight: () => ( - // { - // entitiesMember.length == 0 - // ? Toast.show({ type: 'small', text1: 'Anda belum memilih anggota', }) - // : handleCreate() - // }} - // /> - // ), header: () => ( router.back()} + onPressLeft={() => { dispatch(setMemberChoose([])); router.back() }} right={ { - entitiesMember.length == 0 - ? Toast.show({ type: 'small', text1: 'Anda belum memilih anggota', }) + entitiesMember.length === 0 + ? Toast.show({ type: 'small', text1: 'Anda belum memilih anggota' }) : handleCreate() }} /> @@ -211,25 +177,20 @@ export default function CreateDiscussionGeneral() { {loading && } - { - (entityUser.role == "supadmin" || - entityUser.role == "developer") && ( - { - setValChoose(chooseGroup.val); - setValSelect("group"); - setSelect(true); - }} - error={error.group} - errorText="Lembaga Desa tidak boleh kosong" - /> - ) - } + + {(entityUser.role === "supadmin" || entityUser.role === "developer") && ( + { setValChoose(chooseGroup.val); setValSelect("group"); setSelect(true) }} + error={error.group} + errorText="Lembaga Desa tidak boleh kosong" + /> + )} + { validationForm("title", val) }} + onChange={(val) => validationForm("title", val)} /> + { validationForm("desc", val) }} + onChange={(val) => validationForm("desc", val)} multiline /> - - { - fileForm.length > 0 - && - <> - - File - {fileForm.length} file + + {/* File */} + + 0 ? 12 : 0 }]} + > + + - - { - fileForm.map((item, index) => ( - + File + {fileForm.length === 0 && ( + Opsional — ketuk untuk upload + )} + + {fileForm.length > 0 && ( + + {fileForm.length} file + + )} + + + {fileForm.length > 0 && ( + + {fileForm.map((item, index) => { + const ext = item.name.split('.').pop()?.toLowerCase() ?? '' + const baseName = item.name.includes('.') ? item.name.split('.').slice(0, -1).join('.') : item.name + const iconName = getFileIcon(ext) + const iconColor = getFileColor(ext) + return ( + } - title={item.name} - bgColor="transparent" - titleWeight="normal" onPress={() => { setIndexDelFile(index); setModalFile(true) }} - /> - )) - } + style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]} + > + + + + + {baseName} + {ext.toUpperCase()} + + + ) + })} - - } - { - if (entityUser.role == "supadmin" || entityUser.role == "developer") { - if (chooseGroup.val != "") { - setSelect(true); - setValSelect("member"); - } else { - Toast.show({ type: 'small', text1: 'Pilih Lembaga Desa terlebih dahulu', }) - } - } else { - validationForm('group', userLogin.idGroup, userLogin.group); - setValChoose(userLogin.idGroup) - setSelect(true); - setValSelect("member"); - } + )} + - }} - /> - { - entitiesMember.length > 0 && - - - Anggota - {entitiesMember.length} Anggota + {/* Anggota */} + + 0 ? 12 : 0 }]} + > + + + + Anggota + {entitiesMember.length === 0 && ( + Belum ada anggota dipilih + )} + + {entitiesMember.length > 0 && ( + + {entitiesMember.length} anggota + + )} + + + {entitiesMember.length > 0 && ( + + {entitiesMember.map((item: any, index: number) => ( + + + + {item.name} + + + ))} + + )} + - - { - entitiesMember.map((item: { img: any; name: any; }, index: any) => { - return ( - - } - title={item.name} - bgColor="transparent" - /> - ) - }) - } - - - } { - validationForm(valSelect, value.val, value.label); - }} - title={valSelect == "group" ? "Lembaga Desa" : "Pilih Anggota"} + onSelect={(value) => validationForm(valSelect, value.val, value.label)} + title={valSelect === "group" ? "Lembaga Desa" : "Pilih Anggota"} open={isSelect} - idParent={valSelect == "member" ? chooseGroup.val : ""} + idParent={valSelect === "member" ? chooseGroup.val : ""} valChoose={valChoose} /> @@ -343,7 +319,7 @@ export default function CreateDiscussionGeneral() { } title="Hapus" - onPress={() => { deleteFile(indexDelFile) }} + onPress={() => deleteFile(indexDelFile)} /> diff --git a/app/(application)/discussion/edit/[id].tsx b/app/(application)/discussion/edit/[id].tsx index 5d1641e..dbee704 100644 --- a/app/(application)/discussion/edit/[id].tsx +++ b/app/(application)/discussion/edit/[id].tsx @@ -1,12 +1,10 @@ import AppHeader from "@/components/AppHeader"; -import Text from "@/components/Text"; -import BorderBottomItem from "@/components/borderBottomItem"; import ButtonSaveHeader from "@/components/buttonSaveHeader"; -import ButtonSelect from "@/components/buttonSelect"; import DrawerBottom from "@/components/drawerBottom"; import { InputForm } from "@/components/inputForm"; import LoadingCenter from "@/components/loadingCenter"; import MenuItemRow from "@/components/menuItemRow"; +import Text from "@/components/Text"; import Styles from "@/constants/Styles"; import { apiEditDiscussionGeneral, apiGetDiscussionGeneralOne } from "@/lib/api"; import { setUpdateDiscussionGeneralDetail } from "@/lib/discussionGeneralDetail"; @@ -16,10 +14,30 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import * as DocumentPicker from "expo-document-picker"; import { router, Stack, useLocalSearchParams } from "expo-router"; import { useEffect, useState } from "react"; -import { SafeAreaView, ScrollView, View } from "react-native"; +import { Pressable, SafeAreaView, ScrollView, View } from "react-native"; import Toast from "react-native-toast-message"; import { useDispatch, useSelector } from "react-redux"; +function getFileIcon(ext: string): keyof typeof MaterialCommunityIcons.glyphMap { + if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return 'image-outline' + if (ext === 'pdf') return 'file-pdf-box' + if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline' + if (['doc', 'docx'].includes(ext)) return 'file-word-outline' + if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline' + if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline' + return 'file-outline' +} + +function getFileColor(ext: string): string { + if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return '#339AF0' + if (ext === 'pdf') return '#F03E3E' + if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return '#AE3EC9' + if (['doc', 'docx'].includes(ext)) return '#1C7ED6' + if (['xls', 'xlsx'].includes(ext)) return '#2F9E44' + if (['zip', 'rar', '7z'].includes(ext)) return '#E8590C' + return '#868E96' +} + export default function EditDiscussionGeneral() { const { token, decryptToken } = useAuthSession(); const { colors } = useTheme(); @@ -32,136 +50,91 @@ export default function EditDiscussionGeneral() { const [indexDelFile, setIndexDelFile] = useState<{ id: string | number; cat: "newFile" | "oldFile" }>({ id: "", cat: "newFile" }) const [dataFile, setDataFile] = useState<{ id: string; idStorage: string; name: string; extension: string; delete?: boolean }[]>([]) const update = useSelector((state: any) => state.discussionGeneralDetailUpdate) - const [dataForm, setDataForm] = useState({ - title: "", - desc: "", - }); - const [error, setError] = useState({ - title: false, - desc: false, - }) + const [dataForm, setDataForm] = useState({ title: "", desc: "" }); + const [error, setError] = useState({ title: false, desc: false }) + + const visibleOldFiles = dataFile.filter(v => !v.delete) + const totalFiles = fileForm.length + visibleOldFiles.length async function handleLoad() { try { const hasil = await decryptToken(String(token?.current)); - const response = await apiGetDiscussionGeneralOne({ - id: id, - user: hasil, - cat: "detail", - }); - const responseFile = await apiGetDiscussionGeneralOne({ - id: id, - user: hasil, - cat: "file", - }); - if (response.success) { - setDataForm(response.data); - } - if (responseFile.success) { - setDataFile(responseFile.data); - } + const response = await apiGetDiscussionGeneralOne({ id, user: hasil, cat: "detail" }); + const responseFile = await apiGetDiscussionGeneralOne({ id, user: hasil, cat: "file" }); + if (response.success) setDataForm(response.data); + if (responseFile.success) setDataFile(responseFile.data); } catch (error) { console.error(error); } } - - useEffect(() => { - handleLoad(); - }, []); + useEffect(() => { handleLoad() }, []); function validationForm(cat: string, val: any) { - if (cat == "title") { + if (cat === "title") { setDataForm({ ...dataForm, title: val }); - if (val == "" || val == "null") { - setError({ ...error, title: true }); - } else { - setError({ ...error, title: false }); - } - } else if (cat == "desc") { + setError({ ...error, title: val === "" || val === "null" }); + } else if (cat === "desc") { setDataForm({ ...dataForm, desc: val }); - if (val == "" || val == "null") { - setError({ ...error, desc: true }); - } else { - setError({ ...error, desc: false }); - } + setError({ ...error, desc: val === "" || val === "null" }); } } function checkForm() { - if (Object.values(error).some((v) => v == true) == true || Object.values(dataForm).some((v) => v == "") == true) { - setDisableBtn(true) - } else { - setDisableBtn(false) - } + const hasError = Object.values(error).some(v => v) + const hasEmpty = Object.values(dataForm).some(v => v === "") + setDisableBtn(hasError || hasEmpty); } - useEffect(() => { - checkForm() - }, [error, dataForm]) + useEffect(() => { checkForm() }, [error, dataForm]) const pickDocumentAsync = async () => { - let result = await DocumentPicker.getDocumentAsync({ - type: ["*/*"], - multiple: true - }); + const result = await DocumentPicker.getDocumentAsync({ type: ["*/*"], multiple: true }); if (!result.canceled) { - for (let i = 0; i < result.assets?.length; i++) { - if (result.assets[i].uri) { - setFileForm((prev) => [...prev, result.assets[i]]) + let skipped = 0 + for (const asset of result.assets) { + if (!asset.uri) continue + const isDup = fileForm.some(f => f.name === asset.name) || + visibleOldFiles.some(f => `${f.name}.${f.extension}` === asset.name) + if (isDup) { + skipped++ + } else { + setFileForm(prev => [...prev, asset]) } } + if (skipped > 0) Toast.show({ type: 'small', text1: 'Beberapa file sudah ditambahkan' }) } }; function deleteFile(index: number | string, cat: "newFile" | "oldFile" | null) { - if (cat == "newFile") { - setFileForm([...fileForm.filter((val, i) => i !== index)]) + if (cat === "newFile") { + setFileForm(fileForm.filter((_, i) => i !== index)) } else { - setDataFile(prev => - prev.map(item => - item.id === index - ? { ...item, delete: true } - : item - ) - ); + setDataFile(prev => prev.map(item => item.id === index ? { ...item, delete: true } : item)) } setModalFile(false) } - async function handleEdit() { try { setLoading(true) const hasil = await decryptToken(String(token?.current)); const fd = new FormData() for (let i = 0; i < fileForm.length; i++) { - fd.append(`file${i}`, { - uri: fileForm[i].uri, - type: 'application/octet-stream', - name: fileForm[i].name, - } as any); + fd.append(`file${i}`, { uri: fileForm[i].uri, type: 'application/octet-stream', name: fileForm[i].name } as any); } - - fd.append("data", JSON.stringify( - { - user: hasil, title: dataForm.title, desc: dataForm.desc, oldFile: dataFile - } - )) - + fd.append("data", JSON.stringify({ user: hasil, title: dataForm.title, desc: dataForm.desc, oldFile: dataFile })) const response = await apiEditDiscussionGeneral(fd, id); if (response.success) { dispatch(setUpdateDiscussionGeneralDetail(!update)) - Toast.show({ type: 'small', text1: 'Berhasil mengubah data', }) + Toast.show({ type: 'small', text1: 'Berhasil mengubah data' }) router.back(); } else { - Toast.show({ type: 'small', text1: 'Gagal mengubah data', }) + Toast.show({ type: 'small', text1: 'Gagal mengubah data' }) } } catch (error: any) { console.error(error); - const message = error?.response?.data?.message || "Gagal mengubah data" - - Toast.show({ type: 'small', text1: message }) + Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal mengubah data" }) } finally { setLoading(false) } @@ -171,22 +144,6 @@ export default function EditDiscussionGeneral() { ( - // { - // router.back(); - // }} - // /> - // ), - headerTitle: "Edit Diskusi", - headerTitleAlign: "center", - // headerRight: () => ( - // { handleEdit() }} - // /> - // ), header: () => ( router.back()} right={ { handleEdit() }} + onPress={() => handleEdit()} /> } /> @@ -205,7 +162,8 @@ export default function EditDiscussionGeneral() { /> {loading && } - + + validationForm("title", val)} /> + validationForm("desc", val)} multiline /> - - { - (fileForm.length > 0 || dataFile.filter((val) => !val.delete).length > 0) - && - <> - - File - {fileForm.length + dataFile.filter((val) => !val.delete).length} file + + {/* File */} + + 0 ? 12 : 0 }]} + > + + - - { - dataFile.filter((val) => !val.delete).map((item, index) => ( - !val.delete).length - 1 == index && fileForm.length == 0 ? "none" : "bottom"} - icon={} - title={item.name + '.' + item.extension} - titleWeight="normal" - bgColor="transparent" + + File + {totalFiles === 0 && ( + Opsional — ketuk untuk upload + )} + + {totalFiles > 0 && ( + + {totalFiles} file + + )} + + + {totalFiles > 0 && ( + + {visibleOldFiles.map((item, index) => { + const ext = item.extension.toLowerCase() + const iconName = getFileIcon(ext) + const iconColor = getFileColor(ext) + return ( + { setIndexDelFile({ id: item.id, cat: "oldFile" }); setModalFile(true) }} - /> - )) - } - { - fileForm.map((item, index) => ( - } - title={item.name} - titleWeight="normal" - bgColor="transparent" + style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]} + > + + + + + {item.name} + {ext.toUpperCase()} + + + ) + })} + {fileForm.map((item, index) => { + const ext = item.name.split('.').pop()?.toLowerCase() ?? '' + const baseName = item.name.includes('.') ? item.name.split('.').slice(0, -1).join('.') : item.name + const iconName = getFileIcon(ext) + const iconColor = getFileColor(ext) + return ( + { setIndexDelFile({ id: index, cat: "newFile" }); setModalFile(true) }} - /> - )) - } + style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]} + > + + + + + {baseName} + {ext.toUpperCase()} + + + ) + })} - - } + )} + + @@ -276,7 +267,7 @@ export default function EditDiscussionGeneral() { } title="Hapus" - onPress={() => { deleteFile(indexDelFile.id, indexDelFile.cat) }} + onPress={() => deleteFile(indexDelFile.id, indexDelFile.cat)} />