feat: ubah halaman tambah anggota diskusi umum menggunakan pilih anggota berdasarkan divisi

This commit is contained in:
2026-06-09 15:25:22 +08:00
parent 209254af23
commit 9cd78dae3a

View File

@@ -6,69 +6,159 @@ import InputSearch from "@/components/inputSearch";
import Text from '@/components/Text'; import Text from '@/components/Text';
import { ConstEnv } from "@/constants/ConstEnv"; import { ConstEnv } from "@/constants/ConstEnv";
import Styles from "@/constants/Styles"; 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 { setUpdateDiscussionGeneralDetail } from "@/lib/discussionGeneralDetail";
import { useAuthSession } from "@/providers/AuthProvider"; import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider"; 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 { router, Stack, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react"; 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 Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
type Props = { type Member = { idUser: string; name: string; img: string }
idUser: string,
name: string, type DivisionItem = {
img: string id: string
name: string
expanded: boolean
membersLoaded: boolean
members: Member[]
} }
export default function AddMemberDiscussionDetail() { export default function AddMemberDiscussionDetail() {
const dispatch = useDispatch() const dispatch = useDispatch()
const update = useSelector((state: any) => state.discussionGeneralDetailUpdate) const update = useSelector((state: any) => state.discussionGeneralDetailUpdate)
const { token, decryptToken } = useAuthSession() const { token, decryptToken } = useAuthSession()
const { colors } = useTheme(); const { colors } = useTheme()
const { id } = useLocalSearchParams<{ id: string }>() const { id } = useLocalSearchParams<{ id: string }>()
const [dataOld, setDataOld] = useState<Props[]>([]) const [dataOld, setDataOld] = useState<any[]>([])
const [data, setData] = useState<Props[]>([])
const [idGroup, setIdGroup] = useState('') const [idGroup, setIdGroup] = useState('')
const [selectMember, setSelectMember] = useState<any[]>([]) const [selectMember, setSelectMember] = useState<Member[]>([])
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [divisions, setDivisions] = useState<DivisionItem[]>([])
const [loadingDivisions, setLoadingDivisions] = useState(false)
const [loadingIds, setLoadingIds] = useState<string[]>([])
const [searchResults, setSearchResults] = useState<Member[]>([])
const [loadingSearch, setLoadingSearch] = useState(false)
async function handleLoad() { async function handleLoad() {
try { try {
const hasil = await decryptToken(String(token?.current)) const hasil = await decryptToken(String(token?.current))
const response = await apiGetDiscussionGeneralOne({ id: id, user: hasil, cat: 'anggota' }) const [resAnggota, resDetail] = await Promise.all([
setDataOld(response.data) apiGetDiscussionGeneralOne({ id, user: hasil, cat: 'anggota' }),
const responseGroup = await apiGetDiscussionGeneralOne({ id: id, user: hasil, cat: 'detail' }) apiGetDiscussionGeneralOne({ id, user: hasil, cat: 'detail' })
setIdGroup(responseGroup.data.idGroup) ])
setDataOld(resAnggota.data ?? [])
setIdGroup(resDetail.data.idGroup)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} }
} }
async function handleLoadMember() { async function loadDivisions(group: string) {
const hasil = await decryptToken(String(token?.current)) if (!group) return
const response = await apiGetUser({ user: hasil, active: "true", search: search, group: String(idGroup) }) setLoadingDivisions(true)
setData(response.data.filter((i: any) => i.idUserRole != 'supadmin')) 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<Member[]> {
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(() => { useEffect(() => {
handleLoad() if (idGroup) loadDivisions(idGroup)
}, []); }, [idGroup])
useEffect(() => { useEffect(() => {
handleLoadMember() if (!idGroup) return
if (search) {
searchUsers(search)
} else {
setSearchResults([])
loadDivisions(idGroup)
}
}, [search]) }, [search])
function onChoose(val: string, label: string, img?: string) { async function handleTapDivision(division: DivisionItem) {
if (selectMember.some((i: any) => i.idUser == val)) { let members = division.members
setSelectMember(selectMember.filter((i: any) => i.idUser != val)) 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 { } 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 { try {
setLoading(true) setLoading(true)
const hasil = await decryptToken(String(token?.current)) 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) { if (response.success) {
Toast.show({ type: 'small', text1: 'Berhasil menambahkan anggota', }) Toast.show({ type: 'small', text1: 'Berhasil menambahkan anggota' })
dispatch(setUpdateDiscussionGeneralDetail(!update)) dispatch(setUpdateDiscussionGeneralDetail(!update))
router.back() router.back()
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message })
} }
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error)
const message = error?.response?.data?.message || "Gagal menambahkan anggota" Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menambahkan anggota" })
Toast.show({ type: 'small', text1: message })
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
return ( return (
<> <>
<Stack.Screen <Stack.Screen
options={{ options={{
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
headerTitle: 'Tambah Anggota Diskusi',
headerTitleAlign: 'center',
// headerRight: () => (
// <ButtonSaveHeader
// category="update"
// disable={selectMember.length == 0 || loading ? true : false}
// onPress={() => {
// handleAddMember()
// }}
// />
// )
header: () => ( header: () => (
<AppHeader <AppHeader
title="Tambah Anggota Diskusi" title="Tambah Anggota Diskusi"
@@ -119,10 +194,8 @@ export default function AddMemberDiscussionDetail() {
right={ right={
<ButtonSaveHeader <ButtonSaveHeader
category="update" category="update"
disable={selectMember.length == 0 || loading ? true : false} disable={selectMember.length === 0 || loading}
onPress={() => { onPress={handleAddMember}
handleAddMember()
}}
/> />
} }
/> />
@@ -131,65 +204,138 @@ export default function AddMemberDiscussionDetail() {
/> />
<View style={[Styles.p15, Styles.flex1, { backgroundColor: colors.background }]}> <View style={[Styles.p15, Styles.flex1, { backgroundColor: colors.background }]}>
<InputSearch onChange={setSearch} value={search} /> <InputSearch onChange={setSearch} value={search} />
{selectMember.length > 0 ? (
{ <View>
selectMember.length > 0 <ScrollView horizontal style={[Styles.mb10, Styles.pv10]} showsHorizontalScrollIndicator={false}>
? {selectMember.map((item, index) => (
<View> <ImageWithLabel
<ScrollView horizontal style={[Styles.mb10, Styles.pv10]} showsHorizontalScrollIndicator={false}> key={index}
{ label={item.name}
selectMember.map((item: any, index: any) => ( src={`${ConstEnv.url_storage}/files/${item.img}`}
<ImageWithLabel onClick={() => handleToggleMember(item)}
key={index} />
label={item.name} ))}
src={`${ConstEnv.url_storage}/files/${item.img}`} </ScrollView>
onClick={() => onChoose(item.idUser, item.name, item.img)} </View>
/> ) : (
)) <Text style={[Styles.textDefault, Styles.pv05, Styles.textCenter, { color: colors.dimmed }]}>
} Tidak ada member yang dipilih
</ScrollView> </Text>
</View> )}
<ScrollView showsVerticalScrollIndicator={false}>
: <View>
<Text style={[Styles.textDefault, Styles.pv05, Styles.textCenter, { color: colors.dimmed }]}>Tidak ada member yang dipilih</Text> {search ? (
} loadingSearch ? (
<ScrollView <ActivityIndicator color={colors.tabActive} style={{ marginTop: 20 }} />
showsVerticalScrollIndicator={false} ) : searchResults.length > 0 ? (
> searchResults.map((item, idx) => {
const isOld = dataOld.some((o: any) => o.idUser === item.idUser)
{ return (
data.length > 0 ? <Pressable
data.map((item: any, index: any) => { key={idx}
const found = dataOld.some((i: any) => i.idUser == item.id) style={[Styles.itemSelectModal, { borderColor: colors.icon + '20' }]}
return ( onPress={() => !isOld && handleToggleMember(item)}
<Pressable >
key={index} <View style={Styles.rowItemsCenter}>
style={[Styles.itemSelectModal, { borderColor: colors.icon + '20' }]} <ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} border />
onPress={() => { <View style={Styles.ml10}>
!found && onChoose(item.id, item.name, item.img) <Text style={Styles.textDefault}>{item.name}</Text>
}} {isOld && (
> <Text style={[Styles.textInformation, { color: colors.dimmed }]}>sudah menjadi anggota</Text>
<View style={[Styles.rowItemsCenter]}> )}
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} border /> </View>
<View style={[Styles.ml10]}>
<Text style={[Styles.textDefault]}>{item.name}</Text>
{
found && <Text style={[Styles.textInformation, { color: colors.dimmed }]}>sudah menjadi anggota</Text>
}
</View> </View>
</View> {selectMember.some(s => s.idUser === item.idUser) && (
{ <AntDesign name="check" size={18} color={colors.tabActive} />
selectMember.some((i: any) => i.idUser == item.id) && <AntDesign name="check" size={20} color={colors.text} /> )}
} </Pressable>
</Pressable> )
) })
} ) : (
<Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed, marginTop: 20 }]}>
Tidak ada hasil
</Text>
) )
: ) : loadingDivisions ? (
<Text style={[Styles.textDefault, Styles.textCenter]}>Tidak ada data</Text> <ActivityIndicator color={colors.tabActive} style={{ marginTop: 20 }} />
} ) : 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 (
<View key={division.id}>
<Pressable
style={[Styles.itemSelectModal, { borderColor: colors.icon + '20' }]}
onPress={() => handleTapDivision(division)}
>
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>{division.name}</Text>
{division.membersLoaded && (
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>
{selectedCount > 0
? `${selectedCount} dari ${eligible.length} dipilih`
: `${eligible.length} anggota`}
</Text>
)}
</View>
{isLoadingThis ? (
<ActivityIndicator size="small" color={colors.dimmed} />
) : allSelected ? (
<AntDesign name="checkcircle" size={18} color={colors.tabActive} />
) : someSelected ? (
<AntDesign name="checkcircleo" size={18} color={colors.tabActive} />
) : null}
<Pressable
onPress={() => handleToggleExpand(division.id)}
style={{ paddingLeft: 10 }}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons
name={division.expanded ? "chevron-up" : "chevron-down"}
size={16}
color={colors.dimmed}
/>
</Pressable>
</Pressable>
{division.expanded && division.members.map((member, idx) => {
const isOld = dataOld.some((o: any) => o.idUser === member.idUser)
return (
<Pressable
key={idx}
style={[Styles.itemSelectModal, { borderColor: colors.icon + '15' }]}
onPress={() => !isOld && handleToggleMember(member)}
>
<View style={Styles.rowItemsCenter}>
<ImageUser src={`${ConstEnv.url_storage}/files/${member.img}`} border />
<View style={Styles.ml10}>
<Text style={Styles.textDefault}>{member.name}</Text>
{isOld && (
<Text style={[Styles.textInformation, { color: colors.dimmed }]}>sudah menjadi anggota</Text>
)}
</View>
</View>
{!isOld && selectMember.some(s => s.idUser === member.idUser) && (
<AntDesign name="check" size={18} color={colors.tabActive} />
)}
</Pressable>
)
})}
</View>
)
})
) : (
<Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed, marginTop: 20 }]}>
Tidak ada divisi
</Text>
)}
</View>
</ScrollView> </ScrollView>
</View> </View>
</> </>
) )
} }