25 Commits

Author SHA1 Message Date
3567629941 Merge pull request 'amalia/09-jun-26' (#57) from amalia/09-jun-26 into join
Reviewed-on: #57
2026-06-09 17:42:33 +08:00
4ab8422808 feat: tambah fitur hapus file existing saat edit komentar diskusi umum
- 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
2026-06-09 17:35:30 +08:00
3370f48238 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)
2026-06-09 17:35:22 +08:00
9cd78dae3a feat: ubah halaman tambah anggota diskusi umum menggunakan pilih anggota berdasarkan divisi 2026-06-09 15:25:22 +08:00
209254af23 feat: tambah komponen pilih anggota berdasarkan divisi pada tambah diskusi umum 2026-06-09 15:25:15 +08:00
6c80eb77fb Merge pull request 'amalia/08-jun-26' (#56) from amalia/08-jun-26 into join
Reviewed-on: #56
2026-06-08 17:27:20 +08:00
ae96a79b69 upd: tampilan jarak antar section pada fitur detail kegiatan dan detail tugas divisi 2026-06-08 17:26:11 +08:00
a5c58d0de2 feat: tambah fitur pilih semua pada halaman pilih anggota divisi 2026-06-08 16:49:16 +08:00
c979a68028 feat: tambah fitur pilih semua pada halaman pilih anggota kegiatan & tugas 2026-06-08 16:43:59 +08:00
2cf5c8d960 feat: tambah fitur pilih semua pada modal pilih divisi
Menambahkan baris "Pilih Semua" / "Batalkan Semua" di atas list pada
ModalSelectMultiple untuk kedua kategori choose-division dan share-division.
2026-06-08 16:26:26 +08:00
9dc4d8dc8d fix: perbaiki parseDate agar case-insensitive sehingga urutan tanggal notifikasi benar 2026-06-08 14:42:27 +08:00
789e4f84f1 fix: kurangi paddingHorizontal card carousel di halaman utama agar label Pengumuman tidak terpotong di iPhone 2026-06-08 12:12:22 +08:00
99c13b57e1 Merge pull request 'amalia/03-jun-26' (#54) from amalia/03-jun-26 into join
Reviewed-on: #54
2026-06-08 11:28:36 +08:00
47ed52e9d2 chore: update kotlin error logs 2026-06-03 13:49:27 +08:00
02904b1e48 chore: bump versionCode 20 → 21 2026-06-03 13:46:59 +08:00
8df5b48578 fix: gunakan tools:node=remove agar Gradle merger hapus READ_MEDIA_IMAGES/VIDEO dari semua library manifest
Filter sebelumnya hanya menghapus dari app manifest, tapi expo-image-picker
menyuntikkan permission lewat library manifest-nya sendiri saat Gradle build.
2026-06-03 13:41:00 +08:00
21617f9c4c chore: update kotlin error logs 2026-06-03 11:32:57 +08:00
383ca069d5 chore: bump versionCode 19 → 20 2026-06-03 11:32:00 +08:00
267454637f fix: hapus READ_MEDIA_IMAGES & VIDEO via Expo config plugin agar tahan prebuild EAS
Perubahan sebelumnya langsung di android/ tidak efektif karena EAS
melakukan prebuild ulang. Plugin withRemoveMediaPermissions.js kini
memfilter permission tersebut saat prebuild berjalan.
2026-06-03 11:08:06 +08:00
f939ddb5f5 Merge pull request 'amalia/02-jun-26' (#53) from amalia/02-jun-26 into join
Reviewed-on: #53
2026-06-02 17:41:33 +08:00
e135c9dc11 chore: bump versionCode 18 → 19 2026-06-02 16:56:54 +08:00
144db584d0 fix: paksa hapus READ_MEDIA_IMAGES dan READ_MEDIA_VIDEO dari merged manifest
expo-media-library (dependency transitif) menyuntikkan kedua permission ini
saat build. tools:node="remove" memastikan permission dihapus dari hasil merge.
2026-06-02 16:55:00 +08:00
90551399a5 fix: hapus permission READ_MEDIA_IMAGES dan READ_MEDIA_VIDEO untuk comply Google Play policy
versionCode bump 17 → 18
2026-06-02 16:24:26 +08:00
0d46d76c70 Merge pull request 'amalia/21-mei-26' (#52) from amalia/21-mei-26 into join
Reviewed-on: #52
2026-05-21 17:25:17 +08:00
1c3aa308d1 Merge pull request 'amalia/20-mei-26' (#51) from amalia/20-mei-26 into join
Reviewed-on: #51
2026-05-20 17:21:40 +08:00
28 changed files with 1434 additions and 524 deletions

View File

@@ -1,4 +0,0 @@
kotlin version: 2.0.21
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
1. Kotlin compile daemon is ready

View File

@@ -92,7 +92,7 @@ android {
applicationId 'mobiledarmasaba.app' applicationId 'mobiledarmasaba.app'
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 17 versionCode 21
versionName "2.2.0" versionName "2.2.0"
} }
signingConfigs { signingConfigs {

View File

@@ -2,8 +2,8 @@
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/> <uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/> <uses-permission android:name="android.permission.VIBRATE"/>

View File

@@ -23,7 +23,7 @@ export default {
}, },
android: { android: {
package: "mobiledarmasaba.app", package: "mobiledarmasaba.app",
versionCode: 17, versionCode: 21,
adaptiveIcon: { adaptiveIcon: {
foregroundImage: "./assets/images/logo-icon-small.png", foregroundImage: "./assets/images/logo-icon-small.png",
backgroundColor: "#ffffff" backgroundColor: "#ffffff"
@@ -32,9 +32,7 @@ export default {
permissions: [ permissions: [
"READ_EXTERNAL_STORAGE", "READ_EXTERNAL_STORAGE",
"WRITE_EXTERNAL_STORAGE", "WRITE_EXTERNAL_STORAGE",
"READ_MEDIA_IMAGES", // Android 13+ "READ_MEDIA_AUDIO"
"READ_MEDIA_VIDEO", // Android 13+
"READ_MEDIA_AUDIO" // Android 13+
] ]
}, },
web: { web: {
@@ -56,6 +54,7 @@ export default {
"expo-font", "expo-font",
"expo-image-picker", "expo-image-picker",
"expo-web-browser", "expo-web-browser",
"./plugins/withRemoveMediaPermissions",
[ [
"@react-native-firebase/app", "@react-native-firebase/app",
{ {

View File

@@ -1,247 +1,178 @@
import AppHeader from "@/components/AppHeader"; import AppHeader from "@/components/AppHeader";
import BorderBottomItem2 from "@/components/borderBottomItem2"; 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 HeaderRightDiscussionGeneralDetail from "@/components/discussion_general/headerDiscussionDetail";
import DrawerBottom from "@/components/drawerBottom"; import DrawerBottom from "@/components/drawerBottom";
import ImageUser from "@/components/imageNew";
import { InputForm } from "@/components/inputForm";
import MenuItemRow from "@/components/menuItemRow"; import MenuItemRow from "@/components/menuItemRow";
import ModalConfirmation from "@/components/ModalConfirmation"; import ModalConfirmation from "@/components/ModalConfirmation";
import Skeleton from "@/components/skeleton";
import SkeletonContent from "@/components/skeletonContent"; import SkeletonContent from "@/components/skeletonContent";
import Text from '@/components/Text'; import Text from '@/components/Text';
import { ConstEnv } from "@/constants/ConstEnv"; import { ConstEnv } from "@/constants/ConstEnv";
import { regexOnlySpacesOrEnter } from "@/constants/OnlySpaceOrEnter";
import Styles from "@/constants/Styles"; 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 { getDB } from "@/lib/firebaseDatabase";
import { useAuthSession } from "@/providers/AuthProvider"; import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider"; 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 { ref } from '@react-native-firebase/database';
import { useHeaderHeight } from '@react-navigation/elements'; import { useHeaderHeight } from '@react-navigation/elements';
import { router, Stack, useLocalSearchParams } from "expo-router"; import { router, Stack, useLocalSearchParams } from "expo-router";
import React, { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { KeyboardAvoidingView, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; import { KeyboardAvoidingView, Platform, RefreshControl, ScrollView, View } from "react-native";
import Toast from "react-native-toast-message"; import Toast from "react-native-toast-message";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
type Props = { type DiscussionDetail = {
id: string id: string; isActive: boolean; title: string
isActive: boolean desc: string; status: number; createdAt: string
title: string
desc: string
status: number
createdAt: string
} }
type PropsKomentar = { type PropsFile = { id: string; idStorage: string; name: string; extension: string }
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
}
export default function DetailDiscussionGeneral() { export default function DetailDiscussionGeneral() {
const { token, decryptToken } = useAuthSession() const { token, decryptToken } = useAuthSession()
const { colors } = useTheme(); const { colors } = useTheme()
const entityUser = useSelector((state: any) => state.user) const entityUser = useSelector((state: any) => state.user)
const entities = useSelector((state: any) => state.entities) const entities = useSelector((state: any) => state.entities)
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>()
const [data, setData] = useState<Props>() const [data, setData] = useState<DiscussionDetail>()
const [dataKomentar, setDataKomentar] = useState<PropsKomentar[]>([]) const [dataKomentar, setDataKomentar] = useState<CommentItem[]>([])
const [memberDiscussion, setMemberDiscussion] = useState(false) const [memberDiscussion, setMemberDiscussion] = useState(false)
const [fileDiscussion, setFileDiscussion] = useState<PropsFile[]>([]) const [fileDiscussion, setFileDiscussion] = useState<PropsFile[]>([])
const [komentar, setKomentar] = useState('') const [komentar, setKomentar] = useState('')
const [commentFiles, setCommentFiles] = useState<{ uri: string; name: string }[]>([])
const update = useSelector((state: any) => state.discussionGeneralDetailUpdate) const update = useSelector((state: any) => state.discussionGeneralDetailUpdate)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [loadingKomentar, setLoadingKomentar] = useState(true) const [loadingKomentar, setLoadingKomentar] = useState(true)
const arrSkeleton = Array.from({ length: 3 }, (_, index) => index) const [loadingSend, setLoadingSend] = useState(false)
const reference = ref(getDB(), `/discussion-general/${id}`);
const headerHeight = useHeaderHeight();
const [detailMore, setDetailMore] = useState<any>([])
const [loadingSendKomentar, setLoadingSendKomentar] = useState(false)
const [isVisible, setVisible] = useState(false) const [isVisible, setVisible] = useState(false)
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const [selectKomentar, setSelectKomentar] = useState({ const [selectKomentar, setSelectKomentar] = useState<{ id: string; comment: string; files: CommentFile[] }>({ id: '', comment: '', files: [] })
id: '', const [removedFileIds, setRemovedFileIds] = useState<string[]>([])
comment: ''
})
const [viewEdit, setViewEdit] = useState(false) const [viewEdit, setViewEdit] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false)
const reference = ref(getDB(), `/discussion-general/${id}`)
const headerHeight = useHeaderHeight()
useEffect(() => { useEffect(() => {
const onValueChange = reference.on('value', snapshot => { const onValueChange = reference.on('value', snapshot => {
if (snapshot.val() == null) { if (snapshot.val() == null) reference.set({ trigger: true })
reference.set({ trigger: true })
}
handleLoad('komentar', false) handleLoad('komentar', false)
}); })
return () => reference.off('value', onValueChange)
// Stop listening for updates when no longer required }, [])
return () => reference.off('value', onValueChange);
}, []);
function updateTrigger() { function updateTrigger() {
reference.once('value', snapshot => { reference.once('value', snapshot => {
const data = snapshot.val(); reference.update({ trigger: !snapshot.val().trigger })
reference.update({ trigger: !data.trigger }); })
});
} }
async function handleLoad(cat: 'detail' | 'komentar' | 'cek-anggota' | 'file', showLoading: boolean) {
async function handleLoad(cat: 'detail' | 'komentar' | 'cek-anggota' | 'file', loading: boolean) {
try { try {
if (cat == "detail") { if (cat === 'detail') setLoading(showLoading)
setLoading(loading) else if (cat === 'komentar') setLoadingKomentar(showLoading)
} else if (cat == "komentar") {
setLoadingKomentar(loading)
}
const hasil = await decryptToken(String(token?.current)) const hasil = await decryptToken(String(token?.current))
const response = await apiGetDiscussionGeneralOne({ id: id, user: hasil, cat }) const response = await apiGetDiscussionGeneralOne({ id, user: hasil, cat })
if (cat === 'detail') setData(response.data)
if (cat == 'detail') { else if (cat === 'komentar') setDataKomentar(response.data)
setData(response.data) else if (cat === 'cek-anggota') setMemberDiscussion(response.data)
} else if (cat == 'komentar') { else if (cat === 'file') setFileDiscussion(response.data)
setDataKomentar(response.data) } catch (error) { console.error(error) }
} else if (cat == 'cek-anggota') { finally { setLoading(false); setLoadingKomentar(false) }
setMemberDiscussion(response.data)
} else if (cat == 'file') {
setFileDiscussion(response.data)
}
} catch (error) {
console.error(error)
} finally {
setLoading(false)
setLoadingKomentar(false)
}
} }
useEffect(() => { useEffect(() => {
handleLoad('detail', false) handleLoad('detail', true); handleLoad('komentar', true)
handleLoad('komentar', false) handleLoad('cek-anggota', true); handleLoad('file', true)
handleLoad('cek-anggota', false) }, [])
handleLoad('file', false)
}, [update]);
useEffect(() => { useEffect(() => {
handleLoad('detail', true) handleLoad('detail', false); handleLoad('komentar', false)
handleLoad('komentar', true) handleLoad('cek-anggota', false); handleLoad('file', false)
handleLoad('cek-anggota', true) }, [update])
handleLoad('file', true)
}, []);
async function handleKomentar() { async function handleKomentar() {
try { try {
setLoadingSendKomentar(true) setLoadingSend(true)
if (komentar != '') {
const hasil = await decryptToken(String(token?.current)) const hasil = await decryptToken(String(token?.current))
const response = await apiSendDiscussionGeneralCommentar({ id: id, data: { desc: komentar, user: hasil } }) 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) { if (response.success) {
setKomentar('') setKomentar(''); setCommentFiles([])
updateTrigger() updateTrigger()
} 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); Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menambahkan data" })
const message = error?.response?.data?.message || "Gagal menambahkan data" } finally { setLoadingSend(false) }
Toast.show({ type: 'small', text1: message })
} finally {
setLoadingSendKomentar(false)
}
} }
async function handleEditKomentar() { async function handleEditKomentar() {
try { try {
setLoadingSendKomentar(true) setLoadingSend(true)
const hasil = await decryptToken(String(token?.current)) const hasil = await decryptToken(String(token?.current))
const response = await apiUpdateDiscussionGeneralCommentar({ id: selectKomentar.id, data: { desc: selectKomentar.comment, user: hasil } }) const response = await apiUpdateDiscussionGeneralCommentar({ id: selectKomentar.id, data: { desc: selectKomentar.comment, user: hasil, filesToRemove: removedFileIds } })
if (response.success) { if (response.success) updateTrigger()
updateTrigger() else Toast.show({ type: 'small', text1: response.message })
} else {
Toast.show({ type: 'small', text1: response.message })
}
} catch (error: any) { } catch (error: any) {
console.error(error); Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal mengupdate data" })
const message = error?.response?.data?.message || "Gagal mengupdate data" } finally { setLoadingSend(false); handleViewEditKomentar() }
Toast.show({ type: 'small', text1: message })
} finally {
setLoadingSendKomentar(false)
handleViewEditKomentar()
}
} }
async function handleDeleteKomentar() { async function handleDeleteKomentar() {
try { try {
setLoadingSendKomentar(true) setLoadingSend(true)
const hasil = await decryptToken(String(token?.current)) const hasil = await decryptToken(String(token?.current))
const response = await apiDeleteDiscussionGeneralCommentar({ id: selectKomentar.id, data: { user: hasil } }) const response = await apiDeleteDiscussionGeneralCommentar({ id: selectKomentar.id, data: { user: hasil } })
if (response.success) { if (response.success) updateTrigger()
updateTrigger() else Toast.show({ type: 'small', text1: response.message })
} else {
Toast.show({ type: 'small', text1: response.message })
}
} catch (error: any) { } catch (error: any) {
console.error(error); Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menghapus data" })
const message = error?.response?.data?.message || "Gagal menghapus data" } finally { setLoadingSend(false); setVisible(false) }
Toast.show({ type: 'small', text1: message })
} finally {
setLoadingSendKomentar(false)
setVisible(false)
}
} }
function handleMenuKomentar(id: string, comment: string) { function handleViewEditKomentar() { setVisible(false); setViewEdit(!viewEdit); setRemovedFileIds([]) }
setSelectKomentar({ id, comment })
setVisible(true)
}
function handleViewEditKomentar() { const isLocked = data?.status === 2 || !data?.isActive
setVisible(false) const isMember = memberDiscussion || (entityUser.role !== "user" && entityUser.role !== "coadmin")
setViewEdit(!viewEdit) 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 () => { const handleRefresh = async () => {
setRefreshing(true) setRefreshing(true)
handleLoad('detail', false) handleLoad('detail', false); handleLoad('komentar', false)
handleLoad('komentar', false) handleLoad('cek-anggota', false); handleLoad('file', false)
handleLoad('cek-anggota', false) await new Promise(resolve => setTimeout(resolve, 2000))
handleLoad('file', false)
await new Promise(resolve => setTimeout(resolve, 2000));
setRefreshing(false) setRefreshing(false)
}; }
return ( return (
<> <>
<Stack.Screen <Stack.Screen
options={{ options={{
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
headerTitle: 'Diskusi',
headerTitleAlign: 'center',
// headerRight: () => <HeaderRightDiscussionGeneralDetail id={id} active={data?.isActive !== undefined ? data.isActive : false} status={data?.status !== undefined ? data.status : 0} />,
header: () => ( header: () => (
<AppHeader <AppHeader
title="Diskusi" title="Diskusi"
showBack={true} showBack={true}
onPressLeft={() => router.back()} onPressLeft={() => router.back()}
right={<HeaderRightDiscussionGeneralDetail id={id} active={data?.isActive !== undefined ? data.isActive : false} status={data?.status !== undefined ? data.status : 0} />} right={<HeaderRightDiscussionGeneralDetail id={id} active={data?.isActive ?? false} status={data?.status ?? 0} />}
/> />
) )
}} }}
@@ -250,19 +181,10 @@ export default function DetailDiscussionGeneral() {
<ScrollView <ScrollView
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
style={[Styles.h100, { backgroundColor: colors.background }]} style={[Styles.h100, { backgroundColor: colors.background }]}
refreshControl={ refreshControl={<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} tintColor={colors.icon} />}
<RefreshControl
refreshing={refreshing}
onRefresh={() => handleRefresh()}
tintColor={colors.icon}
/>
}
> >
<View style={[Styles.p15]}> <View style={Styles.p15}>
{ {loading ? <SkeletonContent /> : (
loading ?
<SkeletonContent />
:
<BorderBottomItem2 <BorderBottomItem2
dataFile={fileDiscussion} dataFile={fileDiscussion}
descEllipsize={false} descEllipsize={false}
@@ -277,192 +199,79 @@ export default function DetailDiscussionGeneral() {
titleShowAll={true} titleShowAll={true}
subtitle={ subtitle={
<View style={[Styles.discussionStatusPill, { <View style={[Styles.discussionStatusPill, {
borderColor: !data?.isActive borderColor: !data?.isActive ? '#F59E0B' : data?.status === 1 ? '#10B981' : colors.dimmed + '80'
? '#F59E0B'
: data?.status == 1 ? '#10B981' : colors.dimmed + '80',
}]}> }]}>
<Text style={[Styles.discussionStatusText, { <Text style={[Styles.discussionStatusText, {
color: !data?.isActive color: !data?.isActive ? '#F59E0B' : data?.status === 1 ? '#10B981' : colors.dimmed
? '#F59E0B'
: data?.status == 1 ? '#10B981' : colors.dimmed,
}]}> }]}>
{!data?.isActive ? 'Arsip' : data?.status == 1 ? 'Buka' : 'Tutup'} {!data?.isActive ? 'Arsip' : data?.status === 1 ? 'Buka' : 'Tutup'}
</Text> </Text>
</View> </View>
} }
desc={data?.desc} desc={data?.desc}
leftBottomInfo={ leftBottomInfo={
<View style={[Styles.rowItemsCenter]}> <View style={Styles.rowItemsCenter}>
<Feather name="message-square" size={14} color={colors.dimmed} style={Styles.mr05} /> <Feather name="message-square" size={14} color={colors.dimmed} style={Styles.mr05} />
<Text style={[Styles.textInformation, { color: colors.dimmed }]}>{dataKomentar.length} Komentar</Text> <Text style={[Styles.textInformation, { color: colors.dimmed }]}>{dataKomentar.length} Komentar</Text>
</View> </View>
} }
rightBottomInfo={ rightBottomInfo={<Text style={[Styles.textInformation, { color: colors.dimmed }]}>{data?.createdAt}</Text>}
<Text style={[Styles.textInformation, { color: colors.dimmed }]}>{data?.createdAt}</Text>
}
/> />
}
<View style={[Styles.mt10]}>
{
loadingKomentar ?
arrSkeleton.map((item: any, i: number) => {
return (
<Skeleton key={i} width={100} widthType="percent" height={40} borderRadius={5} />
)
})
:
dataKomentar.map((item, i) => (
<Pressable
key={i}
onPress={() => {
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',
}
]}
>
<View style={Styles.flex1}>
{/* Name + time */}
<View style={[Styles.rowSpaceBetween, Styles.itemsCenter, Styles.mb05]}>
<View style={[Styles.rowItemsCenter, { gap: 8, flex: 1, marginRight: 8 }]}>
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
<Text style={[Styles.textMediumSemiBold, { color: colors.text }]} numberOfLines={1}>
{item.username}
</Text>
{item.isEdited && (
<Text style={[Styles.discussionEditedText, { color: colors.dimmed }]}>
diedit
</Text>
)} )}
</View> <DiscussionCommentList
<Text style={[Styles.discussionDateText, { color: colors.dimmed, flexShrink: 0 }]}> data={dataKomentar}
{item.createdAt} loading={loadingKomentar}
</Text> myId={entities.id}
</View> canInteract={data?.status !== 2 && data?.isActive === true}
onLongPress={(commentId, comment, files) => {
{/* Comment text */} setSelectKomentar({ id: commentId, comment, files })
<Text setRemovedFileIds([])
style={[Styles.textDefault, { color: colors.text }]} setVisible(true)
numberOfLines={detailMore.includes(item.id) ? 0 : 3} }}
> />
{item.comment}
</Text>
</View>
</Pressable>
))
}
</View>
</View> </View>
</ScrollView> </ScrollView>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={headerHeight}
>
<View style={[
Styles.contentItemCenter,
Styles.w100,
{ backgroundColor: colors.background },
viewEdit && Styles.borderTop
]}>
{
viewEdit ?
<>
<View style={[Styles.w90, Styles.rowSpaceBetween, Styles.pv05]}>
<View style={[Styles.rowItemsCenter]}>
<Feather name="edit-3" color={colors.text} size={22} style={[Styles.mh05]} />
<Text style={[Styles.textMediumSemiBold]}>Edit Komentar</Text>
</View>
<Pressable onPress={() => handleViewEditKomentar()}>
<MaterialIcons name="close" color={colors.text} size={22} />
</Pressable>
</View>
<InputForm
disable={(data?.status === 2 || !data?.isActive || (!memberDiscussion && (entityUser.role == "user" || entityUser.role == "coadmin")))}
type="default"
round
placeholder="Kirim Komentar"
onChange={(val: string) => setSelectKomentar({ ...selectKomentar, comment: val })}
value={selectKomentar.comment}
multiline
focus={viewEdit}
itemRight={
<Pressable
onPress={() => {
(!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]}
>
<MaterialIcons name="send" size={25} style={(loadingSendKomentar || selectKomentar.comment == '' || regexOnlySpacesOrEnter.test(selectKomentar.comment) || data?.status === 2 || !data?.isActive || (!memberDiscussion && (entityUser.role == "user" || entityUser.role == "coadmin"))) ? { color: colors.dimmed } : { color: colors.tint }} />
</Pressable>
}
/>
</>
:
data?.status != 2 && data?.isActive && ((entityUser.role != "user" && entityUser.role != "coadmin") || memberDiscussion)
?
<InputForm
disable={(data?.status === 2 || !data?.isActive || (!memberDiscussion && (entityUser.role == "user" || entityUser.role == "coadmin")))}
type="default"
round
placeholder="Kirim Komentar"
onChange={setKomentar}
value={komentar}
multiline
focus={viewEdit}
itemRight={
<Pressable
onPress={() => {
(!loadingSendKomentar && komentar != '' && !regexOnlySpacesOrEnter.test(komentar) && data?.status === 1 && data?.isActive && (memberDiscussion || (entityUser.role != "user" && entityUser.role != "coadmin")))
&& handleKomentar()
}}
style={[Platform.OS == 'android' && Styles.mb12]}
>
<MaterialIcons name="send" size={25} style={(loadingSendKomentar || komentar == '' || regexOnlySpacesOrEnter.test(komentar) || data?.status === 2 || !data?.isActive || (!memberDiscussion && (entityUser.role == "user" || entityUser.role == "coadmin"))) ? { color: colors.dimmed } : { color: colors.tint }} />
</Pressable>
}
/>
:
<View style={[Styles.pv20, Styles.itemsCenter]}>
<Text style={[Styles.textInformation, { color: colors.dimmed }]}>
{
data?.status == 2 ? "Diskusi telah ditutup" : data?.isActive == false ? "Diskusi telah diarsipkan" : "Hanya anggota diskusi yang dapat memberikan komentar"
}
</Text>
</View>
}
</View> <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} keyboardVerticalOffset={headerHeight}>
{canComment ? (
<DiscussionCommentInput
mode={viewEdit ? 'edit' : 'new'}
value={viewEdit ? selectKomentar.comment : komentar}
onChange={viewEdit
? (val) => 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}
/>
) : (
<DiscussionCommentInput
mode="locked"
lockedReason={lockedReason()}
value="" onChange={() => {}} loading={false} onSend={() => {}} canSend={false}
/>
)}
</KeyboardAvoidingView> </KeyboardAvoidingView>
</View > </View>
<DrawerBottom animation="slide" isVisible={isVisible} setVisible={setVisible} title="Komentar"> <DrawerBottom animation="slide" isVisible={isVisible} setVisible={setVisible} title="Komentar">
<View style={Styles.rowItemsCenter}> <View style={Styles.rowItemsCenter}>
<MenuItemRow <MenuItemRow
icon={<MaterialCommunityIcons name="pencil-outline" color={colors.text} size={25} />} icon={<MaterialCommunityIcons name="pencil-outline" color={colors.text} size={25} />}
title="Edit" title="Edit"
onPress={() => { handleViewEditKomentar() }} onPress={() => handleViewEditKomentar()}
/> />
<MenuItemRow <MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />} icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus" title="Hapus"
onPress={() => { onPress={() => { setVisible(false); setTimeout(() => setShowDeleteModal(true), 600) }}
setVisible(false)
setTimeout(() => {
setShowDeleteModal(true)
}, 600)
}}
/> />
</View> </View>
</DrawerBottom> </DrawerBottom>
@@ -471,10 +280,7 @@ export default function DetailDiscussionGeneral() {
visible={showDeleteModal} visible={showDeleteModal}
title="Konfirmasi" title="Konfirmasi"
message="Apakah anda yakin ingin menghapus komentar?" message="Apakah anda yakin ingin menghapus komentar?"
onConfirm={() => { onConfirm={() => { setShowDeleteModal(false); handleDeleteKomentar() }}
setShowDeleteModal(false)
handleDeleteKomentar()
}}
onCancel={() => setShowDeleteModal(false)} onCancel={() => setShowDeleteModal(false)}
confirmText="Hapus" confirmText="Hapus"
cancelText="Batal" cancelText="Batal"

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) {
if (!group) return
setLoadingDivisions(true)
try {
const hasil = await decryptToken(String(token?.current)) const hasil = await decryptToken(String(token?.current))
const response = await apiGetUser({ user: hasil, active: "true", search: search, group: String(idGroup) }) const response = await apiGetDivision({ user: hasil, search: '', group, active: 'true', kategori: 'semua', page: 1 })
setData(response.data.filter((i: any) => i.idUserRole != 'supadmin')) 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,63 +204,136 @@ 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 ? (
{
selectMember.length > 0
?
<View> <View>
<ScrollView horizontal style={[Styles.mb10, Styles.pv10]} showsHorizontalScrollIndicator={false}> <ScrollView horizontal style={[Styles.mb10, Styles.pv10]} showsHorizontalScrollIndicator={false}>
{ {selectMember.map((item, index) => (
selectMember.map((item: any, index: any) => (
<ImageWithLabel <ImageWithLabel
key={index} key={index}
label={item.name} label={item.name}
src={`${ConstEnv.url_storage}/files/${item.img}`} src={`${ConstEnv.url_storage}/files/${item.img}`}
onClick={() => onChoose(item.idUser, item.name, item.img)} onClick={() => handleToggleMember(item)}
/> />
)) ))}
}
</ScrollView> </ScrollView>
</View> </View>
) : (
: <Text style={[Styles.textDefault, Styles.pv05, Styles.textCenter, { color: colors.dimmed }]}>
<Text style={[Styles.textDefault, Styles.pv05, Styles.textCenter, { color: colors.dimmed }]}>Tidak ada member yang dipilih</Text> Tidak ada member yang dipilih
} </Text>
<ScrollView )}
showsVerticalScrollIndicator={false} <ScrollView showsVerticalScrollIndicator={false}>
> <View>
{search ? (
{ loadingSearch ? (
data.length > 0 ? <ActivityIndicator color={colors.tabActive} style={{ marginTop: 20 }} />
data.map((item: any, index: any) => { ) : searchResults.length > 0 ? (
const found = dataOld.some((i: any) => i.idUser == item.id) searchResults.map((item, idx) => {
const isOld = dataOld.some((o: any) => o.idUser === item.idUser)
return ( return (
<Pressable <Pressable
key={index} key={idx}
style={[Styles.itemSelectModal, { borderColor: colors.icon + '20' }]} style={[Styles.itemSelectModal, { borderColor: colors.icon + '20' }]}
onPress={() => { onPress={() => !isOld && handleToggleMember(item)}
!found && onChoose(item.id, item.name, item.img)
}}
> >
<View style={[Styles.rowItemsCenter]}> <View style={Styles.rowItemsCenter}>
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} border /> <ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} border />
<View style={[Styles.ml10]}> <View style={Styles.ml10}>
<Text style={[Styles.textDefault]}>{item.name}</Text> <Text style={Styles.textDefault}>{item.name}</Text>
{ {isOld && (
found && <Text style={[Styles.textInformation, { color: colors.dimmed }]}>sudah menjadi anggota</Text> <Text style={[Styles.textInformation, { color: colors.dimmed }]}>sudah menjadi anggota</Text>
} )}
</View> </View>
</View> </View>
{ {selectMember.some(s => s.idUser === item.idUser) && (
selectMember.some((i: any) => i.idUser == item.id) && <AntDesign name="check" size={20} color={colors.text} /> <AntDesign name="check" size={18} color={colors.tabActive} />
} )}
</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>
</> </>

View File

@@ -1,5 +1,6 @@
import AppHeader from "@/components/AppHeader"; import AppHeader from "@/components/AppHeader";
import ButtonSaveHeader from "@/components/buttonSaveHeader"; import ButtonSaveHeader from "@/components/buttonSaveHeader";
import ModalSelectMemberByDivision from "@/components/discussion_general/modalSelectMemberByDivision";
import DrawerBottom from "@/components/drawerBottom"; import DrawerBottom from "@/components/drawerBottom";
import ImageUser from "@/components/imageNew"; import ImageUser from "@/components/imageNew";
import { InputForm } from "@/components/inputForm"; import { InputForm } from "@/components/inputForm";
@@ -50,10 +51,10 @@ export default function CreateDiscussionGeneral() {
const userLogin = useSelector((state: any) => state.entities) const userLogin = useSelector((state: any) => state.entities)
const [chooseGroup, setChooseGroup] = useState({ val: "", label: "" }); const [chooseGroup, setChooseGroup] = useState({ val: "", label: "" });
const [valChoose, setValChoose] = useState("") const [valChoose, setValChoose] = useState("")
const [valSelect, setValSelect] = useState<"group" | "member">("group");
const dispatch = useDispatch() const dispatch = useDispatch()
const [disableBtn, setDisableBtn] = useState(true); const [disableBtn, setDisableBtn] = useState(true);
const [isSelect, setSelect] = useState(false); const [isSelect, setSelect] = useState(false);
const [isMemberModal, setMemberModal] = useState(false);
const entitiesMember = useSelector((state: any) => state.memberChoose) const entitiesMember = useSelector((state: any) => state.memberChoose)
const update = useSelector((state: any) => state.discussionGeneralDetailUpdate) const update = useSelector((state: any) => state.discussionGeneralDetailUpdate)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -90,16 +91,13 @@ export default function CreateDiscussionGeneral() {
function handleOpenMemberPicker() { function handleOpenMemberPicker() {
if (entityUser.role === "supadmin" || entityUser.role === "developer") { if (entityUser.role === "supadmin" || entityUser.role === "developer") {
if (chooseGroup.val !== "") { if (chooseGroup.val !== "") {
setSelect(true); setMemberModal(true);
setValSelect("member");
} else { } else {
Toast.show({ type: 'small', text1: 'Pilih Lembaga Desa terlebih dahulu' }) Toast.show({ type: 'small', text1: 'Pilih Lembaga Desa terlebih dahulu' })
} }
} else { } else {
validationForm('group', userLogin.idGroup, userLogin.group); validationForm('group', userLogin.idGroup, userLogin.group);
setValChoose(userLogin.idGroup) setMemberModal(true);
setSelect(true);
setValSelect("member");
} }
} }
@@ -185,7 +183,7 @@ export default function CreateDiscussionGeneral() {
value={chooseGroup.label} value={chooseGroup.label}
required required
bg={colors.card} bg={colors.card}
onPress={() => { setValChoose(chooseGroup.val); setValSelect("group"); setSelect(true) }} onPress={() => { setValChoose(chooseGroup.val); setSelect(true) }}
error={error.group} error={error.group}
errorText="Lembaga Desa tidak boleh kosong" errorText="Lembaga Desa tidak boleh kosong"
/> />
@@ -305,14 +303,19 @@ export default function CreateDiscussionGeneral() {
</ScrollView> </ScrollView>
<ModalSelect <ModalSelect
category={valSelect} category="group"
close={setSelect} close={setSelect}
onSelect={(value) => validationForm(valSelect, value.val, value.label)} onSelect={(value) => validationForm("group", value.val, value.label)}
title={valSelect === "group" ? "Lembaga Desa" : "Pilih Anggota"} title="Lembaga Desa"
open={isSelect} open={isSelect}
idParent={valSelect === "member" ? chooseGroup.val : ""} idParent=""
valChoose={valChoose} valChoose={valChoose}
/> />
<ModalSelectMemberByDivision
open={isMemberModal}
close={setMemberModal}
idGroup={chooseGroup.val || userLogin.idGroup}
/>
<DrawerBottom animation="slide" isVisible={isModalFile} setVisible={setModalFile} title="Menu"> <DrawerBottom animation="slide" isVisible={isModalFile} setVisible={setModalFile} title="Menu">
<View style={Styles.rowItemsCenter}> <View style={Styles.rowItemsCenter}>

View File

@@ -74,6 +74,22 @@ export default function AddMemberTask() {
} }
} }
const availableData = data.filter((item: any) => !dataOld.some((i: any) => i.idUser == item.idUser))
const isAllSelected = availableData.length > 0 && availableData.every((item: any) =>
selectMember.some((s: any) => s.idUser == item.idUser)
)
function handleSelectAll() {
if (isAllSelected) {
setSelectMember([])
} else {
const newMembers = availableData
.filter((item: any) => !selectMember.some((s: any) => s.idUser == item.idUser))
.map((item: any) => ({ idUser: item.idUser, name: item.name, img: item.img }))
setSelectMember([...selectMember, ...newMembers])
}
}
async function handleAddMember() { async function handleAddMember() {
try { try {
setLoading(true) setLoading(true)
@@ -159,6 +175,15 @@ export default function AddMemberTask() {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{availableData.length > 0 && (
<Pressable
style={[Styles.itemSelectModal, { borderColor: colors.icon + '20', paddingTop: 0 }]}
onPress={handleSelectAll}
>
<Text style={[Styles.textMediumSemiBold]}>{isAllSelected ? 'Batalkan Semua' : 'Pilih Semua'}</Text>
{isAllSelected && <AntDesign name="check" size={20} color={colors.text} />}
</Pressable>
)}
{ {
data.length > 0 ? data.length > 0 ?
data.map((item: any, index: any) => { data.map((item: any, index: any) => {

View File

@@ -54,6 +54,21 @@ export default function AddMemberCreateTask() {
} }
} }
const isAllSelected = data.length > 0 && data.every((item: any) =>
selectMember.some((s: any) => s.idUser == item.idUser)
)
function handleSelectAll() {
if (isAllSelected) {
setSelectMember([])
} else {
const newMembers = data
.filter((item: any) => !selectMember.some((s: any) => s.idUser == item.idUser))
.map((item: any) => ({ idUser: item.idUser, name: item.name, img: item.img }))
setSelectMember([...selectMember, ...newMembers])
}
}
async function handleAddMember() { async function handleAddMember() {
try { try {
dispatch(setMemberChoose(selectMember)) dispatch(setMemberChoose(selectMember))
@@ -127,6 +142,15 @@ export default function AddMemberCreateTask() {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{data.length > 0 && (
<Pressable
style={[Styles.itemSelectModal, { borderColor: colors.icon + '20', paddingTop: 0 }]}
onPress={handleSelectAll}
>
<Text style={[Styles.textMediumSemiBold]}>{isAllSelected ? 'Batalkan Semua' : 'Pilih Semua'}</Text>
{isAllSelected && <AntDesign name="check" size={20} color={colors.text} />}
</Pressable>
)}
{ {
data.length > 0 ? data.length > 0 ?
data.map((item: any, index: any) => { data.map((item: any, index: any) => {

View File

@@ -77,6 +77,22 @@ export default function AddMemberDivision() {
} }
} }
const availableData = data.filter((item: any) => !dataOld.some((i: any) => i.idUser == item.id))
const isAllSelected = availableData.length > 0 && availableData.every((item: any) =>
selectMember.some((s: any) => s.idUser == item.id)
)
function handleSelectAll() {
if (isAllSelected) {
setSelectMember([])
} else {
const newMembers = availableData
.filter((item: any) => !selectMember.some((s: any) => s.idUser == item.id))
.map((item: any) => ({ idUser: item.id, name: item.name, img: item.img }))
setSelectMember([...selectMember, ...newMembers])
}
}
async function handleAddMember() { async function handleAddMember() {
try { try {
setLoading(true) setLoading(true)
@@ -141,7 +157,7 @@ export default function AddMemberDivision() {
selectMember.length > 0 selectMember.length > 0
? ?
<View> <View>
<ScrollView horizontal style={[Styles.mb10, Styles.pv10]} showsHorizontalScrollIndicator={false}> <ScrollView horizontal style={[Styles.mb05, Styles.pv10]} showsHorizontalScrollIndicator={false}>
{ {
selectMember.map((item: any, index: any) => ( selectMember.map((item: any, index: any) => (
<ImageWithLabel <ImageWithLabel
@@ -162,6 +178,15 @@ export default function AddMemberDivision() {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{availableData.length > 0 && (
<Pressable
style={[Styles.itemSelectModal, { borderColor: colors.icon + '20', paddingTop: 0 }]}
onPress={handleSelectAll}
>
<Text style={[Styles.textMediumSemiBold]}>{isAllSelected ? 'Batalkan Semua' : 'Pilih Semua'}</Text>
{isAllSelected && <AntDesign name="check" size={20} color={colors.text} />}
</Pressable>
)}
{ {
data.length > 0 ? data.length > 0 ?
data.map((item: any, index: any) => { data.map((item: any, index: any) => {

View File

@@ -9,8 +9,8 @@ import Styles from "@/constants/Styles";
import { apiGetUser } from "@/lib/api"; import { apiGetUser } from "@/lib/api";
import { setFormCreateDivision } from "@/lib/divisionCreate"; import { setFormCreateDivision } from "@/lib/divisionCreate";
import { useAuthSession } from "@/providers/AuthProvider"; import { useAuthSession } from "@/providers/AuthProvider";
import { AntDesign } from "@expo/vector-icons";
import { useTheme } from "@/providers/ThemeProvider"; import { useTheme } from "@/providers/ThemeProvider";
import { AntDesign } 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, ScrollView, View } from "react-native"; import { Pressable, ScrollView, View } from "react-native";
@@ -55,6 +55,21 @@ export default function CreateDivisionAddMember() {
} }
} }
const isAllSelected = data.length > 0 && data.every((item: any) =>
selectMember.some((s: any) => s.idUser == item.id)
)
function handleSelectAll() {
if (isAllSelected) {
setSelectMember([])
} else {
const newMembers = data
.filter((item: any) => !selectMember.some((s: any) => s.idUser == item.id))
.map((item: any) => ({ idUser: item.id, name: item.name, img: item.img }))
setSelectMember([...selectMember, ...newMembers])
}
}
async function handleAddMember() { async function handleAddMember() {
dispatch(setFormCreateDivision({ ...update, member: selectMember })) dispatch(setFormCreateDivision({ ...update, member: selectMember }))
router.push(`./add-admin-division`) router.push(`./add-admin-division`)
@@ -93,7 +108,7 @@ export default function CreateDivisionAddMember() {
selectMember.length > 0 selectMember.length > 0
? ?
<View> <View>
<ScrollView horizontal style={[Styles.mb10, Styles.pv10]} showsHorizontalScrollIndicator={false}> <ScrollView horizontal style={[Styles.mb05, Styles.pv10]} showsHorizontalScrollIndicator={false}>
{ {
selectMember.map((item: any, index: any) => ( selectMember.map((item: any, index: any) => (
<ImageWithLabel <ImageWithLabel
@@ -114,6 +129,15 @@ export default function CreateDivisionAddMember() {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{data.length > 0 && (
<Pressable
style={[Styles.itemSelectModal, { borderColor: colors.icon + '20', paddingTop: 0 }]}
onPress={handleSelectAll}
>
<Text style={[Styles.textMediumSemiBold]}>{isAllSelected ? 'Batalkan Semua' : 'Pilih Semua'}</Text>
{isAllSelected && <AntDesign name="check" size={20} color={colors.text} />}
</Pressable>
)}
{ {
data.length > 0 ? data.length > 0 ?
data.map((item: any, index: any) => { data.map((item: any, index: any) => {

View File

@@ -84,7 +84,7 @@ export default function Notification() {
} }
const parseDate = (str: string) => { const parseDate = (str: string) => {
const [d, m, y] = str.split(' ') const [d, m, y] = str.split(' ')
return new Date(Number(y), BULAN[m] ?? 0, Number(d)).getTime() return new Date(Number(y), BULAN[m?.toUpperCase()] ?? 0, Number(d)).getTime()
} }
const groups: Record<string, Props[]> = {} const groups: Record<string, Props[]> = {}

View File

@@ -78,6 +78,22 @@ export default function AddMemberProject() {
} }
} }
const availableData = data.filter((item: any) => !dataOld.some((i: any) => i.idUser == item.id))
const isAllSelected = availableData.length > 0 && availableData.every((item: any) =>
selectMember.some((s: any) => s.idUser == item.id)
)
function handleSelectAll() {
if (isAllSelected) {
setSelectMember([])
} else {
const newMembers = availableData
.filter((item: any) => !selectMember.some((s: any) => s.idUser == item.id))
.map((item: any) => ({ idUser: item.id, name: item.name, img: item.img }))
setSelectMember([...selectMember, ...newMembers])
}
}
async function handleAddMember() { async function handleAddMember() {
try { try {
setLoading(true) setLoading(true)
@@ -160,6 +176,15 @@ export default function AddMemberProject() {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
style={[Styles.h100, { backgroundColor: colors.background }]} style={[Styles.h100, { backgroundColor: colors.background }]}
> >
{availableData.length > 0 && (
<Pressable
style={[Styles.itemSelectModal, { borderColor: colors.icon + '20', paddingTop: 0 }]}
onPress={handleSelectAll}
>
<Text style={[Styles.textMediumSemiBold]}>{isAllSelected ? 'Batalkan Semua' : 'Pilih Semua'}</Text>
{isAllSelected && <AntDesign name="check" size={20} color={colors.text} />}
</Pressable>
)}
{ {
data.length > 0 ? data.length > 0 ?
<View style={[Styles.mb100]}> <View style={[Styles.mb100]}>

View File

@@ -61,6 +61,21 @@ export default function AddMemberCreateProject() {
} }
} }
const isAllSelected = data.length > 0 && data.every((item: any) =>
selectMember.some((s: any) => s.idUser == item.id)
)
function handleSelectAll() {
if (isAllSelected) {
setSelectMember([])
} else {
const newMembers = data
.filter((item: any) => !selectMember.some((s: any) => s.idUser == item.id))
.map((item: any) => ({ idUser: item.id, name: item.name, img: item.img }))
setSelectMember([...selectMember, ...newMembers])
}
}
async function handleAddMember() { async function handleAddMember() {
try { try {
dispatch(setMemberChoose(selectMember)) dispatch(setMemberChoose(selectMember))
@@ -134,6 +149,15 @@ export default function AddMemberCreateProject() {
style={[Styles.h100, Styles.flex1, { backgroundColor: colors.background }]} style={[Styles.h100, Styles.flex1, { backgroundColor: colors.background }]}
> >
{data.length > 0 && (
<Pressable
style={[Styles.itemSelectModal, { borderColor: colors.icon + '20', paddingTop: 0 }]}
onPress={handleSelectAll}
>
<Text style={[Styles.textMediumSemiBold]}>{isAllSelected ? 'Batalkan Semua' : 'Pilih Semua'}</Text>
{isAllSelected && <AntDesign name="check" size={20} color={colors.text} />}
</Pressable>
)}
{ {
data.length > 0 ? data.length > 0 ?
data.map((item: any, index: any) => { data.map((item: any, index: any) => {

View File

@@ -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 (
<View style={[Styles.pv20, Styles.itemsCenter]}>
<Text style={[Styles.textInformation, { color: colors.dimmed }]}>{lockedReason}</Text>
</View>
)
}
const sendDisabled = loading || value.trim() === '' || regexOnlySpacesOrEnter.test(value) || !canSend
return (
<View style={{ backgroundColor: colors.background, borderTopWidth: mode === 'edit' ? 1 : 0, borderTopColor: colors.icon + '20' }}>
{mode === 'edit' && (
<View style={[Styles.w90, Styles.rowSpaceBetween, Styles.pv05]}>
<View style={Styles.rowItemsCenter}>
<Feather name="edit-3" color={colors.text} size={20} style={Styles.mh05} />
<Text style={Styles.textMediumSemiBold}>Edit Komentar</Text>
</View>
<Pressable onPress={onCancelEdit}>
<MaterialIcons name="close" color={colors.text} size={20} />
</Pressable>
</View>
)}
{(existingFiles.length > 0 || files.length > 0) && (
<ScrollView horizontal style={[Styles.ph15, Styles.pv05]} showsHorizontalScrollIndicator={false}>
{existingFiles.map((f) => (
<Pressable
key={f.id}
onPress={() => 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
}}
>
<MaterialCommunityIcons name="file-outline" size={14} color={colors.dimmed} />
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed, maxWidth: 100 }]} numberOfLines={1}>
{f.name}.{f.extension}
</Text>
<MaterialIcons name="close" size={14} color='#F03E3E' />
</Pressable>
))}
{files.map((f, idx) => (
<Pressable
key={idx}
onPress={() => 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
}}
>
<MaterialCommunityIcons name="file-outline" size={14} color={colors.dimmed} />
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed, maxWidth: 100 }]} numberOfLines={1}>
{f.name}
</Text>
<MaterialIcons name="close" size={14} color={colors.dimmed} />
</Pressable>
))}
</ScrollView>
)}
<View style={{
flexDirection: 'row', alignItems: 'flex-end',
paddingHorizontal: 12,
paddingBottom: Platform.OS === 'ios' ? 10 : 6,
paddingTop: 6,
gap: 8
}}>
{mode === 'new' && (
<Pressable
onPress={pickFiles}
style={{ marginBottom: 6 }}
>
<MaterialIcons
name="add"
size={28}
color={files.length > 0 ? colors.tabActive : colors.dimmed}
/>
</Pressable>
)}
<View style={{
flex: 1,
backgroundColor: colors.input,
borderRadius: 30, borderWidth: 1, borderColor: colors.icon + '20',
paddingHorizontal: 14,
paddingVertical: 10,
}}>
<TextInput
style={{
color: colors.text,
maxHeight: 100,
paddingVertical: Platform.OS === 'android' ? 4 : 0,
textAlignVertical: 'bottom',
}}
placeholder="Kirim Komentar"
placeholderTextColor={colors.dimmed}
value={value}
onChangeText={onChange}
multiline
/>
</View>
<Pressable
onPress={() => !sendDisabled && onSend()}
style={{
width: 40, height: 40, borderRadius: 20,
backgroundColor: sendDisabled ? colors.dimmed + '40' : colors.tint,
justifyContent: 'center', alignItems: 'center',
marginBottom: 0
}}
>
<Ionicons name="send" size={18} color={sendDisabled ? colors.dimmed : '#fff'} />
</Pressable>
</View>
</View>
)
}

View File

@@ -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 (
<Pressable
onPress={onPress}
style={({ pressed }) => [Styles.fileCard, {
borderColor: colors.icon + '18',
backgroundColor: pressed ? colors.icon + '10' : 'transparent'
}]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: getFileColor(ext) + '20' }]}>
<MaterialCommunityIcons name={getFileIcon(ext)} size={18} color={getFileColor(ext)} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{file.name}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
}
export default function DiscussionCommentList({ data, loading, myId, canInteract, onLongPress }: Props) {
const { colors } = useTheme()
const [expandedIds, setExpandedIds] = useState<string[]>([])
const [modalFiles, setModalFiles] = useState<CommentFile[]>([])
const [modalVisible, setModalVisible] = useState(false)
const [previewFile, setPreviewFile] = useState<CommentFile | null>(null)
const [modalPreviewFile, setModalPreviewFile] = useState<CommentFile | null>(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 (
<View style={Styles.mt10}>
{arrSkeleton.map((_, i) => (
<Skeleton key={i} width={100} widthType="percent" height={40} borderRadius={5} />
))}
</View>
)
}
return (
<>
<View style={Styles.mt10}>
{data.map((item, i) => (
<Pressable
key={i}
onPress={() => 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' }
]}
>
<View style={Styles.flex1}>
<View style={[Styles.rowSpaceBetween, Styles.itemsCenter, Styles.mb05]}>
<View style={[Styles.rowItemsCenter, { gap: 8, flex: 1, marginRight: 8 }]}>
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
<Text style={[Styles.textMediumSemiBold, { color: colors.text }]} numberOfLines={1}>
{item.username}
</Text>
{item.isEdited && (
<Text style={[Styles.discussionEditedText, { color: colors.dimmed }]}>diedit</Text>
)}
</View>
<Text style={[Styles.discussionDateText, { color: colors.dimmed, flexShrink: 0 }]}>
{item.createdAt}
</Text>
</View>
{item.comment.length > 0 && (
<Text
style={[Styles.textDefault, { color: colors.text }]}
numberOfLines={expandedIds.includes(item.id) ? 0 : 3}
>
{item.comment}
</Text>
)}
{item.files?.length > 0 && (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: item.comment.length > 0 ? 8 : 0 }}>
{(item.files.length > 2 ? item.files.slice(0, 1) : item.files).map((file, idx) => (
<FileCard key={idx} file={file} colors={colors} onPress={() => handleFilePress(file)} />
))}
{item.files.length > 2 && (
<Pressable
onPress={() => { setModalFiles(item.files); setModalVisible(true) }}
style={[Styles.fileCard, { borderColor: colors.icon + '18', backgroundColor: 'transparent' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: '#868E96' + '20' }]}>
<MaterialCommunityIcons name="folder-multiple-outline" size={18} color="#868E96" />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>+{item.files.length - 1} lainnya</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>Lihat semua</Text>
</View>
</Pressable>
)}
</View>
)}
</View>
</Pressable>
))}
</View>
<Modal visible={modalVisible} animationType="slide" onRequestClose={() => setModalVisible(false)}>
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
<View style={[Styles.rowSpaceBetween, Styles.itemsCenter, Styles.ph15, Styles.pv10, { borderBottomWidth: 1, borderBottomColor: colors.icon + '20' }]}>
<Text style={Styles.textLargeSemiBold}>Lampiran ({modalFiles.length} file)</Text>
<Pressable onPress={() => setModalVisible(false)}>
<MaterialIcons name="close" size={24} color={colors.text} />
</Pressable>
</View>
<ScrollView contentContainerStyle={[Styles.ph15, Styles.pv10, { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }]}>
{modalFiles.map((file, idx) => (
<FileCard
key={idx} file={file} colors={colors}
onPress={() => {
if (isImageFile(file.extension.toLowerCase())) {
setModalPreviewFile(file)
} else {
openExternal(file)
}
}}
/>
))}
</ScrollView>
</SafeAreaView>
<ImageViewing
images={[{ uri: `${ConstEnv.url_storage}/files/${modalPreviewFile?.idStorage}` }]}
imageIndex={0}
visible={modalPreviewFile !== null}
onRequestClose={() => setModalPreviewFile(null)}
doubleTapToZoomEnabled
HeaderComponent={() => (
<View style={Styles.headerModalViewImg}>
<Pressable onPress={() => setModalPreviewFile(null)}>
<Text style={{ color: 'white', fontSize: 26 }}></Text>
</Pressable>
<Pressable onPress={() => modalPreviewFile && openExternal(modalPreviewFile)} disabled={loadingOpen}>
<Text style={{ color: loadingOpen ? 'gray' : 'white', fontSize: 26 }}></Text>
</Pressable>
</View>
)}
FooterComponent={() => (
<View style={{ paddingBottom: 20, paddingHorizontal: 16, alignItems: 'center' }}>
<Text style={{ color: 'white', fontSize: 16 }}>{modalPreviewFile?.name}.{modalPreviewFile?.extension}</Text>
</View>
)}
/>
</Modal>
<ImageViewing
images={[{ uri: `${ConstEnv.url_storage}/files/${previewFile?.idStorage}` }]}
imageIndex={0}
visible={previewFile !== null}
onRequestClose={() => setPreviewFile(null)}
doubleTapToZoomEnabled
HeaderComponent={() => (
<View style={Styles.headerModalViewImg}>
<Pressable onPress={() => setPreviewFile(null)}>
<Text style={{ color: 'white', fontSize: 26 }}></Text>
</Pressable>
<Pressable onPress={() => previewFile && openExternal(previewFile)} disabled={loadingOpen}>
<Text style={{ color: loadingOpen ? 'gray' : 'white', fontSize: 26 }}></Text>
</Pressable>
</View>
)}
FooterComponent={() => (
<View style={{ paddingBottom: 20, paddingHorizontal: 16, alignItems: 'center' }}>
<Text style={{ color: 'white', fontSize: 16 }}>{previewFile?.name}.{previewFile?.extension}</Text>
</View>
)}
/>
</>
)
}

View File

@@ -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<DivisionItem[]>([])
const [selectMember, setSelectMember] = useState<Member[]>([])
const [search, setSearch] = useState('')
const [loadingDivisions, setLoadingDivisions] = useState(false)
const [loadingIds, setLoadingIds] = useState<string[]>([])
const [searchResults, setSearchResults] = useState<any[]>([])
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<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(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 (
<DrawerBottom animation="none" isVisible={open} setVisible={handleClose} title="Pilih Anggota" height={90}>
<InputSearch onChange={setSearch} value={search} bg="transparent" />
{selectMember.length > 0
? (
<View>
<ScrollView horizontal style={[Styles.mb10, Styles.pv10]} showsHorizontalScrollIndicator={false}>
{selectMember.map((item, index) => (
<ImageWithLabel
key={index}
label={item.name}
src={`${ConstEnv.url_storage}/files/${item.img}`}
onClick={() => handleToggleMember(item)}
/>
))}
</ScrollView>
</View>
)
: (
<Text style={[Styles.textDefault, { color: colors.dimmed, textAlign: 'center' }, Styles.pv05]}>
Tidak ada member yang dipilih
</Text>
)
}
<ScrollView showsVerticalScrollIndicator={false}>
<View>
{search ? (
loadingSearch ? (
<ActivityIndicator color={colors.tabActive} style={{ marginTop: 20 }} />
) : searchResults.length > 0 ? (
searchResults.map((item, idx) => (
<Pressable
key={idx}
style={[Styles.itemSelectModal, { borderColor: colors.icon + '20' }]}
onPress={() => handleToggleMember({ idUser: item.id, name: item.name, img: item.img })}
>
<View style={Styles.rowItemsCenter}>
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} border />
<Text style={[Styles.textDefault, Styles.ml10]}>{item.name}</Text>
</View>
{selectMember.some(s => s.idUser === item.id) && (
<AntDesign name="check" size={18} color={colors.tabActive} />
)}
</Pressable>
))
) : (
<Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed, marginTop: 20 }]}>
Tidak ada hasil
</Text>
)
) : loadingDivisions ? (
<ActivityIndicator color={colors.tabActive} style={{ marginTop: 20 }} />
) : 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 (
<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 ${division.members.length} dipilih`
: `${division.members.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) => (
<Pressable
key={idx}
style={[Styles.itemSelectModal, { borderColor: colors.icon + '15' }]}
onPress={() => handleToggleMember(member)}
>
<View style={Styles.rowItemsCenter}>
<ImageUser src={`${ConstEnv.url_storage}/files/${member.img}`} border />
<Text style={[Styles.textDefault, Styles.ml10]}>{member.name}</Text>
</View>
{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>
<ButtonForm
onPress={handleConfirm}
text="PILIH MEMBER"
disabled={selectMember.length === 0}
/>
</DrawerBottom>
)
}

View File

@@ -67,9 +67,8 @@ export default function CaraouselHome2({ refreshing }: { refreshing: boolean })
<View <View
style={[ style={[
Styles.mv15, Styles.mv15,
Styles.p15,
Styles.round05, Styles.round05,
{ backgroundColor: colors.card }, { backgroundColor: colors.card, paddingVertical: 15, paddingHorizontal: 10 },
Styles.wrapHomeCarousel Styles.wrapHomeCarousel
]} ]}
> >

View File

@@ -97,7 +97,7 @@ export default function ItemSectionTanggalTugas({ status, title, dateStart, date
borderWidth: 1, borderWidth: 1,
borderColor: colors.icon + '18', borderColor: colors.icon + '18',
backgroundColor: colors.card, backgroundColor: colors.card,
marginBottom: 10, marginBottom: 0,
}} }}
> >
{/* Accent bar kiri */} {/* Accent bar kiri */}

View File

@@ -128,6 +128,35 @@ export default function ModalSelectMultiple({ open, close, title, category, choo
}; };
const groupsWithDivisions = data.filter((group: any) => group.Division?.length > 0)
const isAllSelected = category === 'choose-division'
? groupsWithDivisions.length > 0 && groupsWithDivisions.every((group: any) =>
checked[group.id]?.length === group.Division?.length
)
: data.length > 0 && selectedDivision.length === data.length
const handleSelectAll = () => {
if (category === 'choose-division') {
if (isAllSelected) {
setChecked({})
} else {
const newChecked: CheckedState = {}
data.forEach((group: any) => {
if (group.Division?.length > 0) {
newChecked[group.id] = group.Division.map((d: any) => d.id)
}
})
setChecked(newChecked)
}
} else {
if (isAllSelected) {
setSelectedDivision([])
} else {
setSelectedDivision(data.map((d: any) => ({ id: d.id, name: d.name })))
}
}
}
const handleSubmit = () => { const handleSubmit = () => {
if (category == "choose-division") { if (category == "choose-division") {
const selectedGroups: GroupData[] = []; const selectedGroups: GroupData[] = [];
@@ -154,8 +183,11 @@ export default function ModalSelectMultiple({ open, close, title, category, choo
{ {
category == 'share-division' ? category == 'share-division' ?
<> <>
{ <Pressable style={[Styles.itemSelectModal, { borderColor: colors.icon + 20 }]} onPress={handleSelectAll}>
data.map((item: any, index: number) => { <Text style={[Styles.textMediumSemiBold]}>{isAllSelected ? 'Batalkan Semua' : 'Pilih Semua'}</Text>
{isAllSelected && <AntDesign name="check" size={17} color={colors.text} />}
</Pressable>
{data.map((item: any, index: number) => {
return ( return (
<Pressable key={index} style={[Styles.itemSelectModal, { borderColor: colors.icon + 20 }]} onPress={() => { <Pressable key={index} style={[Styles.itemSelectModal, { borderColor: colors.icon + 20 }]} onPress={() => {
handleDivisionClick(index) handleDivisionClick(index)
@@ -168,11 +200,15 @@ export default function ModalSelectMultiple({ open, close, title, category, choo
} }
</Pressable> </Pressable>
) )
}) })}
}
</> </>
: :
data.map((item: any, index: number) => { <>
<Pressable style={[Styles.itemSelectModal, { borderColor: colors.icon + 20 }]} onPress={handleSelectAll}>
<Text style={[Styles.textMediumSemiBold]}>{isAllSelected ? 'Batalkan Semua' : 'Pilih Semua'}</Text>
{isAllSelected && <AntDesign name="check" size={17} color={colors.text} />}
</Pressable>
{data.map((item: any, index: number) => {
return ( return (
<View key={index}> <View key={index}>
<Pressable style={[Styles.itemSelectModal, { borderColor: colors.icon + 20 }]} onPress={() => { handleGroupCheck(item.id) }}> <Pressable style={[Styles.itemSelectModal, { borderColor: colors.icon + 20 }]} onPress={() => { handleGroupCheck(item.id) }}>
@@ -199,7 +235,8 @@ export default function ModalSelectMultiple({ open, close, title, category, choo
} }
</View> </View>
) )
}) })}
</>
} }
</ScrollView> </ScrollView>
<View style={[Styles.absolute0, { width: '100%' }]}> <View style={[Styles.absolute0, { width: '100%' }]}>

View File

@@ -127,7 +127,7 @@ export default function SectionFile({ status, member, refreshing }: { status: nu
<> <>
<ModalLoading isVisible={loadingOpen} setVisible={setLoadingOpen} /> <ModalLoading isVisible={loadingOpen} setVisible={setLoadingOpen} />
<View style={[Styles.mb15]}> <View style={[Styles.mb15]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mv05]}>File</Text> <Text style={[Styles.textDefaultSemiBold, { marginBottom: 5 }]}>File</Text>
{loading ? ( {loading ? (
<View style={Styles.fileGrid}> <View style={Styles.fileGrid}>

View File

@@ -74,7 +74,7 @@ export default function SectionMember({ status, refreshing }: { status: number |
return ( return (
<> <>
<View style={[Styles.mb15]}> <View style={[Styles.mb15]}>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}> <View style={[Styles.rowSpaceBetween, { marginBottom: 5 }]}>
<Text style={[Styles.textDefaultSemiBold]}>Anggota</Text> <Text style={[Styles.textDefaultSemiBold]}>Anggota</Text>
{!loading && data.length > 0 && ( {!loading && data.length > 0 && (
<Text style={[Styles.textDefault, { color: colors.dimmed }]}>{data.length} orang</Text> <Text style={[Styles.textDefault, { color: colors.dimmed }]}>{data.length} orang</Text>

View File

@@ -174,9 +174,9 @@ export default function SectionTanggalTugasProject({ status, member, refreshing,
return ( return (
<> <>
<View style={[Styles.mb15, Styles.mt10]}> <View style={[Styles.mb15]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mv05]}>Tanggal & Tugas</Text> <Text style={[Styles.textDefaultSemiBold, { marginBottom: 5 }]}>Tanggal & Tugas</Text>
<View> <View style={{ gap: 10 }}>
{loading {loading
? arrSkeleton.map((_, index) => <SkeletonTask key={index} />) ? arrSkeleton.map((_, index) => <SkeletonTask key={index} />)
: data.length > 0 : data.length > 0

View File

@@ -153,7 +153,7 @@ export default function SectionFileTask({ refreshing, isMemberDivision }: { refr
<> <>
<ModalLoading isVisible={loadingOpen} setVisible={setLoadingOpen} /> <ModalLoading isVisible={loadingOpen} setVisible={setLoadingOpen} />
<View style={[Styles.mb15]}> <View style={[Styles.mb15]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mv05]}>File</Text> <Text style={[Styles.textDefaultSemiBold, { marginBottom: 5 }]}>File</Text>
{loading ? ( {loading ? (
<View style={Styles.fileGrid}> <View style={Styles.fileGrid}>
{arrSkeleton.map((_, index) => ( {arrSkeleton.map((_, index) => (

View File

@@ -100,7 +100,7 @@ export default function SectionMemberTask({ refreshing, isAdminDivision }: { ref
return ( return (
<> <>
<View style={[Styles.mb15]}> <View style={[Styles.mb15]}>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}> <View style={[Styles.rowSpaceBetween, { marginBottom: 5 }]}>
<Text style={[Styles.textDefaultSemiBold]}>Anggota</Text> <Text style={[Styles.textDefaultSemiBold]}>Anggota</Text>
{!loading && data.length > 0 && ( {!loading && data.length > 0 && (
<Text style={[Styles.textDefault, { color: colors.dimmed }]}>{data.length} orang</Text> <Text style={[Styles.textDefault, { color: colors.dimmed }]}>{data.length} orang</Text>

View File

@@ -172,9 +172,9 @@ export default function SectionTanggalTugasTask({ refreshing, isMemberDivision,
return ( return (
<> <>
<View style={[Styles.mb15, Styles.mt10]}> <View style={[Styles.mb15]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mv05]}>Tanggal & Tugas</Text> <Text style={[Styles.textDefaultSemiBold, { marginBottom: 5 }]}>Tanggal & Tugas</Text>
<View> <View style={{ gap: 10 }}>
{loading {loading
? arrSkeleton.map((_, index) => <SkeletonTask key={index} />) ? arrSkeleton.map((_, index) => <SkeletonTask key={index} />)
: data.length > 0 : data.length > 0

View File

@@ -39,7 +39,14 @@ export const apiSendDiscussionGeneralCommentar = async ({ id, data }: { id: stri
return response.data; 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) const response = await api.put(`/mobile/discussion-general/${id}/comment`, data)
return response.data; return response.data;
}; };

View File

@@ -0,0 +1,37 @@
const { withAndroidManifest } = require('@expo/config-plugins');
const BLOCKED_PERMISSIONS = [
'android.permission.READ_MEDIA_IMAGES',
'android.permission.READ_MEDIA_VIDEO',
];
const withRemoveMediaPermissions = (config) =>
withAndroidManifest(config, (config) => {
const manifest = config.modResults.manifest;
// Pastikan xmlns:tools ada di manifest root
if (!manifest.$['xmlns:tools']) {
manifest.$['xmlns:tools'] = 'http://schemas.android.com/tools';
}
// Hapus entry yang ada (apapun atributnya)
const existing = manifest['uses-permission'] ?? [];
manifest['uses-permission'] = existing.filter(
(perm) => !BLOCKED_PERMISSIONS.includes(perm.$?.['android:name'])
);
// Tambahkan entry dengan tools:node="remove" agar Gradle merger
// membuang permission ini dari SEMUA sumber (termasuk library manifests)
for (const permission of BLOCKED_PERMISSIONS) {
manifest['uses-permission'].push({
$: {
'android:name': permission,
'tools:node': 'remove',
},
});
}
return config;
});
module.exports = withRemoveMediaPermissions;