From 209254af2370bf946b43f0f6bd7a3526b7457df1 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 9 Jun 2026 15:25:15 +0800 Subject: [PATCH 1/4] feat: tambah komponen pilih anggota berdasarkan divisi pada tambah diskusi umum --- app/(application)/discussion/create.tsx | 25 +- .../modalSelectMemberByDivision.tsx | 287 ++++++++++++++++++ 2 files changed, 301 insertions(+), 11 deletions(-) create mode 100644 components/discussion_general/modalSelectMemberByDivision.tsx diff --git a/app/(application)/discussion/create.tsx b/app/(application)/discussion/create.tsx index 6a0fa7a..2992bf9 100644 --- a/app/(application)/discussion/create.tsx +++ b/app/(application)/discussion/create.tsx @@ -1,5 +1,6 @@ import AppHeader from "@/components/AppHeader"; import ButtonSaveHeader from "@/components/buttonSaveHeader"; +import ModalSelectMemberByDivision from "@/components/discussion_general/modalSelectMemberByDivision"; import DrawerBottom from "@/components/drawerBottom"; import ImageUser from "@/components/imageNew"; import { InputForm } from "@/components/inputForm"; @@ -50,10 +51,10 @@ export default function CreateDiscussionGeneral() { const userLogin = useSelector((state: any) => state.entities) const [chooseGroup, setChooseGroup] = useState({ val: "", label: "" }); const [valChoose, setValChoose] = useState("") - const [valSelect, setValSelect] = useState<"group" | "member">("group"); const dispatch = useDispatch() const [disableBtn, setDisableBtn] = useState(true); const [isSelect, setSelect] = useState(false); + const [isMemberModal, setMemberModal] = useState(false); const entitiesMember = useSelector((state: any) => state.memberChoose) const update = useSelector((state: any) => state.discussionGeneralDetailUpdate) const [loading, setLoading] = useState(false) @@ -90,16 +91,13 @@ export default function CreateDiscussionGeneral() { function handleOpenMemberPicker() { if (entityUser.role === "supadmin" || entityUser.role === "developer") { if (chooseGroup.val !== "") { - setSelect(true); - setValSelect("member"); + setMemberModal(true); } 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"); + setMemberModal(true); } } @@ -185,7 +183,7 @@ export default function CreateDiscussionGeneral() { value={chooseGroup.label} required bg={colors.card} - onPress={() => { setValChoose(chooseGroup.val); setValSelect("group"); setSelect(true) }} + onPress={() => { setValChoose(chooseGroup.val); setSelect(true) }} error={error.group} errorText="Lembaga Desa tidak boleh kosong" /> @@ -305,14 +303,19 @@ export default function CreateDiscussionGeneral() { validationForm(valSelect, value.val, value.label)} - title={valSelect === "group" ? "Lembaga Desa" : "Pilih Anggota"} + onSelect={(value) => validationForm("group", value.val, value.label)} + title="Lembaga Desa" open={isSelect} - idParent={valSelect === "member" ? chooseGroup.val : ""} + idParent="" valChoose={valChoose} /> + diff --git a/components/discussion_general/modalSelectMemberByDivision.tsx b/components/discussion_general/modalSelectMemberByDivision.tsx new file mode 100644 index 0000000..1a69556 --- /dev/null +++ b/components/discussion_general/modalSelectMemberByDivision.tsx @@ -0,0 +1,287 @@ +import { ButtonForm } from "@/components/buttonForm" +import DrawerBottom from "@/components/drawerBottom" +import ImageUser from "@/components/imageNew" +import ImageWithLabel from "@/components/imageWithLabel" +import InputSearch from "@/components/inputSearch" +import Text from "@/components/Text" +import { ConstEnv } from "@/constants/ConstEnv" +import Styles from "@/constants/Styles" +import { apiGetDivision, apiGetDivisionMember, apiGetUser } from "@/lib/api" +import { setMemberChoose } from "@/lib/memberChoose" +import { useAuthSession } from "@/providers/AuthProvider" +import { useTheme } from "@/providers/ThemeProvider" +import { AntDesign, Ionicons } from "@expo/vector-icons" +import { useEffect, useState } from "react" +import { ActivityIndicator, Pressable, ScrollView, View } from "react-native" +import { useDispatch, useSelector } from "react-redux" + +type Member = { idUser: string; name: string; img: string } + +type DivisionItem = { + id: string + name: string + expanded: boolean + membersLoaded: boolean + members: Member[] +} + +type Props = { + open: boolean + close: (value: boolean) => void + idGroup: string +} + +export default function ModalSelectMemberByDivision({ open, close, idGroup }: Props) { + const { token, decryptToken } = useAuthSession() + const { colors } = useTheme() + const dispatch = useDispatch() + const entitiesMember = useSelector((state: any) => state.memberChoose) + + const [divisions, setDivisions] = useState([]) + const [selectMember, setSelectMember] = useState([]) + const [search, setSearch] = useState('') + const [loadingDivisions, setLoadingDivisions] = useState(false) + const [loadingIds, setLoadingIds] = useState([]) + const [searchResults, setSearchResults] = useState([]) + const [loadingSearch, setLoadingSearch] = useState(false) + + async function loadDivisions() { + if (!idGroup) return + setLoadingDivisions(true) + try { + const hasil = await decryptToken(String(token?.current)) + const response = await apiGetDivision({ user: hasil, search: '', group: idGroup, active: 'true', kategori: 'semua', page: 1 }) + const divisionList: DivisionItem[] = (response.data ?? []).map((d: any) => ({ + id: d.id, name: d.name, expanded: false, membersLoaded: false, members: [] + })) + const withMembers = await Promise.all( + divisionList.map(async (d) => { + try { + const res = await apiGetDivisionMember({ user: hasil, id: d.id, search: '' }) + const members: Member[] = (res.data ?? []).map((m: any) => ({ idUser: m.idUser, name: m.name, img: m.img })) + return { ...d, members, membersLoaded: true } + } catch { + return { ...d, membersLoaded: true } + } + }) + ) + setDivisions(withMembers) + } catch { setDivisions([]) } + finally { setLoadingDivisions(false) } + } + + async function fetchMembers(divisionId: string): Promise { + setLoadingIds(prev => [...prev, divisionId]) + try { + const hasil = await decryptToken(String(token?.current)) + const response = await apiGetDivisionMember({ user: hasil, id: divisionId, search: '' }) + const members: Member[] = (response.data ?? []).map((m: any) => ({ idUser: m.idUser, name: m.name, img: m.img })) + setDivisions(prev => prev.map(d => + d.id === divisionId ? { ...d, members, membersLoaded: true } : d + )) + return members + } catch { return [] } + finally { setLoadingIds(prev => prev.filter(id => id !== divisionId)) } + } + + async function searchUsers(query: string) { + setLoadingSearch(true) + try { + const hasil = await decryptToken(String(token?.current)) + const response = await apiGetUser({ user: hasil, active: 'true', search: query, group: idGroup }) + setSearchResults((response.data ?? []).filter((i: any) => i.idUserRole !== 'supadmin')) + } catch { setSearchResults([]) } + finally { setLoadingSearch(false) } + } + + useEffect(() => { + if (open) { loadDivisions(); setSelectMember(entitiesMember) } + }, [open]) + + useEffect(() => { + if (!open) return + if (search) { + searchUsers(search) + } else { + setSearchResults([]) + loadDivisions() + } + }, [search]) + + async function handleTapDivision(division: DivisionItem) { + let members = division.members + if (!division.membersLoaded) members = await fetchMembers(division.id) + setDivisions(prev => prev.map(d => + d.id === division.id ? { ...d, expanded: true, members, membersLoaded: true } : d + )) + const allSelected = members.length > 0 && members.every(m => + selectMember.some(s => s.idUser === m.idUser) + ) + if (allSelected) { + setSelectMember(prev => prev.filter(s => !members.some(m => m.idUser === s.idUser))) + } else { + const existingIds = new Set(selectMember.map(s => s.idUser)) + setSelectMember(prev => [...prev, ...members.filter(m => !existingIds.has(m.idUser))]) + } + } + + async function handleToggleExpand(divisionId: string) { + const division = divisions.find(d => d.id === divisionId)! + if (!division.membersLoaded && !division.expanded) await fetchMembers(divisionId) + setDivisions(prev => prev.map(d => + d.id === divisionId ? { ...d, expanded: !d.expanded } : d + )) + } + + function handleToggleMember(member: Member) { + if (selectMember.some(s => s.idUser === member.idUser)) { + setSelectMember(prev => prev.filter(s => s.idUser !== member.idUser)) + } else { + setSelectMember(prev => [...prev, member]) + } + } + + function handleConfirm() { + dispatch(setMemberChoose(selectMember)) + handleClose() + } + + function handleClose() { + setDivisions([]) + setSelectMember([]) + setSearch('') + close(false) + } + + return ( + + + {selectMember.length > 0 + ? ( + + + {selectMember.map((item, index) => ( + handleToggleMember(item)} + /> + ))} + + + ) + : ( + + Tidak ada member yang dipilih + + ) + } + + + {search ? ( + loadingSearch ? ( + + ) : searchResults.length > 0 ? ( + searchResults.map((item, idx) => ( + handleToggleMember({ idUser: item.id, name: item.name, img: item.img })} + > + + + {item.name} + + {selectMember.some(s => s.idUser === item.id) && ( + + )} + + )) + ) : ( + + Tidak ada hasil + + ) + ) : loadingDivisions ? ( + + ) : divisions.length > 0 ? ( + divisions.map((division) => { + const selectedCount = division.members.filter(m => + selectMember.some(s => s.idUser === m.idUser) + ).length + const allSelected = division.membersLoaded && division.members.length > 0 + && selectedCount === division.members.length + const someSelected = selectedCount > 0 && !allSelected + const isLoadingThis = loadingIds.includes(division.id) + + return ( + + handleTapDivision(division)} + > + + {division.name} + {division.membersLoaded && ( + + {selectedCount > 0 + ? `${selectedCount} dari ${division.members.length} dipilih` + : `${division.members.length} anggota`} + + )} + + {isLoadingThis ? ( + + ) : allSelected ? ( + + ) : someSelected ? ( + + ) : null} + handleToggleExpand(division.id)} + style={{ paddingLeft: 10 }} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + + + + + {division.expanded && division.members.map((member, idx) => ( + handleToggleMember(member)} + > + + + {member.name} + + {selectMember.some(s => s.idUser === member.idUser) && ( + + )} + + ))} + + ) + }) + ) : ( + + Tidak ada divisi + + )} + + + + + + ) +} -- 2.49.1 From 9cd78dae3acfced1d050daaff303ff76026d8b17 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 9 Jun 2026 15:25:22 +0800 Subject: [PATCH 2/4] feat: ubah halaman tambah anggota diskusi umum menggunakan pilih anggota berdasarkan divisi --- .../discussion/add-member/[id].tsx | 360 ++++++++++++------ 1 file changed, 253 insertions(+), 107 deletions(-) diff --git a/app/(application)/discussion/add-member/[id].tsx b/app/(application)/discussion/add-member/[id].tsx index f60275f..4aa3415 100644 --- a/app/(application)/discussion/add-member/[id].tsx +++ b/app/(application)/discussion/add-member/[id].tsx @@ -6,69 +6,159 @@ import InputSearch from "@/components/inputSearch"; import Text from '@/components/Text'; import { ConstEnv } from "@/constants/ConstEnv"; import Styles from "@/constants/Styles"; -import { apiAddMemberDiscussionGeneral, apiGetDiscussionGeneralOne, apiGetUser } from "@/lib/api"; +import { apiAddMemberDiscussionGeneral, apiGetDiscussionGeneralOne, apiGetDivision, apiGetDivisionMember, apiGetUser } from "@/lib/api"; import { setUpdateDiscussionGeneralDetail } from "@/lib/discussionGeneralDetail"; import { useAuthSession } from "@/providers/AuthProvider"; import { useTheme } from "@/providers/ThemeProvider"; -import { AntDesign } from "@expo/vector-icons"; +import { AntDesign, Ionicons } from "@expo/vector-icons"; import { router, Stack, useLocalSearchParams } from "expo-router"; import { useEffect, useState } from "react"; -import { Pressable, SafeAreaView, ScrollView, View } from "react-native"; +import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; import Toast from "react-native-toast-message"; import { useDispatch, useSelector } from "react-redux"; -type Props = { - idUser: string, - name: string, - img: string +type Member = { idUser: string; name: string; img: string } + +type DivisionItem = { + id: string + name: string + expanded: boolean + membersLoaded: boolean + members: Member[] } export default function AddMemberDiscussionDetail() { const dispatch = useDispatch() const update = useSelector((state: any) => state.discussionGeneralDetailUpdate) const { token, decryptToken } = useAuthSession() - const { colors } = useTheme(); + const { colors } = useTheme() const { id } = useLocalSearchParams<{ id: string }>() - const [dataOld, setDataOld] = useState([]) - const [data, setData] = useState([]) + const [dataOld, setDataOld] = useState([]) const [idGroup, setIdGroup] = useState('') - const [selectMember, setSelectMember] = useState([]) + const [selectMember, setSelectMember] = useState([]) const [search, setSearch] = useState('') const [loading, setLoading] = useState(false) + const [divisions, setDivisions] = useState([]) + const [loadingDivisions, setLoadingDivisions] = useState(false) + const [loadingIds, setLoadingIds] = useState([]) + const [searchResults, setSearchResults] = useState([]) + const [loadingSearch, setLoadingSearch] = useState(false) async function handleLoad() { try { const hasil = await decryptToken(String(token?.current)) - const response = await apiGetDiscussionGeneralOne({ id: id, user: hasil, cat: 'anggota' }) - setDataOld(response.data) - const responseGroup = await apiGetDiscussionGeneralOne({ id: id, user: hasil, cat: 'detail' }) - setIdGroup(responseGroup.data.idGroup) + const [resAnggota, resDetail] = await Promise.all([ + apiGetDiscussionGeneralOne({ id, user: hasil, cat: 'anggota' }), + apiGetDiscussionGeneralOne({ id, user: hasil, cat: 'detail' }) + ]) + setDataOld(resAnggota.data ?? []) + setIdGroup(resDetail.data.idGroup) } catch (error) { console.error(error) } } - async function handleLoadMember() { - const hasil = await decryptToken(String(token?.current)) - const response = await apiGetUser({ user: hasil, active: "true", search: search, group: String(idGroup) }) - setData(response.data.filter((i: any) => i.idUserRole != 'supadmin')) + async function loadDivisions(group: string) { + if (!group) return + setLoadingDivisions(true) + try { + const hasil = await decryptToken(String(token?.current)) + const response = await apiGetDivision({ user: hasil, search: '', group, active: 'true', kategori: 'semua', page: 1 }) + const divisionList: DivisionItem[] = (response.data ?? []).map((d: any) => ({ + id: d.id, name: d.name, expanded: false, membersLoaded: false, members: [] + })) + const withMembers = await Promise.all( + divisionList.map(async (d) => { + try { + const res = await apiGetDivisionMember({ user: hasil, id: d.id, search: '' }) + const members: Member[] = (res.data ?? []).map((m: any) => ({ idUser: m.idUser, name: m.name, img: m.img })) + return { ...d, members, membersLoaded: true } + } catch { + return { ...d, membersLoaded: true } + } + }) + ) + setDivisions(withMembers) + } catch { setDivisions([]) } + finally { setLoadingDivisions(false) } } + async function fetchMembers(divisionId: string): Promise { + setLoadingIds(prev => [...prev, divisionId]) + try { + const hasil = await decryptToken(String(token?.current)) + const response = await apiGetDivisionMember({ user: hasil, id: divisionId, search: '' }) + const members: Member[] = (response.data ?? []).map((m: any) => ({ idUser: m.idUser, name: m.name, img: m.img })) + setDivisions(prev => prev.map(d => + d.id === divisionId ? { ...d, members, membersLoaded: true } : d + )) + return members + } catch { return [] } + finally { setLoadingIds(prev => prev.filter(i => i !== divisionId)) } + } + + async function searchUsers(query: string) { + if (!idGroup) return + setLoadingSearch(true) + try { + const hasil = await decryptToken(String(token?.current)) + const response = await apiGetUser({ user: hasil, active: 'true', search: query, group: idGroup }) + setSearchResults((response.data ?? []) + .filter((i: any) => i.idUserRole !== 'supadmin') + .map((i: any) => ({ idUser: i.id, name: i.name, img: i.img })) + ) + } catch { setSearchResults([]) } + finally { setLoadingSearch(false) } + } + + useEffect(() => { handleLoad() }, []) useEffect(() => { - handleLoad() - }, []); - + if (idGroup) loadDivisions(idGroup) + }, [idGroup]) useEffect(() => { - handleLoadMember() + if (!idGroup) return + if (search) { + searchUsers(search) + } else { + setSearchResults([]) + loadDivisions(idGroup) + } }, [search]) - function onChoose(val: string, label: string, img?: string) { - if (selectMember.some((i: any) => i.idUser == val)) { - setSelectMember(selectMember.filter((i: any) => i.idUser != val)) + async function handleTapDivision(division: DivisionItem) { + let members = division.members + if (!division.membersLoaded) members = await fetchMembers(division.id) + setDivisions(prev => prev.map(d => + d.id === division.id ? { ...d, expanded: true, members, membersLoaded: true } : d + )) + const eligible = members.filter(m => !dataOld.some((o: any) => o.idUser === m.idUser)) + const allSelected = eligible.length > 0 && eligible.every(m => + selectMember.some(s => s.idUser === m.idUser) + ) + if (allSelected) { + setSelectMember(prev => prev.filter(s => !eligible.some(m => m.idUser === s.idUser))) } else { - setSelectMember([...selectMember, { idUser: val, name: label, img }]) + const existingIds = new Set(selectMember.map(s => s.idUser)) + setSelectMember(prev => [...prev, ...eligible.filter(m => !existingIds.has(m.idUser))]) + } + } + + async function handleToggleExpand(divisionId: string) { + const division = divisions.find(d => d.id === divisionId)! + if (!division.membersLoaded && !division.expanded) await fetchMembers(divisionId) + setDivisions(prev => prev.map(d => + d.id === divisionId ? { ...d, expanded: !d.expanded } : d + )) + } + + function handleToggleMember(member: Member) { + if (dataOld.some((o: any) => o.idUser === member.idUser)) return + if (selectMember.some(s => s.idUser === member.idUser)) { + setSelectMember(prev => prev.filter(s => s.idUser !== member.idUser)) + } else { + setSelectMember(prev => [...prev, member]) } } @@ -76,41 +166,26 @@ export default function AddMemberDiscussionDetail() { try { setLoading(true) const hasil = await decryptToken(String(token?.current)) - const response = await apiAddMemberDiscussionGeneral({ id: id, data: { user: hasil, member: selectMember } }) + const response = await apiAddMemberDiscussionGeneral({ id, data: { user: hasil, member: selectMember } }) if (response.success) { - Toast.show({ type: 'small', text1: 'Berhasil menambahkan anggota', }) + Toast.show({ type: 'small', text1: 'Berhasil menambahkan anggota' }) dispatch(setUpdateDiscussionGeneralDetail(!update)) 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 anggota" - - Toast.show({ type: 'small', text1: message }) + console.error(error) + Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menambahkan anggota" }) } finally { setLoading(false) } } - return ( <> { router.back() }} />, - headerTitle: 'Tambah Anggota Diskusi', - headerTitleAlign: 'center', - // headerRight: () => ( - // { - // handleAddMember() - // }} - // /> - // ) header: () => ( { - handleAddMember() - }} + disable={selectMember.length === 0 || loading} + onPress={handleAddMember} /> } /> @@ -131,65 +204,138 @@ export default function AddMemberDiscussionDetail() { /> - - { - selectMember.length > 0 - ? - - - { - selectMember.map((item: any, index: any) => ( - onChoose(item.idUser, item.name, item.img)} - /> - )) - } - - - - : - Tidak ada member yang dipilih - } - - - { - data.length > 0 ? - data.map((item: any, index: any) => { - const found = dataOld.some((i: any) => i.idUser == item.id) - return ( - { - !found && onChoose(item.id, item.name, item.img) - }} - > - - - - {item.name} - { - found && sudah menjadi anggota - } + {selectMember.length > 0 ? ( + + + {selectMember.map((item, index) => ( + handleToggleMember(item)} + /> + ))} + + + ) : ( + + Tidak ada member yang dipilih + + )} + + + {search ? ( + loadingSearch ? ( + + ) : searchResults.length > 0 ? ( + searchResults.map((item, idx) => { + const isOld = dataOld.some((o: any) => o.idUser === item.idUser) + return ( + !isOld && handleToggleMember(item)} + > + + + + {item.name} + {isOld && ( + sudah menjadi anggota + )} + - - { - selectMember.some((i: any) => i.idUser == item.id) && - } - - ) - } + {selectMember.some(s => s.idUser === item.idUser) && ( + + )} + + ) + }) + ) : ( + + Tidak ada hasil + ) - : - Tidak ada data - } + ) : loadingDivisions ? ( + + ) : divisions.length > 0 ? ( + divisions.map((division) => { + const eligible = division.members.filter(m => !dataOld.some((o: any) => o.idUser === m.idUser)) + const selectedCount = eligible.filter(m => selectMember.some(s => s.idUser === m.idUser)).length + const allSelected = division.membersLoaded && eligible.length > 0 && selectedCount === eligible.length + const someSelected = selectedCount > 0 && !allSelected + const isLoadingThis = loadingIds.includes(division.id) + + return ( + + handleTapDivision(division)} + > + + {division.name} + {division.membersLoaded && ( + + {selectedCount > 0 + ? `${selectedCount} dari ${eligible.length} dipilih` + : `${eligible.length} anggota`} + + )} + + {isLoadingThis ? ( + + ) : allSelected ? ( + + ) : someSelected ? ( + + ) : null} + handleToggleExpand(division.id)} + style={{ paddingLeft: 10 }} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + + + + + {division.expanded && division.members.map((member, idx) => { + const isOld = dataOld.some((o: any) => o.idUser === member.idUser) + return ( + !isOld && handleToggleMember(member)} + > + + + + {member.name} + {isOld && ( + sudah menjadi anggota + )} + + + {!isOld && selectMember.some(s => s.idUser === member.idUser) && ( + + )} + + ) + })} + + ) + }) + ) : ( + + Tidak ada divisi + + )} + ) -} \ No newline at end of file +} -- 2.49.1 From 3370f482386d7297031de4b976efe4ee7c8a7c4e Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 9 Jun 2026 17:35:22 +0800 Subject: [PATCH 3/4] feat: redesign input komentar gaya messaging app dan tampilan file lampiran - Input komentar: tombol + di kiri, pill input flex-1, tombol send lingkaran di kanan - Tampilan file di komentar: semua tipe file sebagai card (tidak ada preview inline) - Grid 2 kolom untuk file, chip '+N lainnya' jika lebih dari 2 - Modal full screen saat klik '+N lainnya' dengan grid 2 kolom - Preview gambar via ImageViewing (dalam modal dan di luar modal) - File non-gambar dibuka via native viewer (download + intent/share) --- .../discussionCommentInput.tsx | 171 +++++++++++ .../discussionCommentList.tsx | 275 ++++++++++++++++++ 2 files changed, 446 insertions(+) create mode 100644 components/discussion_general/discussionCommentInput.tsx create mode 100644 components/discussion_general/discussionCommentList.tsx diff --git a/components/discussion_general/discussionCommentInput.tsx b/components/discussion_general/discussionCommentInput.tsx new file mode 100644 index 0000000..020177f --- /dev/null +++ b/components/discussion_general/discussionCommentInput.tsx @@ -0,0 +1,171 @@ +import Text from "@/components/Text"; +import { regexOnlySpacesOrEnter } from "@/constants/OnlySpaceOrEnter"; +import Styles from "@/constants/Styles"; +import { useTheme } from "@/providers/ThemeProvider"; +import { Feather, Ionicons, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons"; +import * as DocumentPicker from "expo-document-picker"; +import { Platform, Pressable, ScrollView, TextInput, View } from "react-native"; +import Toast from "react-native-toast-message"; + +type Props = { + mode: 'new' | 'edit' | 'locked' + lockedReason?: string + value: string + onChange: (val: string) => void + loading: boolean + onSend: () => void + onCancelEdit?: () => void + files?: { uri: string; name: string }[] + onAddFile?: (files: { uri: string; name: string }[]) => void + onRemoveFile?: (index: number) => void + existingFiles?: { id: string; name: string; extension: string }[] + onRemoveExistingFile?: (id: string) => void + canSend: boolean +} + +export default function DiscussionCommentInput({ + mode, lockedReason, value, onChange, loading, onSend, + onCancelEdit, files = [], onAddFile, onRemoveFile, + existingFiles = [], onRemoveExistingFile, canSend +}: Props) { + const { colors } = useTheme() + + async function pickFiles() { + const result = await DocumentPicker.getDocumentAsync({ type: ['*/*'], multiple: true }) + if (!result.canceled && onAddFile) { + let skipped = 0 + const newFiles: { uri: string; name: string }[] = [] + for (const asset of result.assets) { + if (!asset.uri) continue + if (files.some(f => f.name === asset.name)) { skipped++; continue } + newFiles.push({ uri: asset.uri, name: asset.name }) + } + if (skipped > 0) Toast.show({ type: 'small', text1: 'Beberapa file sudah ditambahkan' }) + if (newFiles.length > 0) onAddFile(newFiles) + } + } + + if (mode === 'locked') { + return ( + + {lockedReason} + + ) + } + + const sendDisabled = loading || value.trim() === '' || regexOnlySpacesOrEnter.test(value) || !canSend + + return ( + + {mode === 'edit' && ( + + + + Edit Komentar + + + + + + )} + + {(existingFiles.length > 0 || files.length > 0) && ( + + {existingFiles.map((f) => ( + onRemoveExistingFile?.(f.id)} + style={{ + flexDirection: 'row', alignItems: 'center', gap: 6, + paddingHorizontal: 10, paddingVertical: 6, + borderRadius: 20, borderWidth: 1, + backgroundColor: colors.card, borderColor: colors.icon + '18', + marginRight: 8 + }} + > + + + {f.name}.{f.extension} + + + + ))} + {files.map((f, idx) => ( + onRemoveFile?.(idx)} + style={{ + flexDirection: 'row', alignItems: 'center', gap: 6, + paddingHorizontal: 10, paddingVertical: 6, + borderRadius: 20, borderWidth: 1, + backgroundColor: colors.card, borderColor: colors.icon + '18', + marginRight: 8 + }} + > + + + {f.name} + + + + ))} + + )} + + + {mode === 'new' && ( + + 0 ? colors.tabActive : colors.dimmed} + /> + + )} + + + + + + !sendDisabled && onSend()} + style={{ + width: 40, height: 40, borderRadius: 20, + backgroundColor: sendDisabled ? colors.dimmed + '40' : colors.tint, + justifyContent: 'center', alignItems: 'center', + marginBottom: 0 + }} + > + + + + + ) +} diff --git a/components/discussion_general/discussionCommentList.tsx b/components/discussion_general/discussionCommentList.tsx new file mode 100644 index 0000000..d1666c6 --- /dev/null +++ b/components/discussion_general/discussionCommentList.tsx @@ -0,0 +1,275 @@ +import ImageUser from "@/components/imageNew"; +import Skeleton from "@/components/skeleton"; +import Text from "@/components/Text"; +import { ConstEnv } from "@/constants/ConstEnv"; +import { isImageFile } from "@/constants/FileExtensions"; +import Styles from "@/constants/Styles"; +import { useTheme } from "@/providers/ThemeProvider"; +import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons"; +import * as FileSystem from 'expo-file-system'; +import { startActivityAsync } from 'expo-intent-launcher'; +import * as Sharing from 'expo-sharing'; +import { useState } from "react"; +import { Modal, Platform, Pressable, SafeAreaView, ScrollView, View } from "react-native"; +import ImageViewing from "react-native-image-viewing"; +import * as mime from 'react-native-mime-types'; +import Toast from "react-native-toast-message"; + +export type CommentFile = { + id: string + name: string + extension: string + idStorage: string +} + +export type CommentItem = { + id: string + comment: string + createdAt: string + idUser: string + img: string + username: string + isEdited: boolean + updatedAt: string + files: CommentFile[] +} + +type Props = { + data: CommentItem[] + loading: boolean + myId: string + canInteract: boolean + onLongPress: (id: string, comment: string, files: CommentFile[]) => void +} + +function getFileIcon(ext: string): keyof typeof MaterialCommunityIcons.glyphMap { + if (isImageFile(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 (isImageFile(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' +} + +function FileCard({ file, colors, onPress }: { file: CommentFile; colors: any; onPress: () => void }) { + const ext = file.extension.toLowerCase() + return ( + [Styles.fileCard, { + borderColor: colors.icon + '18', + backgroundColor: pressed ? colors.icon + '10' : 'transparent' + }]} + > + + + + + {file.name} + {ext.toUpperCase()} + + + ) +} + +export default function DiscussionCommentList({ data, loading, myId, canInteract, onLongPress }: Props) { + const { colors } = useTheme() + const [expandedIds, setExpandedIds] = useState([]) + const [modalFiles, setModalFiles] = useState([]) + const [modalVisible, setModalVisible] = useState(false) + const [previewFile, setPreviewFile] = useState(null) + const [modalPreviewFile, setModalPreviewFile] = useState(null) + const [loadingOpen, setLoadingOpen] = useState(false) + const arrSkeleton = Array.from({ length: 3 }, (_, i) => i) + + function toggleExpand(id: string) { + setExpandedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]) + } + + async function openExternal(file: CommentFile) { + try { + setLoadingOpen(true) + const remoteUrl = `${ConstEnv.url_storage}/files/${file.idStorage}` + const fileName = `${file.name}.${file.extension}` + const localPath = `${FileSystem.documentDirectory}/${fileName}` + const dl = await FileSystem.downloadAsync(remoteUrl, localPath) + if (dl.status !== 200) throw new Error('Download failed') + const contentURL = await FileSystem.getContentUriAsync(dl.uri) + const mimeType = mime.lookup(fileName) as string + if (Platform.OS === 'android') { + await startActivityAsync('android.intent.action.VIEW', { data: contentURL, flags: 1, type: mimeType }) + } else { + await Sharing.shareAsync(localPath) + } + } catch { + Toast.show({ type: 'error', text1: 'Gagal membuka file' }) + } finally { + setLoadingOpen(false) + } + } + + function handleFilePress(file: CommentFile) { + if (isImageFile(file.extension.toLowerCase())) { + setPreviewFile(file) + } else { + openExternal(file) + } + } + + if (loading) { + return ( + + {arrSkeleton.map((_, i) => ( + + ))} + + ) + } + + return ( + <> + + {data.map((item, i) => ( + toggleExpand(item.id)} + onLongPress={() => item.idUser === myId && canInteract && onLongPress(item.id, item.comment, item.files ?? [])} + style={({ pressed }) => [ + Styles.discussionCommentCard, + { backgroundColor: pressed ? colors.icon + '10' : colors.card, borderColor: colors.icon + '20' } + ]} + > + + + + + + {item.username} + + {item.isEdited && ( + diedit + )} + + + {item.createdAt} + + + + {item.comment.length > 0 && ( + + {item.comment} + + )} + + {item.files?.length > 0 && ( + 0 ? 8 : 0 }}> + {(item.files.length > 2 ? item.files.slice(0, 1) : item.files).map((file, idx) => ( + handleFilePress(file)} /> + ))} + {item.files.length > 2 && ( + { setModalFiles(item.files); setModalVisible(true) }} + style={[Styles.fileCard, { borderColor: colors.icon + '18', backgroundColor: 'transparent' }]} + > + + + + + +{item.files.length - 1} lainnya + Lihat semua + + + )} + + )} + + + ))} + + + setModalVisible(false)}> + + + Lampiran ({modalFiles.length} file) + setModalVisible(false)}> + + + + + {modalFiles.map((file, idx) => ( + { + if (isImageFile(file.extension.toLowerCase())) { + setModalPreviewFile(file) + } else { + openExternal(file) + } + }} + /> + ))} + + + setModalPreviewFile(null)} + doubleTapToZoomEnabled + HeaderComponent={() => ( + + setModalPreviewFile(null)}> + + + modalPreviewFile && openExternal(modalPreviewFile)} disabled={loadingOpen}> + + + + )} + FooterComponent={() => ( + + {modalPreviewFile?.name}.{modalPreviewFile?.extension} + + )} + /> + + + setPreviewFile(null)} + doubleTapToZoomEnabled + HeaderComponent={() => ( + + setPreviewFile(null)}> + + + previewFile && openExternal(previewFile)} disabled={loadingOpen}> + + + + )} + FooterComponent={() => ( + + {previewFile?.name}.{previewFile?.extension} + + )} + /> + + ) +} -- 2.49.1 From 4ab8422808c8cb00b5f1aba11c62fff82350c766 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 9 Jun 2026 17:35:30 +0800 Subject: [PATCH 4/4] feat: tambah fitur hapus file existing saat edit komentar diskusi umum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Edit mode menampilkan file existing dengan chip ✕ merah untuk tandai hapus - File yang ditandai dikirim sebagai filesToRemove ke backend saat submit - Reset removedFileIds saat cancel edit --- app/(application)/discussion/[id].tsx | 522 ++++++++------------------ lib/api/discussion.api.ts | 9 +- 2 files changed, 172 insertions(+), 359 deletions(-) diff --git a/app/(application)/discussion/[id].tsx b/app/(application)/discussion/[id].tsx index bc364bf..d083e62 100644 --- a/app/(application)/discussion/[id].tsx +++ b/app/(application)/discussion/[id].tsx @@ -1,247 +1,178 @@ import AppHeader from "@/components/AppHeader"; import BorderBottomItem2 from "@/components/borderBottomItem2"; +import DiscussionCommentInput from "@/components/discussion_general/discussionCommentInput"; +import DiscussionCommentList, { CommentFile, CommentItem } from "@/components/discussion_general/discussionCommentList"; import HeaderRightDiscussionGeneralDetail from "@/components/discussion_general/headerDiscussionDetail"; import DrawerBottom from "@/components/drawerBottom"; -import ImageUser from "@/components/imageNew"; -import { InputForm } from "@/components/inputForm"; import MenuItemRow from "@/components/menuItemRow"; import ModalConfirmation from "@/components/ModalConfirmation"; -import Skeleton from "@/components/skeleton"; import SkeletonContent from "@/components/skeletonContent"; import Text from '@/components/Text'; import { ConstEnv } from "@/constants/ConstEnv"; -import { regexOnlySpacesOrEnter } from "@/constants/OnlySpaceOrEnter"; import Styles from "@/constants/Styles"; -import { apiDeleteDiscussionGeneralCommentar, apiGetDiscussionGeneralOne, apiSendDiscussionGeneralCommentar, apiUpdateDiscussionGeneralCommentar } from "@/lib/api"; +import { apiDeleteDiscussionGeneralCommentar, apiGetDiscussionGeneralOne, apiSendDiscussionGeneralCommentar, apiSendDiscussionGeneralCommentarWithFile, apiUpdateDiscussionGeneralCommentar } from "@/lib/api"; import { getDB } from "@/lib/firebaseDatabase"; import { useAuthSession } from "@/providers/AuthProvider"; import { useTheme } from "@/providers/ThemeProvider"; -import { Feather, Ionicons, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons"; +import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { ref } from '@react-native-firebase/database'; import { useHeaderHeight } from '@react-navigation/elements'; import { router, Stack, useLocalSearchParams } from "expo-router"; -import React, { useEffect, useState } from "react"; -import { KeyboardAvoidingView, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; +import { useEffect, useState } from "react"; +import { KeyboardAvoidingView, Platform, RefreshControl, ScrollView, View } from "react-native"; import Toast from "react-native-toast-message"; import { useSelector } from "react-redux"; -type Props = { - id: string - isActive: boolean - title: string - desc: string - status: number - createdAt: string +type DiscussionDetail = { + id: string; isActive: boolean; title: string + desc: string; status: number; createdAt: string } -type PropsKomentar = { - id: string - comment: string - createdAt: string - idUser: string - img: string - username: string - isEdited: boolean - updatedAt: string -} - -type PropsFile = { - id: string; - idStorage: string; - name: string; - extension: string -} +type PropsFile = { id: string; idStorage: string; name: string; extension: string } export default function DetailDiscussionGeneral() { const { token, decryptToken } = useAuthSession() - const { colors } = useTheme(); + const { colors } = useTheme() const entityUser = useSelector((state: any) => state.user) const entities = useSelector((state: any) => state.entities) - const { id } = useLocalSearchParams<{ id: string }>(); - const [data, setData] = useState() - const [dataKomentar, setDataKomentar] = useState([]) + const { id } = useLocalSearchParams<{ id: string }>() + const [data, setData] = useState() + const [dataKomentar, setDataKomentar] = useState([]) const [memberDiscussion, setMemberDiscussion] = useState(false) const [fileDiscussion, setFileDiscussion] = useState([]) const [komentar, setKomentar] = useState('') + const [commentFiles, setCommentFiles] = useState<{ uri: string; name: string }[]>([]) const update = useSelector((state: any) => state.discussionGeneralDetailUpdate) const [loading, setLoading] = useState(true) const [loadingKomentar, setLoadingKomentar] = useState(true) - const arrSkeleton = Array.from({ length: 3 }, (_, index) => index) - const reference = ref(getDB(), `/discussion-general/${id}`); - const headerHeight = useHeaderHeight(); - const [detailMore, setDetailMore] = useState([]) - const [loadingSendKomentar, setLoadingSendKomentar] = useState(false) + const [loadingSend, setLoadingSend] = useState(false) const [isVisible, setVisible] = useState(false) const [refreshing, setRefreshing] = useState(false) - const [selectKomentar, setSelectKomentar] = useState({ - id: '', - comment: '' - }) + const [selectKomentar, setSelectKomentar] = useState<{ id: string; comment: string; files: CommentFile[] }>({ id: '', comment: '', files: [] }) + const [removedFileIds, setRemovedFileIds] = useState([]) const [viewEdit, setViewEdit] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false) + const reference = ref(getDB(), `/discussion-general/${id}`) + const headerHeight = useHeaderHeight() useEffect(() => { const onValueChange = reference.on('value', snapshot => { - if (snapshot.val() == null) { - reference.set({ trigger: true }) - } + if (snapshot.val() == null) reference.set({ trigger: true }) handleLoad('komentar', false) - }); - - // Stop listening for updates when no longer required - return () => reference.off('value', onValueChange); - }, []); + }) + return () => reference.off('value', onValueChange) + }, []) function updateTrigger() { reference.once('value', snapshot => { - const data = snapshot.val(); - reference.update({ trigger: !data.trigger }); - }); + reference.update({ trigger: !snapshot.val().trigger }) + }) } - - async function handleLoad(cat: 'detail' | 'komentar' | 'cek-anggota' | 'file', loading: boolean) { + async function handleLoad(cat: 'detail' | 'komentar' | 'cek-anggota' | 'file', showLoading: boolean) { try { - if (cat == "detail") { - setLoading(loading) - } else if (cat == "komentar") { - setLoadingKomentar(loading) - } - + if (cat === 'detail') setLoading(showLoading) + else if (cat === 'komentar') setLoadingKomentar(showLoading) const hasil = await decryptToken(String(token?.current)) - const response = await apiGetDiscussionGeneralOne({ id: id, user: hasil, cat }) - - if (cat == 'detail') { - setData(response.data) - } else if (cat == 'komentar') { - setDataKomentar(response.data) - } else if (cat == 'cek-anggota') { - setMemberDiscussion(response.data) - } else if (cat == 'file') { - setFileDiscussion(response.data) - } - - } catch (error) { - console.error(error) - } finally { - setLoading(false) - setLoadingKomentar(false) - } + const response = await apiGetDiscussionGeneralOne({ id, user: hasil, cat }) + if (cat === 'detail') setData(response.data) + else if (cat === 'komentar') setDataKomentar(response.data) + else if (cat === 'cek-anggota') setMemberDiscussion(response.data) + else if (cat === 'file') setFileDiscussion(response.data) + } catch (error) { console.error(error) } + finally { setLoading(false); setLoadingKomentar(false) } } useEffect(() => { - handleLoad('detail', false) - handleLoad('komentar', false) - handleLoad('cek-anggota', false) - handleLoad('file', false) - }, [update]); + handleLoad('detail', true); handleLoad('komentar', true) + handleLoad('cek-anggota', true); handleLoad('file', true) + }, []) useEffect(() => { - handleLoad('detail', true) - handleLoad('komentar', true) - handleLoad('cek-anggota', true) - handleLoad('file', true) - }, []); + handleLoad('detail', false); handleLoad('komentar', false) + handleLoad('cek-anggota', false); handleLoad('file', false) + }, [update]) async function handleKomentar() { try { - setLoadingSendKomentar(true) - if (komentar != '') { - const hasil = await decryptToken(String(token?.current)) - const response = await apiSendDiscussionGeneralCommentar({ id: id, data: { desc: komentar, user: hasil } }) - if (response.success) { - setKomentar('') - updateTrigger() - } else { - Toast.show({ type: 'small', text1: response.message }) - } + setLoadingSend(true) + const hasil = await decryptToken(String(token?.current)) + let response + if (commentFiles.length > 0) { + const fd = new FormData() + commentFiles.forEach((f, i) => + fd.append(`file${i}`, { uri: f.uri, type: 'application/octet-stream', name: f.name } as any) + ) + fd.append("data", JSON.stringify({ desc: komentar, user: hasil })) + response = await apiSendDiscussionGeneralCommentarWithFile(id, fd) + } else { + response = await apiSendDiscussionGeneralCommentar({ id, data: { desc: komentar, user: hasil } }) + } + if (response.success) { + setKomentar(''); setCommentFiles([]) + updateTrigger() + } else { + 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 }) - } finally { - setLoadingSendKomentar(false) - } + Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menambahkan data" }) + } finally { setLoadingSend(false) } } async function handleEditKomentar() { try { - setLoadingSendKomentar(true) + setLoadingSend(true) const hasil = await decryptToken(String(token?.current)) - const response = await apiUpdateDiscussionGeneralCommentar({ id: selectKomentar.id, data: { desc: selectKomentar.comment, user: hasil } }) - if (response.success) { - updateTrigger() - } else { - Toast.show({ type: 'small', text1: response.message }) - } + const response = await apiUpdateDiscussionGeneralCommentar({ id: selectKomentar.id, data: { desc: selectKomentar.comment, user: hasil, filesToRemove: removedFileIds } }) + if (response.success) updateTrigger() + else Toast.show({ type: 'small', text1: response.message }) } catch (error: any) { - console.error(error); - const message = error?.response?.data?.message || "Gagal mengupdate data" - - Toast.show({ type: 'small', text1: message }) - } finally { - setLoadingSendKomentar(false) - handleViewEditKomentar() - } + Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal mengupdate data" }) + } finally { setLoadingSend(false); handleViewEditKomentar() } } async function handleDeleteKomentar() { try { - setLoadingSendKomentar(true) + setLoadingSend(true) const hasil = await decryptToken(String(token?.current)) const response = await apiDeleteDiscussionGeneralCommentar({ id: selectKomentar.id, data: { user: hasil } }) - if (response.success) { - updateTrigger() - } else { - Toast.show({ type: 'small', text1: response.message }) - } + if (response.success) updateTrigger() + else Toast.show({ type: 'small', text1: response.message }) } catch (error: any) { - console.error(error); - const message = error?.response?.data?.message || "Gagal menghapus data" - - Toast.show({ type: 'small', text1: message }) - } finally { - setLoadingSendKomentar(false) - setVisible(false) - } + Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menghapus data" }) + } finally { setLoadingSend(false); setVisible(false) } } - function handleMenuKomentar(id: string, comment: string) { - setSelectKomentar({ id, comment }) - setVisible(true) - } + function handleViewEditKomentar() { setVisible(false); setViewEdit(!viewEdit); setRemovedFileIds([]) } - function handleViewEditKomentar() { - setVisible(false) - setViewEdit(!viewEdit) + const isLocked = data?.status === 2 || !data?.isActive + const isMember = memberDiscussion || (entityUser.role !== "user" && entityUser.role !== "coadmin") + const canComment = !isLocked && isMember + + function lockedReason() { + if (data?.status === 2) return "Diskusi telah ditutup" + if (!data?.isActive) return "Diskusi telah diarsipkan" + return "Hanya anggota diskusi yang dapat memberikan komentar" } const handleRefresh = async () => { setRefreshing(true) - handleLoad('detail', false) - handleLoad('komentar', false) - handleLoad('cek-anggota', false) - handleLoad('file', false) - await new Promise(resolve => setTimeout(resolve, 2000)); + handleLoad('detail', false); handleLoad('komentar', false) + handleLoad('cek-anggota', false); handleLoad('file', false) + await new Promise(resolve => setTimeout(resolve, 2000)) setRefreshing(false) - }; + } return ( <> { router.back() }} />, - headerTitle: 'Diskusi', - headerTitleAlign: 'center', - // headerRight: () => , header: () => ( router.back()} - right={} + right={} /> ) }} @@ -250,219 +181,97 @@ export default function DetailDiscussionGeneral() { handleRefresh()} - tintColor={colors.icon} - /> - } + refreshControl={} > - - { - loading ? - - : - - - - } - title={data?.title} - titleShowAll={true} - subtitle={ - - - {!data?.isActive ? 'Arsip' : data?.status == 1 ? 'Buka' : 'Tutup'} - - - } - desc={data?.desc} - leftBottomInfo={ - - - {dataKomentar.length} Komentar - - } - rightBottomInfo={ - {data?.createdAt} - } - /> - } - - { - loadingKomentar ? - arrSkeleton.map((item: any, i: number) => { - return ( - - ) - }) - : - dataKomentar.map((item, i) => ( - { - setDetailMore((prev: any) => - prev.includes(item.id) - ? prev.filter((id: string) => id !== item.id) - : [...prev, item.id] - ) - }} - onLongPress={() => { - item.idUser == entities.id && data?.status != 2 && data?.isActive && handleMenuKomentar(item.id, item.comment) - }} - style={({ pressed }) => [ - Styles.discussionCommentCard, - { - backgroundColor: pressed ? colors.icon + '10' : colors.card, - borderColor: colors.icon + '20', - } - ]} - > - - {/* Name + time */} - - - - - {item.username} - - {item.isEdited && ( - - diedit - - )} - - - {item.createdAt} - - - - {/* Comment text */} - - {item.comment} - - - - )) - } - - - - - - { - viewEdit ? - <> - - - - Edit Komentar - - handleViewEditKomentar()}> - - + + {loading ? : ( + + - setSelectKomentar({ ...selectKomentar, comment: val })} - value={selectKomentar.comment} - multiline - focus={viewEdit} - itemRight={ - { - (!loadingSendKomentar && selectKomentar.comment != '' && !regexOnlySpacesOrEnter.test(selectKomentar.comment) && data?.status === 1 && data?.isActive && (memberDiscussion || (entityUser.role != "user" && entityUser.role != "coadmin"))) - && handleEditKomentar() - }} - style={[Platform.OS == 'android' && Styles.mb12]} - > - - - } - /> - - : - data?.status != 2 && data?.isActive && ((entityUser.role != "user" && entityUser.role != "coadmin") || memberDiscussion) - ? - { - (!loadingSendKomentar && komentar != '' && !regexOnlySpacesOrEnter.test(komentar) && data?.status === 1 && data?.isActive && (memberDiscussion || (entityUser.role != "user" && entityUser.role != "coadmin"))) - && handleKomentar() - }} - style={[Platform.OS == 'android' && Styles.mb12]} - > - - - } - /> - : - - - { - data?.status == 2 ? "Diskusi telah ditutup" : data?.isActive == false ? "Diskusi telah diarsipkan" : "Hanya anggota diskusi yang dapat memberikan komentar" - } + } + title={data?.title} + titleShowAll={true} + subtitle={ + + + {!data?.isActive ? 'Arsip' : data?.status === 1 ? 'Buka' : 'Tutup'} - } - + } + desc={data?.desc} + leftBottomInfo={ + + + {dataKomentar.length} Komentar + + } + rightBottomInfo={{data?.createdAt}} + /> + )} + { + setSelectKomentar({ id: commentId, comment, files }) + setRemovedFileIds([]) + setVisible(true) + }} + /> + + + + {canComment ? ( + setSelectKomentar({ ...selectKomentar, comment: val }) + : setKomentar + } + loading={loadingSend} + onSend={viewEdit ? handleEditKomentar : handleKomentar} + onCancelEdit={handleViewEditKomentar} + files={commentFiles} + onAddFile={(newFiles) => setCommentFiles(prev => [...prev, ...newFiles])} + onRemoveFile={(idx) => setCommentFiles(prev => prev.filter((_, i) => i !== idx))} + existingFiles={viewEdit ? selectKomentar.files.filter(f => !removedFileIds.includes(f.id)) : []} + onRemoveExistingFile={(fileId) => setRemovedFileIds(prev => [...prev, fileId])} + canSend={canComment} + /> + ) : ( + {}} loading={false} onSend={() => {}} canSend={false} + /> + )} - + } title="Edit" - onPress={() => { handleViewEditKomentar() }} + onPress={() => handleViewEditKomentar()} /> } title="Hapus" - onPress={() => { - setVisible(false) - setTimeout(() => { - setShowDeleteModal(true) - }, 600) - }} + onPress={() => { setVisible(false); setTimeout(() => setShowDeleteModal(true), 600) }} /> @@ -471,14 +280,11 @@ export default function DetailDiscussionGeneral() { visible={showDeleteModal} title="Konfirmasi" message="Apakah anda yakin ingin menghapus komentar?" - onConfirm={() => { - setShowDeleteModal(false) - handleDeleteKomentar() - }} + onConfirm={() => { setShowDeleteModal(false); handleDeleteKomentar() }} onCancel={() => setShowDeleteModal(false)} confirmText="Hapus" cancelText="Batal" /> ) -} \ No newline at end of file +} diff --git a/lib/api/discussion.api.ts b/lib/api/discussion.api.ts index ef178af..eeb42a7 100644 --- a/lib/api/discussion.api.ts +++ b/lib/api/discussion.api.ts @@ -39,7 +39,14 @@ export const apiSendDiscussionGeneralCommentar = async ({ id, data }: { id: stri return response.data; }; -export const apiUpdateDiscussionGeneralCommentar = async ({ id, data }: { id: string, data: { desc: string, user: string } }) => { +export const apiSendDiscussionGeneralCommentarWithFile = async (id: string, data: FormData) => { + const response = await api.post(`/mobile/discussion-general/${id}/comment`, data, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return response.data; +}; + +export const apiUpdateDiscussionGeneralCommentar = async ({ id, data }: { id: string, data: { desc: string, user: string, filesToRemove?: string[] } }) => { const response = await api.put(`/mobile/discussion-general/${id}/comment`, data) return response.data; }; -- 2.49.1