9 Commits

Author SHA1 Message Date
a61c194ece feat: redesign halaman tambah dan edit diskusi divisi dengan pola sectionCard dan fileGrid 2026-05-19 15:17:07 +08:00
2be59b5ac6 feat: redesign halaman tambah dan edit diskusi
- Ganti ButtonSelect dan BorderBottomItem dengan pola sectionCard + fileGrid
- Tambah getFileIcon/getFileColor helper dan ikon berwarna per tipe file
- Bagian anggota pada create menggunakan listItemCard dengan avatar ImageUser
- Terapkan deduplication file berdasarkan nama dengan toast notifikasi
- Bersihkan komentar lama dan sederhanakan logic validasi
2026-05-19 15:10:18 +08:00
d272b96e53 fix: ganti warna desc diskusi dan pindahkan inline styles ke styles.ts
- Ganti warna teks deskripsi dari dimmed ke text pada list diskusi umum dan divisi
- Tambah class discussionHeaderPadding, discussionListPadding, discussionTitleCol,
  discussionDescMargin, discussionEmptyText ke component.styles.ts
- Ganti semua inline style dengan themed object (warna dinamis) dan Styles.* (statis)
2026-05-19 14:47:37 +08:00
6d0203cc7d fix: hindari refetch saat mount pada halaman list pengumuman
Gunakan useRef untuk skip efek di render pertama sehingga refetch
hanya dipanggil saat state update berubah (setelah CRUD), bukan
setiap kali halaman dibuka.
2026-05-19 14:41:39 +08:00
165f423798 fix: cegah upload file duplikat pada form tambah dan edit pengumuman
Cek nama file sebelum menambahkan ke list, skip jika sudah ada.
Gunakan nama file (bukan URI) karena Android dapat menghasilkan URI berbeda
untuk file yang sama di setiap sesi picker.
2026-05-19 14:39:23 +08:00
0cb085caa8 feat: redesign halaman pengumuman dan pindahkan styles ke announcement.styles.ts
- Redesign list, detail, create, dan edit pengumuman menggunakan pola sectionCard
- Buat constants/styles/announcement.styles.ts untuk class announcementList* dan announcementDetail*
- Hapus local StyleSheet S dari index.tsx dan [id].tsx, ganti dengan Styles global
- Tambah getFileIcon/getFileColor helper dan fileGrid berwarna per tipe file
- Sesuaikan edit/[id].tsx dengan pola design create.tsx
2026-05-19 14:27:29 +08:00
2bacc47d75 fix: sesuaikan warna border dengan tema pada halaman pilih anggota dan admin divisi 2026-05-19 12:06:12 +08:00
fcd3dc7537 fix: samakan jarak dan style label filter di discussion, division, member, position 2026-05-19 11:55:58 +08:00
0cbf12eea7 Merge pull request 'amalia/18-mei-26' (#49) from amalia/18-mei-26 into join
Reviewed-on: #49
2026-05-18 17:28:18 +08:00
20 changed files with 1218 additions and 1179 deletions

View File

@@ -1,6 +1,5 @@
import HeaderRightAnnouncementDetail from "@/components/announcement/headerAnnouncementDetail";
import AppHeader from "@/components/AppHeader";
import BorderBottomItem from "@/components/borderBottomItem";
import Skeleton from "@/components/skeleton";
import Text from '@/components/Text';
import ErrorView from "@/components/ErrorView";
@@ -10,7 +9,7 @@ import Styles from "@/constants/Styles";
import { apiGetAnnouncementOne } from "@/lib/api";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Entypo, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import * as FileSystem from 'expo-file-system';
import { startActivityAsync } from 'expo-intent-launcher';
import { router, Stack, useLocalSearchParams } from "expo-router";
@@ -23,7 +22,6 @@ import RenderHTML from 'react-native-render-html';
import Toast from "react-native-toast-message";
import { useSelector } from "react-redux";
// Define TypeScript interfaces for better type safety
interface AnnouncementData {
id: string;
title: string;
@@ -59,26 +57,31 @@ export default function DetailAnnouncement() {
const [dataFile, setDataFile] = useState<FileData[]>([])
const update = useSelector((state: any) => state.announcementUpdate)
const entityUser = useSelector((state: any) => state.user)
const contentWidth = Dimensions.get('window').width
const contentWidth = Dimensions.get('window').width - 62
const [loading, setLoading] = useState(true)
const arrSkeleton = Array.from({ length: 2 }, (_, index) => index)
const [refreshing, setRefreshing] = useState(false)
const [loadingOpen, setLoadingOpen] = useState(false)
const [preview, setPreview] = useState(false)
const [chooseFile, setChooseFile] = useState<FileData>()
const [isError, setIsError] = useState(false)
/**
* Opens the image preview modal for the selected image file
* @param item The file data object containing image information
*/
const themed = {
background: { backgroundColor: colors.background },
card: { backgroundColor: colors.card, borderColor: colors.icon + '18' },
iconBox: { backgroundColor: colors.icon + '18' },
sectionLabel: { color: colors.dimmed },
titleText: { color: colors.text },
fileChipBorder: { borderColor: colors.icon + '30' },
fileChipPressed: { backgroundColor: colors.icon + '10' },
groupSeparator: { borderTopColor: colors.icon + '18' },
divisionIconBg: { backgroundColor: colors.icon + '15' },
}
function handleChooseFile(item: FileData) {
setChooseFile(item)
setPreview(true)
}
async function handleLoad(loading: boolean) {
try {
setIsError(false)
@@ -97,39 +100,22 @@ export default function DetailAnnouncement() {
console.error(error);
setIsError(true)
const message = error?.response?.data?.message || "Gagal mengambil data"
Toast.show({ type: 'small', text1: message })
} finally {
setLoading(false)
}
}
useEffect(() => {
handleLoad(false)
}, [update])
useEffect(() => { handleLoad(false) }, [update])
useEffect(() => { handleLoad(true) }, [])
useEffect(() => {
handleLoad(true)
}, [])
/**
* Checks if a string contains HTML tags
* @param text The text to check for HTML tags
* @returns True if the text contains HTML tags, false otherwise
*/
function hasHtmlTags(text: string) {
const htmlRegex = /<[a-z][\s\S]*>/i;
return htmlRegex.test(text);
return /<[a-z][\s\S]*>/i.test(text);
}
/**
* Handles pull-to-refresh functionality
* Reloads the announcement data without showing loading indicators
*/
const handleRefresh = async () => {
setRefreshing(true)
handleLoad(false)
// Simulate network request delay for better UX
await new Promise(resolve => setTimeout(resolve, 2000));
setRefreshing(false)
};
@@ -141,178 +127,171 @@ export default function DetailAnnouncement() {
const fileName = item.name + '.' + item.extension;
const localPath = `${FileSystem.documentDirectory}/${fileName}`;
const mimeType = mime.lookup(fileName);
// Download the file
const downloadResult = await FileSystem.downloadAsync(remoteUrl, localPath);
if (downloadResult.status !== 200) {
throw new Error(`Download failed with status ${downloadResult.status}`);
}
const contentURL = await FileSystem.getContentUriAsync(downloadResult.uri);
try {
if (Platform.OS === 'android') {
await startActivityAsync(
'android.intent.action.VIEW',
{
data: contentURL,
flags: 1,
type: mimeType as string,
}
);
await startActivityAsync('android.intent.action.VIEW', {
data: contentURL,
flags: 1,
type: mimeType as string,
});
} else if (Platform.OS === 'ios') {
await Sharing.shareAsync(localPath);
}
} catch (openError) {
console.error('Error opening file:', openError);
Toast.show({
type: 'error',
text1: 'Tidak ada aplikasi yang dapat membuka file ini'
});
} catch {
Toast.show({ type: 'error', text1: 'Tidak ada aplikasi yang dapat membuka file ini' });
}
} catch (error) {
console.error('Error downloading or opening file:', error);
Toast.show({
type: 'error',
text1: 'Gagal membuka file',
text2: 'Silakan coba lagi nanti'
});
} catch {
Toast.show({ type: 'error', text1: 'Gagal membuka file', text2: 'Silakan coba lagi nanti' });
} finally {
setLoadingOpen(false);
}
};
return (
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<SafeAreaView style={[Styles.flex1, themed.background]}>
<Stack.Screen
options={{
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
headerTitle: 'Pengumuman',
headerTitleAlign: 'center',
// headerRight: () => entityUser.role != 'user' && entityUser.role != 'coadmin' ? <HeaderRightAnnouncementDetail id={id} /> : <></>,
header: () => (
<AppHeader title="Pengumuman"
<AppHeader
title="Pengumuman"
showBack={true}
onPressLeft={() => router.back()}
right={entityUser.role != 'user' && entityUser.role != 'coadmin' ? <HeaderRightAnnouncementDetail id={id} /> : <></>}
right={entityUser.role != 'user' && entityUser.role != 'coadmin'
? <HeaderRightAnnouncementDetail id={id} />
: <></>
}
/>
)
}}
/>
<ScrollView
showsVerticalScrollIndicator={false}
style={[Styles.h100, { backgroundColor: colors.background }]}
style={[Styles.flex1, themed.background]}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => handleRefresh()}
tintColor={colors.icon}
/>
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} tintColor={colors.icon} />
}
>
{isError && !loading ? (
<View style={[Styles.mv50]}>
<View style={Styles.mv50}>
<ErrorView />
</View>
) : (
<View style={[Styles.p15, Styles.mb50]}>
<View style={[Styles.wrapPaper, Styles.borderAll, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
loading ?
<View>
<View style={[Styles.rowOnly]}>
<Skeleton width={30} height={30} borderRadius={10} />
<View style={[Styles.flex1, Styles.ph05]}>
<Skeleton width={100} widthType="percent" height={30} borderRadius={10} />
</View>
</View>
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
</View>
:
<>
<View style={[Styles.rowOnly, Styles.alignStart]}>
<MaterialIcons name="campaign" size={25} color={colors.text} style={[Styles.mr05]} />
<Text style={[Styles.textDefaultSemiBold, Styles.w90, Styles.mt02]}>{data?.title}</Text>
</View>
<View style={[Styles.mt10]}>
{
hasHtmlTags(data?.desc) ?
<RenderHTML
contentWidth={contentWidth}
source={{ html: data?.desc }}
baseStyle={{ color: colors.text }}
/>
:
<Text>{data?.desc}</Text>
}
</View>
</>
}
<View style={Styles.announcementDetailContainer}>
</View>
{
dataFile.length > 0 && (
<View style={[Styles.wrapPaper, Styles.borderAll, Styles.mt10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
<View style={[Styles.mb05]}>
<Text style={[Styles.textDefaultSemiBold]}>File</Text>
{/* Title + Description */}
<View style={[Styles.wrapPaper, Styles.noShadow, Styles.sectionCard, Styles.announcementDetailCard, themed.card]}>
{loading ? (
<View style={Styles.announcementDetailSkeletonGap}>
<View style={[Styles.rowItemsCenter, Styles.announcementDetailSkeletonIconRow]}>
<Skeleton width={38} height={38} borderRadius={10} />
<Skeleton width={60} widthType="percent" height={16} borderRadius={6} />
</View>
{dataFile.map((item, index) => (
<BorderBottomItem
key={`${item.id}-${index}`}
borderType={index === dataFile.length - 1 ? 'none' : 'bottom'}
icon={<MaterialCommunityIcons
name={isImageFile(item.extension) ? "file-image-outline" : "file-document-outline"}
size={25}
color={colors.text}
/>}
title={item.name + '.' + item.extension}
titleWeight="normal"
onPress={() => {
isImageFile(item.extension) ?
handleChooseFile(item)
: openFile(item)
}}
/>
))}
<Skeleton width={100} widthType="percent" height={10} borderRadius={6} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={6} />
<Skeleton width={80} widthType="percent" height={10} borderRadius={6} />
</View>
)
}
<View style={[Styles.wrapPaper, Styles.borderAll, Styles.mt10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
loading ?
arrSkeleton.map((item, index) => {
return (
<View key={index}>
<Skeleton width={30} widthType="percent" height={10} borderRadius={10} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
</View>
)
})
:
Object.keys(dataMember).map((v: any, i: any) => {
return (
<View key={i} style={[Styles.mb05]}>
<Text style={[Styles.textDefaultSemiBold]}>{dataMember[v]?.[0].group}</Text>
{
dataMember[v].map((item: any, x: any) => {
return (
<View key={x} style={[Styles.rowItemsCenter, Styles.w90]}>
<Entypo name="dot-single" size={24} color={colors.text} />
<Text style={[Styles.textDefault]} numberOfLines={1} ellipsizeMode='tail'>{item.division}</Text>
</View>
)
})
}
</View>
)
})
}
) : (
<>
<View style={[Styles.rowItemsCenter, Styles.announcementDetailTitleRow]}>
<View style={[Styles.sectionIconBox, Styles.announcementDetailIconBox, themed.iconBox]}>
<MaterialIcons name="campaign" size={22} color={colors.icon} />
</View>
<Text style={[Styles.textDefaultSemiBold, Styles.announcementDetailTitleText, themed.titleText]} numberOfLines={2}>
{data.title}
</Text>
</View>
{hasHtmlTags(data.desc)
? <RenderHTML
contentWidth={contentWidth}
source={{ html: data.desc }}
baseStyle={{ color: colors.text }}
/>
: <Text style={Styles.textDefault}>{data.desc}</Text>
}
</>
)}
</View>
{/* Files */}
{dataFile.length > 0 && (
<View>
<View style={[Styles.rowItemsCenter, Styles.announcementDetailSectionLabelRow]}>
<MaterialCommunityIcons name="paperclip" size={14} color={colors.dimmed} />
<Text style={[Styles.textInformation, themed.sectionLabel]}>
Lampiran ({dataFile.length})
</Text>
</View>
<View style={[Styles.wrapPaper, Styles.noShadow, Styles.sectionCard, Styles.announcementDetailCard, Styles.announcementDetailFileCardPadding, themed.card]}>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={[Styles.rowItemsCenter, Styles.announcementDetailFileChipList]}>
{dataFile.map((item, index) => (
<Pressable
key={`${item.id}-${index}`}
onPress={() => isImageFile(item.extension) ? handleChooseFile(item) : openFile(item)}
style={({ pressed }) => [Styles.announcementDetailFileChip, themed.fileChipBorder,
pressed ? themed.fileChipPressed : themed.background]}
>
<MaterialCommunityIcons
name={isImageFile(item.extension) ? "file-image-outline" : "file-document-outline"}
size={16}
color={colors.icon}
/>
<Text style={[Styles.textInformation, Styles.announcementDetailFileChipText, themed.titleText]} numberOfLines={1}>
{item.name}.{item.extension}
</Text>
</Pressable>
))}
</View>
</ScrollView>
</View>
</View>
)}
{/* Recipients */}
<View>
<View style={[Styles.rowItemsCenter, Styles.announcementDetailSectionLabelRow]}>
<MaterialIcons name="groups" size={14} color={colors.dimmed} />
<Text style={[Styles.textInformation, themed.sectionLabel]}>
Ditujukan Kepada
</Text>
</View>
<View style={[Styles.wrapPaper, Styles.noShadow, Styles.sectionCard, Styles.announcementDetailCard, themed.card]}>
{loading ? (
<View style={Styles.announcementDetailRecipientGap}>
<Skeleton width={40} widthType="percent" height={10} borderRadius={6} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={6} />
<Skeleton width={60} widthType="percent" height={10} borderRadius={6} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={6} />
</View>
) : (
Object.keys(dataMember).map((v, i) => (
<View key={i} style={i > 0 ? [Styles.announcementDetailGroupSeparator, themed.groupSeparator] : undefined}>
<Text style={[Styles.textInformation, Styles.announcementDetailGroupLabel, themed.sectionLabel]}>
{dataMember[v]?.[0].group}
</Text>
<View>
{dataMember[v].map((item, x) => (
<View key={x} style={[Styles.rowItemsCenter, Styles.announcementDetailDivisionRow]}>
<View style={[Styles.announcementDetailDivisionIconCircle, themed.divisionIconBg]}>
<MaterialIcons name="group" size={14} color={colors.icon} />
</View>
<Text style={[Styles.textDefault, Styles.flex1, themed.titleText]}>
{item.division}
</Text>
</View>
))}
</View>
</View>
))
)}
</View>
</View>
</View>
)}
</ScrollView>
@@ -323,38 +302,26 @@ export default function DetailAnnouncement() {
visible={preview}
onRequestClose={() => setPreview(false)}
doubleTapToZoomEnabled
HeaderComponent={({ imageIndex }) => (
<View style={[Styles.headerModalViewImg]}>
{/* CLOSE */}
<Pressable
onPress={() => setPreview(false)}
accessibilityRole="button"
accessibilityLabel="Close image viewer"
>
HeaderComponent={() => (
<View style={Styles.headerModalViewImg}>
<Pressable onPress={() => setPreview(false)} accessibilityRole="button">
<Text style={[Styles.textWhite, Styles.font26]}></Text>
</Pressable>
{/* MENU */}
<Pressable
onPress={() => chooseFile && openFile(chooseFile)}
accessibilityRole="button"
accessibilityLabel="Download or share image"
disabled={loadingOpen}
>
<Text style={[{ color: loadingOpen ? 'gray' : 'white' }, Styles.font26]}></Text>
<Text style={[Styles.font26, { color: loadingOpen ? 'gray' : 'white' }]}></Text>
</Pressable>
</View>
)}
FooterComponent={({ imageIndex }) => (
<View style={[
Styles.pb20,
Styles.ph16,
Styles.alignCenter,
]}>
FooterComponent={() => (
<View style={[Styles.pb20, Styles.ph16, Styles.alignCenter]}>
<Text style={[Styles.textWhite, Styles.font16]}>{chooseFile?.name}.{chooseFile?.extension}</Text>
</View>
)}
/>
</SafeAreaView>
)
}
}

View File

@@ -1,7 +1,5 @@
import AppHeader from "@/components/AppHeader";
import BorderBottomItem from "@/components/borderBottomItem";
import ButtonSaveHeader from "@/components/buttonSaveHeader";
import ButtonSelect from "@/components/buttonSelect";
import DrawerBottom from "@/components/drawerBottom";
import { InputForm } from "@/components/inputForm";
import LoadingCenter from "@/components/loadingCenter";
@@ -13,14 +11,34 @@ import { setUpdateAnnouncement } from "@/lib/announcementUpdate";
import { apiCreateAnnouncement } from "@/lib/api";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Entypo, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { Ionicons, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import * as DocumentPicker from "expo-document-picker";
import { router, Stack } from "expo-router";
import React, { useEffect, useState } from "react";
import { SafeAreaView, ScrollView, View } from "react-native";
import { Pressable, SafeAreaView, ScrollView, View } from "react-native";
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
function getFileIcon(ext: string): keyof typeof MaterialCommunityIcons.glyphMap {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return 'image-outline'
if (ext === 'pdf') return 'file-pdf-box'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline'
if (['doc', 'docx'].includes(ext)) return 'file-word-outline'
if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline'
if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline'
return 'file-outline'
}
function getFileColor(ext: string): string {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return '#339AF0'
if (ext === 'pdf') return '#F03E3E'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return '#AE3EC9'
if (['doc', 'docx'].includes(ext)) return '#1C7ED6'
if (['xls', 'xlsx'].includes(ext)) return '#2F9E44'
if (['zip', 'rar', '7z'].includes(ext)) return '#E8590C'
return '#868E96'
}
export default function CreateAnnouncement() {
const dispatch = useDispatch()
const update = useSelector((state: any) => state.announcementUpdate)
@@ -28,109 +46,77 @@ export default function CreateAnnouncement() {
const { colors } = useTheme();
const [disableBtn, setDisableBtn] = useState(true);
const [modalDivisi, setModalDivisi] = useState(false);
const [divisionMember, setDivisionMember] = useState<any>([])
const [divisionMember, setDivisionMember] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const [fileForm, setFileForm] = useState<any[]>([])
const [isModalFile, setModalFile] = useState(false)
const [indexDelFile, setIndexDelFile] = useState<number>(0)
const [dataForm, setDataForm] = useState({
title: "",
desc: "",
});
const [error, setError] = useState({
title: false,
desc: false,
});
const [dataForm, setDataForm] = useState({ title: "", desc: "" });
const [error, setError] = useState({ title: false, desc: false });
const totalDivisi = divisionMember.reduce((acc: number, g: any) => acc + g.Division.length, 0)
function validationForm(cat: string, val: any) {
if (cat == "title") {
if (cat === "title") {
setDataForm({ ...dataForm, title: val });
if (val == "" || val == "null") {
setError({ ...error, title: true });
} else {
setError({ ...error, title: false });
}
} else if (cat == "desc") {
setError({ ...error, title: val === "" || val === "null" });
} else if (cat === "desc") {
setDataForm({ ...dataForm, desc: val });
if (val == "" || val == "null") {
setError({ ...error, desc: true });
} else {
setError({ ...error, desc: false });
}
setError({ ...error, desc: val === "" || val === "null" });
}
}
function checkForm() {
if (
Object.values(error).some((v) => v == true) ||
Object.values(dataForm).some((v) => v == "")
) {
setDisableBtn(true);
} else {
setDisableBtn(false);
}
const hasError = Object.values(error).some(v => v)
const hasEmpty = Object.values(dataForm).some(v => v === "")
setDisableBtn(hasError || hasEmpty);
}
useEffect(() => {
checkForm();
}, [error, dataForm]);
useEffect(() => { checkForm() }, [error, dataForm]);
async function handleCreate() {
try {
setLoading(true)
const hasil = await decryptToken(String(token?.current))
const fd = new FormData()
for (let i = 0; i < fileForm.length; i++) {
fd.append(`file${i}`, {
uri: fileForm[i].uri,
type: 'application/octet-stream',
name: fileForm[i].name,
} as any);
fd.append(`file${i}`, { uri: fileForm[i].uri, type: 'application/octet-stream', name: fileForm[i].name } as any);
}
fd.append("data", JSON.stringify(
{ user: hasil, groups: divisionMember, ...dataForm }
))
fd.append("data", JSON.stringify({ user: hasil, groups: divisionMember, ...dataForm }))
const response = await apiCreateAnnouncement(fd)
if (response.success) {
dispatch(setUpdateAnnouncement(!update))
Toast.show({ type: 'small', text1: 'Berhasil menambahkan data', })
Toast.show({ type: 'small', text1: 'Berhasil menambahkan data' })
router.back();
} else {
Toast.show({ type: 'small', text1: response.message, })
Toast.show({ type: 'small', text1: response.message })
}
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Tidak dapat terhubung ke server"
Toast.show({
type: 'small',
text1: message
})
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Tidak dapat terhubung ke server" })
} finally {
setLoading(false)
}
}
const pickDocumentAsync = async () => {
let result = await DocumentPicker.getDocumentAsync({
type: ["*/*"],
multiple: true
});
const result = await DocumentPicker.getDocumentAsync({ type: ["*/*"], multiple: true });
if (!result.canceled) {
for (let i = 0; i < result.assets?.length; i++) {
if (result.assets[i].uri) {
setFileForm((prev) => [...prev, result.assets[i]])
let skipped = 0
for (const asset of result.assets) {
if (!asset.uri) continue
if (fileForm.some(f => f.name === asset.name)) {
skipped++
} else {
setFileForm(prev => [...prev, asset])
}
}
if (skipped > 0) Toast.show({ type: 'small', text1: 'Beberapa file sudah ditambahkan' })
}
};
function deleteFile(index: number) {
setFileForm([...fileForm.filter((val, i) => i !== index)])
setFileForm(fileForm.filter((_, i) => i !== index))
setModalFile(false)
}
@@ -138,26 +124,6 @@ export default function CreateAnnouncement() {
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
// headerLeft: () => (
// <ButtonBackHeader
// onPress={() => {
// router.back();
// }}
// />
// ),
headerTitle: "Tambah Pengumuman",
headerTitleAlign: "center",
// headerRight: () => (
// <ButtonSaveHeader
// disable={disableBtn || divisionMember.length == 0 || loading ? true : false}
// category="create"
// onPress={() => {
// divisionMember.length == 0
// ? Toast.show({ type: 'small', text1: "Anda belum memilih divisi", })
// : handleCreate();
// }}
// />
// ),
header: () => (
<AppHeader
title="Tambah Pengumuman"
@@ -165,12 +131,12 @@ export default function CreateAnnouncement() {
onPressLeft={() => router.back()}
right={
<ButtonSaveHeader
disable={disableBtn || divisionMember.length == 0 || loading ? true : false}
disable={disableBtn || divisionMember.length === 0 || loading}
category="create"
onPress={() => {
divisionMember.length == 0
? Toast.show({ type: 'small', text1: "Anda belum memilih divisi", })
: handleCreate();
divisionMember.length === 0
? Toast.show({ type: 'small', text1: "Anda belum memilih divisi" })
: handleCreate()
}}
/>
}
@@ -179,11 +145,9 @@ export default function CreateAnnouncement() {
}}
/>
{loading && <LoadingCenter />}
<ScrollView
showsVerticalScrollIndicator={false}
style={[Styles.h100, { backgroundColor: colors.background }]}
>
<View style={[Styles.p15]}>
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100, { backgroundColor: colors.background }]}>
<View style={Styles.p15}>
<InputForm
label="Judul"
type="default"
@@ -194,6 +158,7 @@ export default function CreateAnnouncement() {
errorText="Judul harus diisi"
onChange={(val) => validationForm("title", val)}
/>
<InputForm
label="Pengumuman"
type="default"
@@ -205,68 +170,105 @@ export default function CreateAnnouncement() {
onChange={(val) => validationForm("desc", val)}
multiline
/>
<ButtonSelect value="Upload File" onPress={pickDocumentAsync} />
{
fileForm.length > 0
&&
<>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>File</Text>
<Text style={[Styles.textDefault]}>{fileForm.length} file</Text>
{/* File */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={pickDocumentAsync}
style={[Styles.sectionActionRow, { marginBottom: fileForm.length > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.icon + '15' }]}>
<MaterialCommunityIcons name="paperclip" size={18} color={colors.dimmed} />
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, Styles.mb10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
fileForm.map((item, index) => (
<BorderBottomItem
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>File</Text>
{fileForm.length === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Opsional ketuk untuk upload</Text>
)}
</View>
{fileForm.length > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.dimmed + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{fileForm.length} file</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{fileForm.length > 0 && (
<View style={Styles.fileGrid}>
{fileForm.map((item, index) => {
const ext = item.name.split('.').pop()?.toLowerCase() ?? ''
const baseName = item.name.includes('.') ? item.name.split('.').slice(0, -1).join('.') : item.name
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={index}
borderType={fileForm.length - 1 == index ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name}
bgColor="transparent"
titleWeight="normal"
onPress={() => { setIndexDelFile(index); setModalFile(true) }}
/>
))
}
</View>
</>
}
<ButtonSelect
value="Pilih divisi penerima pengumuman"
onPress={() => {
setModalDivisi(true)
}}
/>
{
divisionMember.length > 0
&&
<>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>Divisi</Text>
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
divisionMember.map((item: { name: any; Division: any }, index: any) => {
return (
<View key={index}>
<Text style={[Styles.textDefaultSemiBold]}>{item.name}</Text>
{
item.Division.map((division: any, i: any) => (
<View key={i} style={[Styles.rowItemsCenter, Styles.w90]}>
<Entypo name="dot-single" size={24} color={colors.text} />
<Text style={[Styles.textDefault]} numberOfLines={1} ellipsizeMode='tail'>{division.name}</Text>
</View>
))
}
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
)
})
}
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{baseName}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
</View>
</>
}
)}
</View>
{/* Divisi Penerima */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={() => setModalDivisi(true)}
style={[Styles.sectionActionRow, { marginBottom: divisionMember.length > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.tabActive + '18' }]}>
<MaterialIcons name="groups" size={18} color={colors.tabActive} />
</View>
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>Divisi Penerima</Text>
{divisionMember.length === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Belum ada divisi dipilih</Text>
)}
</View>
{divisionMember.length > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.tabActive + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.tabActive }]}>{totalDivisi} divisi</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{divisionMember.length > 0 && (
<View style={{ gap: 10 }}>
{divisionMember.map((item: any, index: number) => (
<View key={index}>
<Text style={[Styles.textMediumNormal, { color: colors.dimmed, marginBottom: 4 }]}>
{item.name}
</Text>
<View style={{ gap: 6 }}>
{item.Division.map((division: any, i: number) => (
<View key={i} style={[Styles.listItemCard, { borderColor: colors.icon + '18' }]}>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.tabActive + '18', width: 28, height: 28, borderRadius: 8 }]}>
<MaterialIcons name="group" size={14} color={colors.tabActive} />
</View>
<Text style={[Styles.textDefault, Styles.flex1, { color: colors.text }]} numberOfLines={1}>
{division.name}
</Text>
</View>
))}
</View>
</View>
))}
</View>
)}
</View>
</View>
</ScrollView>
@@ -287,12 +289,10 @@ export default function CreateAnnouncement() {
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus"
onPress={() => { deleteFile(indexDelFile) }}
onPress={() => deleteFile(indexDelFile)}
/>
</View>
</DrawerBottom>
</SafeAreaView>
);
}

View File

@@ -1,7 +1,5 @@
import AppHeader from "@/components/AppHeader";
import BorderBottomItem from "@/components/borderBottomItem";
import ButtonSaveHeader from "@/components/buttonSaveHeader";
import ButtonSelect from "@/components/buttonSelect";
import DrawerBottom from "@/components/drawerBottom";
import { InputForm } from "@/components/inputForm";
import LoadingCenter from "@/components/loadingCenter";
@@ -13,22 +11,38 @@ import { setUpdateAnnouncement } from "@/lib/announcementUpdate";
import { apiEditAnnouncement, apiGetAnnouncementOne } from "@/lib/api";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Entypo, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { Ionicons, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import * as DocumentPicker from "expo-document-picker";
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { SafeAreaView, ScrollView, View } from "react-native";
import { Pressable, SafeAreaView, ScrollView, View } from "react-native";
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
function getFileIcon(ext: string): keyof typeof MaterialCommunityIcons.glyphMap {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return 'image-outline'
if (ext === 'pdf') return 'file-pdf-box'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline'
if (['doc', 'docx'].includes(ext)) return 'file-word-outline'
if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline'
if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline'
return 'file-outline'
}
function getFileColor(ext: string): string {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return '#339AF0'
if (ext === 'pdf') return '#F03E3E'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return '#AE3EC9'
if (['doc', 'docx'].includes(ext)) return '#1C7ED6'
if (['xls', 'xlsx'].includes(ext)) return '#2F9E44'
if (['zip', 'rar', '7z'].includes(ext)) return '#E8590C'
return '#868E96'
}
type GroupDivision = {
id: string;
name: string;
Division: {
id: string;
name: string;
}[];
Division: { id: string; name: string }[];
}
export default function EditAnnouncement() {
@@ -39,43 +53,29 @@ export default function EditAnnouncement() {
const { colors } = useTheme();
const [modalDivisi, setModalDivisi] = useState(false);
const [disableBtn, setDisableBtn] = useState(true);
const [dataMember, setDataMember] = useState<any>([]);
const [dataMember, setDataMember] = useState<GroupDivision[]>([]);
const [fileForm, setFileForm] = useState<any[]>([])
const [dataFile, setDataFile] = useState<{ id: string; idStorage: string; name: string; extension: string; delete?: boolean }[]>([])
const [indexDelFile, setIndexDelFile] = useState<{ id: string | number; cat: "newFile" | "oldFile" }>({ id: "", cat: "newFile" })
const [isModalFile, setModalFile] = useState(false)
const [loading, setLoading] = useState(false)
const [dataForm, setDataForm] = useState({
title: "",
desc: "",
});
const [error, setError] = useState({
title: false,
desc: false,
});
const [dataForm, setDataForm] = useState({ title: "", desc: "" });
const [error, setError] = useState({ title: false, desc: false });
const visibleOldFiles = dataFile.filter(v => !v.delete)
const totalFiles = fileForm.length + visibleOldFiles.length
const totalDivisi = dataMember.reduce((acc: number, g: any) => acc + g.Division.length, 0)
async function handleLoad() {
try {
const hasil = await decryptToken(String(token?.current));
const response = await apiGetAnnouncementOne({ id: id, user: hasil });
setDataForm(response.data);
const arrNew: GroupDivision[] = []
const coba = Object.keys(response.member).map((v: any, i: any) => {
const newObject = {
"id": response.member[v][0].idGroup,
"name": v,
"Division": response.member[v]
}
response.member[v].map((v: any, i: any) => {
newObject["Division"][i] = {
"id": v.idDivision,
"name": v.division
}
})
arrNew.push(newObject)
})
const arrNew: GroupDivision[] = Object.keys(response.member).map((v) => ({
id: response.member[v][0].idGroup,
name: v,
Division: response.member[v].map((m: any) => ({ id: m.idDivision, name: m.division }))
}))
setDataMember(arrNew);
setDataFile(response.file);
} catch (error) {
@@ -83,42 +83,25 @@ export default function EditAnnouncement() {
}
}
useEffect(() => {
handleLoad();
}, []);
useEffect(() => { handleLoad() }, []);
function validationForm(cat: string, val: any) {
if (cat == "title") {
if (cat === "title") {
setDataForm({ ...dataForm, title: val });
if (val == "" || val == "null") {
setError({ ...error, title: true });
} else {
setError({ ...error, title: false });
}
} else if (cat == "desc") {
setError({ ...error, title: val === "" || val === "null" });
} else if (cat === "desc") {
setDataForm({ ...dataForm, desc: val });
if (val == "" || val == "null") {
setError({ ...error, desc: true });
} else {
setError({ ...error, desc: false });
}
setError({ ...error, desc: val === "" || val === "null" });
}
}
function checkForm() {
if (
Object.values(error).some((v) => v == true) ||
Object.values(dataForm).some((v) => v == "")
) {
setDisableBtn(true);
} else {
setDisableBtn(false);
}
const hasError = Object.values(error).some(v => v)
const hasEmpty = Object.values(dataForm).some(v => v === "")
setDisableBtn(hasError || hasEmpty);
}
useEffect(() => {
checkForm();
}, [error, dataForm]);
useEffect(() => { checkForm() }, [error, dataForm]);
async function handleEdit() {
try {
@@ -126,90 +109,56 @@ export default function EditAnnouncement() {
const hasil = await decryptToken(String(token?.current))
const fd = new FormData()
for (let i = 0; i < fileForm.length; i++) {
fd.append(`file${i}`, {
uri: fileForm[i].uri,
type: 'application/octet-stream',
name: fileForm[i].name,
} as any);
fd.append(`file${i}`, { uri: fileForm[i].uri, type: 'application/octet-stream', name: fileForm[i].name } as any);
}
fd.append("data", JSON.stringify(
{
...dataForm, user: hasil, groups: dataMember, oldFile: dataFile
}
))
fd.append("data", JSON.stringify({ ...dataForm, user: hasil, groups: dataMember, oldFile: dataFile }))
const response = await apiEditAnnouncement(fd, id);
if (response.success) {
dispatch(setUpdateAnnouncement(!update))
Toast.show({ type: 'small', text1: 'Berhasil mengubah data', })
Toast.show({ type: 'small', text1: 'Berhasil mengubah data' })
router.back();
} else {
Toast.show({ type: 'small', text1: 'Gagal mengubah data', })
Toast.show({ type: 'small', text1: 'Gagal mengubah data' })
}
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal mengubah data"
Toast.show({ type: 'small', text1: message })
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal mengubah data" })
} finally {
setLoading(false)
}
}
const pickDocumentAsync = async () => {
let result = await DocumentPicker.getDocumentAsync({
type: ["*/*"],
multiple: true
});
const result = await DocumentPicker.getDocumentAsync({ type: ["*/*"], multiple: true });
if (!result.canceled) {
for (let i = 0; i < result.assets?.length; i++) {
if (result.assets[i].uri) {
setFileForm((prev) => [...prev, result.assets[i]])
let skipped = 0
for (const asset of result.assets) {
if (!asset.uri) continue
const isDup = fileForm.some(f => f.name === asset.name) ||
visibleOldFiles.some(f => `${f.name}.${f.extension}` === asset.name)
if (isDup) {
skipped++
} else {
setFileForm(prev => [...prev, asset])
}
}
if (skipped > 0) Toast.show({ type: 'small', text1: 'Beberapa file sudah ditambahkan' })
}
};
function deleteFile(index: number | string, cat: "newFile" | "oldFile" | null) {
if (cat == "newFile") {
setFileForm([...fileForm.filter((val, i) => i !== index)])
if (cat === "newFile") {
setFileForm(fileForm.filter((_, i) => i !== index))
} else {
setDataFile(prev =>
prev.map(item =>
item.id === index
? { ...item, delete: true }
: item
)
);
setDataFile(prev => prev.map(item => item.id === index ? { ...item, delete: true } : item))
}
setModalFile(false)
}
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
// headerLeft: () => (
// <ButtonBackHeader
// onPress={() => {
// router.back();
// }}
// />
// ),
headerTitle: "Edit Pengumuman",
headerTitleAlign: "center",
// headerRight: () => (
// <ButtonSaveHeader
// disable={disableBtn || loading ? true : false}
// category="update"
// onPress={() => {
// dataMember.length == 0
// ? Toast.show({ type: 'small', text1: "Anda belum memilih divisi", })
// : handleEdit();
// }}
// />
// ),
header: () => (
<AppHeader
title="Edit Pengumuman"
@@ -217,12 +166,12 @@ export default function EditAnnouncement() {
onPressLeft={() => router.back()}
right={
<ButtonSaveHeader
disable={disableBtn || loading ? true : false}
disable={disableBtn || dataMember.length === 0 || loading}
category="update"
onPress={() => {
dataMember.length == 0
? Toast.show({ type: 'small', text1: "Anda belum memilih divisi", })
: handleEdit();
dataMember.length === 0
? Toast.show({ type: 'small', text1: "Anda belum memilih divisi" })
: handleEdit()
}}
/>
}
@@ -231,11 +180,9 @@ export default function EditAnnouncement() {
}}
/>
{loading && <LoadingCenter />}
<ScrollView
showsVerticalScrollIndicator={false}
style={[Styles.h100, { backgroundColor: colors.background }]}
>
<View style={[Styles.p15]}>
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100, { backgroundColor: colors.background }]}>
<View style={Styles.p15}>
<InputForm
label="Judul"
type="default"
@@ -247,6 +194,7 @@ export default function EditAnnouncement() {
onChange={(val) => validationForm("title", val)}
value={dataForm.title}
/>
<InputForm
label="Pengumuman"
type="default"
@@ -259,79 +207,125 @@ export default function EditAnnouncement() {
value={dataForm.desc}
multiline
/>
<ButtonSelect value="Upload File" onPress={pickDocumentAsync} />
{
(fileForm.length > 0 || dataFile.filter((val) => !val.delete).length > 0)
&&
<>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>File</Text>
<Text style={[Styles.textDefault]}>{fileForm.length + dataFile.filter((val) => !val.delete).length} file</Text>
{/* File */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={pickDocumentAsync}
style={[Styles.sectionActionRow, { marginBottom: totalFiles > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.icon + '15' }]}>
<MaterialCommunityIcons name="paperclip" size={18} color={colors.dimmed} />
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, Styles.mb10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
dataFile.filter((val) => !val.delete).map((item, index) => (
<BorderBottomItem
key={index}
borderType={dataFile.filter((val) => !val.delete).length - 1 == index && fileForm.length == 0 ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name + '.' + item.extension}
titleWeight="normal"
bgColor="transparent"
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>File</Text>
{totalFiles === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Opsional ketuk untuk upload</Text>
)}
</View>
{totalFiles > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.dimmed + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{totalFiles} file</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{totalFiles > 0 && (
<View style={Styles.fileGrid}>
{visibleOldFiles.map((item, index) => {
const ext = item.extension.toLowerCase()
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={`old-${index}`}
onPress={() => { setIndexDelFile({ id: item.id, cat: "oldFile" }); setModalFile(true) }}
/>
))
}
{
fileForm.map((item, index) => (
<BorderBottomItem
key={index}
borderType={fileForm.length - 1 == index ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name}
titleWeight="normal"
bgColor="transparent"
onPress={() => { setIndexDelFile({ id: index, cat: "newFile" }); setModalFile(true) }}
/>
))
}
</View>
</>
}
<ButtonSelect
value="Pilih divisi penerima pengumuman"
onPress={() => {
setModalDivisi(true)
}}
/>
{
dataMember.length > 0
&&
<>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>Divisi</Text>
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
dataMember.map((item: { name: any; Division: any }, index: any) => {
return (
<View key={index}>
<Text style={[Styles.textDefaultSemiBold]}>{item.name}</Text>
{
item.Division.map((division: any, i: any) => (
<View key={i} style={[Styles.rowItemsCenter, Styles.w90]}>
<Entypo name="dot-single" size={24} color={colors.text} />
<Text style={[Styles.textDefault]} numberOfLines={1} ellipsizeMode='tail'>{division.name}</Text>
</View>
))
}
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
)
})
}
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{item.name}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
{fileForm.map((item, index) => {
const ext = item.name.split('.').pop()?.toLowerCase() ?? ''
const baseName = item.name.includes('.') ? item.name.split('.').slice(0, -1).join('.') : item.name
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={`new-${index}`}
onPress={() => { setIndexDelFile({ id: index, cat: "newFile" }); setModalFile(true) }}
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{baseName}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
</View>
</>
}
)}
</View>
{/* Divisi Penerima */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={() => setModalDivisi(true)}
style={[Styles.sectionActionRow, { marginBottom: dataMember.length > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.tabActive + '18' }]}>
<MaterialIcons name="groups" size={18} color={colors.tabActive} />
</View>
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>Divisi Penerima</Text>
{dataMember.length === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Belum ada divisi dipilih</Text>
)}
</View>
{dataMember.length > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.tabActive + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.tabActive }]}>{totalDivisi} divisi</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{dataMember.length > 0 && (
<View style={{ gap: 10 }}>
{dataMember.map((item, index) => (
<View key={index}>
<Text style={[Styles.textMediumNormal, { color: colors.dimmed, marginBottom: 4 }]}>
{item.name}
</Text>
<View style={{ gap: 6 }}>
{item.Division.map((division, i) => (
<View key={i} style={[Styles.listItemCard, { borderColor: colors.icon + '18' }]}>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.tabActive + '18', width: 28, height: 28, borderRadius: 8 }]}>
<MaterialIcons name="group" size={14} color={colors.tabActive} />
</View>
<Text style={[Styles.textDefault, Styles.flex1, { color: colors.text }]} numberOfLines={1}>
{division.name}
</Text>
</View>
))}
</View>
</View>
))}
</View>
)}
</View>
</View>
</ScrollView>
@@ -353,7 +347,7 @@ export default function EditAnnouncement() {
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus"
onPress={() => { deleteFile(indexDelFile.id, indexDelFile.cat) }}
onPress={() => deleteFile(indexDelFile.id, indexDelFile.cat)}
/>
</View>
</DrawerBottom>

View File

@@ -1,7 +1,6 @@
import GuideOverlay from "@/components/GuideOverlay";
import BorderBottomItem from "@/components/borderBottomItem";
import InputSearch from "@/components/inputSearch";
import SkeletonContent from "@/components/skeletonContent";
import Skeleton from "@/components/skeleton";
import Text from '@/components/Text';
import Styles from "@/constants/Styles";
import { apiGetAnnouncement } from "@/lib/api";
@@ -12,14 +11,14 @@ import { useTheme } from "@/providers/ThemeProvider";
import { MaterialIcons } from "@expo/vector-icons";
import { useInfiniteQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import { RefreshControl, View, VirtualizedList } from "react-native";
import { useEffect, useMemo, useRef, useState } from "react";
import { Pressable, RefreshControl, View, VirtualizedList } from "react-native";
import { useSelector } from "react-redux";
type Props = {
id: string,
title: string,
desc: string,
id: string
title: string
desc: string
createdAt: string
}
@@ -28,10 +27,20 @@ export default function Announcement() {
const { colors } = useTheme();
const [search, setSearch] = useState('')
const update = useSelector((state: any) => state.announcementUpdate)
const isFirstRender = useRef(true)
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('announcement')
const arrSkeleton = Array.from({ length: 5 }, (_, index) => index)
const arrSkeleton = Array.from({ length: 5 }, (_, i) => i)
const themed = {
background: { backgroundColor: colors.background },
card: { backgroundColor: colors.card, borderColor: colors.icon + '18' },
iconBox: { backgroundColor: colors.icon + '18' },
title: { color: colors.text },
desc: { color: colors.dimmed },
date: { color: colors.dimmed },
cardPressed: { backgroundColor: colors.icon + '08' },
}
// TanStack Query Infinite Query
const {
data,
fetchNextPage,
@@ -44,11 +53,7 @@ export default function Announcement() {
queryKey: ['announcements', search],
queryFn: async ({ pageParam = 1 }) => {
const hasil = await decryptToken(String(token?.current))
const response = await apiGetAnnouncement({
user: hasil,
search: search,
page: pageParam
})
const response = await apiGetAnnouncement({ user: hasil, search, page: pageParam })
return response.data
},
initialPageParam: 1,
@@ -57,21 +62,12 @@ export default function Announcement() {
},
})
// Trigger refetch when Redux state 'update' changes
useEffect(() => {
if (isFirstRender.current) { isFirstRender.current = false; return }
refetch()
}, [update, refetch])
}, [update])
// Flatten data from pages
const flattenedData = useMemo(() => {
return data?.pages.flat() || []
}, [data])
const loadMoreData = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
};
const flattenedData = useMemo(() => data?.pages.flat() || [], [data])
const getItem = (_data: unknown, index: number): Props => ({
id: flattenedData[index].id,
@@ -80,59 +76,80 @@ export default function Announcement() {
createdAt: flattenedData[index].createdAt,
})
return (
<View style={[Styles.p15, Styles.flex1, { backgroundColor: colors.background }]}>
<GuideOverlay visible={guideVisible} steps={GUIDE_ANNOUNCEMENT} onDismiss={dismissGuide} />
<View>
<InputSearch onChange={setSearch} />
const renderSkeleton = () => (
<View style={Styles.announcementListSkeletonCard}>
<View style={[Styles.rowSpaceBetween, Styles.rowItemsCenter, Styles.announcementListSkeletonHeader]}>
<View style={[Styles.rowItemsCenter, Styles.announcementListSkeletonTitleRow]}>
<Skeleton width={28} height={28} borderRadius={8} />
<Skeleton width={50} widthType="percent" height={12} borderRadius={6} />
</View>
<Skeleton width={15} widthType="percent" height={10} borderRadius={6} />
</View>
<View style={[Styles.flex2, Styles.mt05]}>
{
isLoading && !flattenedData.length ?
arrSkeleton.map((item, index) => {
return (
<SkeletonContent key={index} />
)
})
:
flattenedData.length > 0
?
<VirtualizedList
data={flattenedData}
getItemCount={() => flattenedData.length}
getItem={getItem}
renderItem={({ item, index }: { item: Props, index: number }) => {
return (
<BorderBottomItem
key={index}
onPress={() => { router.push(`/announcement/${item.id}`) }}
borderType="bottom"
bgColor="transparent"
icon={
<MaterialIcons name="campaign" size={25} color={colors.text} />
}
title={item.title}
desc={item.desc.replace(/<[^>]*>?/gm, '').replace(/\r?\n|\r/g, ' ')}
rightTopInfo={item.createdAt}
/>
)
}}
keyExtractor={(item, index) => String(item.id || index)}
onEndReached={loadMoreData}
onEndReachedThreshold={0.5}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefetching && !isFetchingNextPage}
onRefresh={refetch}
tintColor={colors.icon}
/>
}
<Skeleton width={100} widthType="percent" height={10} borderRadius={6} />
<Skeleton width={80} widthType="percent" height={10} borderRadius={6} />
</View>
)
const renderItem = ({ item }: { item: Props }) => (
<Pressable
onPress={() => router.push(`/announcement/${item.id}`)}
style={({ pressed }) => [Styles.announcementListCard, themed.card, pressed && themed.cardPressed]}
>
<View style={[Styles.rowSpaceBetween, Styles.rowItemsCenter, Styles.announcementListCardHeader]}>
<View style={[Styles.rowItemsCenter, Styles.announcementListTitleRow]}>
<View style={[Styles.announcementListIconBox, themed.iconBox]}>
<MaterialIcons name="campaign" size={16} color={colors.icon} />
</View>
<Text style={[Styles.textDefaultSemiBold, Styles.announcementListTitleText, themed.title]} numberOfLines={1}>
{item.title}
</Text>
</View>
<Text style={[Styles.textInformation, Styles.announcementListDateText, themed.date]}>
{item.createdAt}
</Text>
</View>
<Text style={[Styles.textMediumNormal, Styles.announcementListDescText, themed.title]} numberOfLines={2}>
{item.desc.replace(/<[^>]*>?/gm, '').replace(/\r?\n|\r/g, ' ')}
</Text>
</Pressable>
)
return (
<View style={[Styles.flex1, Styles.announcementListContainer, themed.background]}>
<GuideOverlay visible={guideVisible} steps={GUIDE_ANNOUNCEMENT} onDismiss={dismissGuide} />
<InputSearch onChange={setSearch} />
<View style={[Styles.flex1, Styles.announcementListInner]}>
{isLoading && !flattenedData.length ? (
arrSkeleton.map((_, i) => (
<View key={i} style={[Styles.announcementListCard, themed.card]}>
{renderSkeleton()}
</View>
))
) : flattenedData.length > 0 ? (
<VirtualizedList
data={flattenedData}
getItemCount={() => flattenedData.length}
getItem={getItem}
renderItem={renderItem}
keyExtractor={(item, index) => String(item.id || index)}
onEndReached={() => { if (hasNextPage && !isFetchingNextPage) fetchNextPage() }}
onEndReachedThreshold={0.5}
showsVerticalScrollIndicator={false}
ItemSeparatorComponent={() => <View style={Styles.announcementListSeparator} />}
refreshControl={
<RefreshControl
refreshing={isRefetching && !isFetchingNextPage}
onRefresh={refetch}
tintColor={colors.icon}
/>
:
<Text style={[Styles.textDefault, Styles.textCenter, { color: colors.dimmed }]}>Tidak ada pengumuman</Text>
}
}
/>
) : (
<Text style={[Styles.textDefault, Styles.textCenter, Styles.mt30, themed.desc]}>
Tidak ada pengumuman
</Text>
)}
</View>
</View>
)
}
}

View File

@@ -1,7 +1,5 @@
import AppHeader from "@/components/AppHeader";
import BorderBottomItem from "@/components/borderBottomItem";
import ButtonSaveHeader from "@/components/buttonSaveHeader";
import ButtonSelect from "@/components/buttonSelect";
import DrawerBottom from "@/components/drawerBottom";
import ImageUser from "@/components/imageNew";
import { InputForm } from "@/components/inputForm";
@@ -17,14 +15,33 @@ import { setUpdateDiscussionGeneralDetail } from "@/lib/discussionGeneralDetail"
import { setMemberChoose } from "@/lib/memberChoose";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { Ionicons, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import * as DocumentPicker from "expo-document-picker";
import { router, Stack } from "expo-router";
import { useEffect, useState } from "react";
import { SafeAreaView, ScrollView, View } from "react-native";
import { Pressable, SafeAreaView, ScrollView, View } from "react-native";
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
function getFileIcon(ext: string): keyof typeof MaterialCommunityIcons.glyphMap {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return 'image-outline'
if (ext === 'pdf') return 'file-pdf-box'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline'
if (['doc', 'docx'].includes(ext)) return 'file-word-outline'
if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline'
if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline'
return 'file-outline'
}
function getFileColor(ext: string): string {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return '#339AF0'
if (ext === 'pdf') return '#F03E3E'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return '#AE3EC9'
if (['doc', 'docx'].includes(ext)) return '#1C7ED6'
if (['xls', 'xlsx'].includes(ext)) return '#2F9E44'
if (['zip', 'rar', '7z'].includes(ext)) return '#E8590C'
return '#868E96'
}
export default function CreateDiscussionGeneral() {
const { token, decryptToken } = useAuthSession()
@@ -43,84 +60,67 @@ export default function CreateDiscussionGeneral() {
const [fileForm, setFileForm] = useState<any[]>([])
const [isModalFile, setModalFile] = useState(false)
const [indexDelFile, setIndexDelFile] = useState<number>(0)
const [dataForm, setDataForm] = useState({
idGroup: "",
title: "",
desc: "",
});
const [error, setError] = useState({
group: false,
title: false,
desc: false,
});
const [dataForm, setDataForm] = useState({ idGroup: "", title: "", desc: "" });
const [error, setError] = useState({ group: false, title: false, desc: false });
function validationForm(cat: string, val: any, label?: string) {
if (cat == "group") {
if (cat === "group") {
setChooseGroup({ val, label: String(label) });
dispatch(setMemberChoose([]))
setDataForm({ ...dataForm, idGroup: val });
if (val == "" || val == "null") {
setError({ ...error, group: true });
} else {
setError({ ...error, group: false });
}
} else if (cat == "title") {
setError({ ...error, group: val === "" || val === "null" });
} else if (cat === "title") {
setDataForm({ ...dataForm, title: val });
if (val == "" || val == "null") {
setError({ ...error, title: true });
} else {
setError({ ...error, title: false });
}
} else if (cat == "desc") {
setError({ ...error, title: val === "" || val === "null" });
} else if (cat === "desc") {
setDataForm({ ...dataForm, desc: val });
if (val == "" || val == "null") {
setError({ ...error, desc: true });
} else {
setError({ ...error, desc: false });
}
setError({ ...error, desc: val === "" || val === "null" });
}
}
function checkForm() {
if (
Object.values(error).some((v) => v == true) ||
Object.values(dataForm).some((v) => v == "")
) {
setDisableBtn(true);
const hasError = Object.values(error).some(v => v)
const hasEmpty = Object.values(dataForm).some(v => v === "")
setDisableBtn(hasError || hasEmpty);
}
useEffect(() => { checkForm() }, [error, dataForm]);
useEffect(() => { dispatch(setMemberChoose([])) }, [])
function handleOpenMemberPicker() {
if (entityUser.role === "supadmin" || entityUser.role === "developer") {
if (chooseGroup.val !== "") {
setSelect(true);
setValSelect("member");
} else {
Toast.show({ type: 'small', text1: 'Pilih Lembaga Desa terlebih dahulu' })
}
} else {
setDisableBtn(false);
validationForm('group', userLogin.idGroup, userLogin.group);
setValChoose(userLogin.idGroup)
setSelect(true);
setValSelect("member");
}
}
useEffect(() => {
checkForm();
}, [error, dataForm]);
useEffect(() => {
dispatch(setMemberChoose([]))
}, [])
function handleBack() {
dispatch(setMemberChoose([]))
router.back()
}
const pickDocumentAsync = async () => {
let result = await DocumentPicker.getDocumentAsync({
type: ["*/*"],
multiple: true
});
const result = await DocumentPicker.getDocumentAsync({ type: ["*/*"], multiple: true });
if (!result.canceled) {
for (let i = 0; i < result.assets?.length; i++) {
if (result.assets[i].uri) {
setFileForm((prev) => [...prev, result.assets[i]])
let skipped = 0
for (const asset of result.assets) {
if (!asset.uri) continue
if (fileForm.some(f => f.name === asset.name)) {
skipped++
} else {
setFileForm(prev => [...prev, asset])
}
}
if (skipped > 0) Toast.show({ type: 'small', text1: 'Beberapa file sudah ditambahkan' })
}
};
function deleteFile(index: number) {
setFileForm([...fileForm.filter((val, i) => i !== index)])
setFileForm(fileForm.filter((_, i) => i !== index))
setModalFile(false)
}
@@ -129,38 +129,22 @@ export default function CreateDiscussionGeneral() {
setLoading(true)
const hasil = await decryptToken(String(token?.current))
const fd = new FormData()
for (let i = 0; i < fileForm.length; i++) {
fd.append(`file${i}`, {
uri: fileForm[i].uri,
type: 'application/octet-stream',
name: fileForm[i].name,
} as any);
fd.append(`file${i}`, { uri: fileForm[i].uri, type: 'application/octet-stream', name: fileForm[i].name } as any);
}
fd.append("data", JSON.stringify(
{ ...dataForm, user: hasil, member: entitiesMember }
))
fd.append("data", JSON.stringify({ ...dataForm, user: hasil, member: entitiesMember }))
const response = await apiCreateDiscussionGeneral(fd)
// const response = await apiCreateDiscussionGeneral({
// data: { ...dataForm, user: hasil, member: entitiesMember },
// })
if (response.success) {
dispatch(setMemberChoose([]))
dispatch(setUpdateDiscussionGeneralDetail(!update))
Toast.show({ type: 'small', text1: 'Berhasil menambahkan data', })
Toast.show({ type: 'small', text1: 'Berhasil menambahkan data' })
router.back()
} else {
Toast.show({ type: 'small', text1: response.message, })
Toast.show({ type: 'small', text1: response.message })
}
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal menambahkan data"
Toast.show({ type: 'small', text1: message })
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menambahkan data" })
} finally {
setLoading(false)
}
@@ -170,36 +154,18 @@ export default function CreateDiscussionGeneral() {
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
// headerLeft: () => (
// <ButtonBackHeader
// onPress={() => { handleBack() }}
// />
// ),
headerTitle: "Tambah Diskusi",
headerTitleAlign: "center",
// headerRight: () => (
// <ButtonSaveHeader
// category="create"
// disable={disableBtn || loading ? true : false}
// onPress={() => {
// entitiesMember.length == 0
// ? Toast.show({ type: 'small', text1: 'Anda belum memilih anggota', })
// : handleCreate()
// }}
// />
// ),
header: () => (
<AppHeader
title="Tambah Diskusi"
showBack={true}
onPressLeft={() => router.back()}
onPressLeft={() => { dispatch(setMemberChoose([])); router.back() }}
right={
<ButtonSaveHeader
category="create"
disable={disableBtn || loading ? true : false}
disable={disableBtn || entitiesMember.length === 0 || loading}
onPress={() => {
entitiesMember.length == 0
? Toast.show({ type: 'small', text1: 'Anda belum memilih anggota', })
entitiesMember.length === 0
? Toast.show({ type: 'small', text1: 'Anda belum memilih anggota' })
: handleCreate()
}}
/>
@@ -211,25 +177,20 @@ export default function CreateDiscussionGeneral() {
{loading && <LoadingCenter />}
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100, Styles.flex1, { backgroundColor: colors.background }]}>
<View style={[Styles.p15, Styles.mb100]}>
{
(entityUser.role == "supadmin" ||
entityUser.role == "developer") && (
<SelectForm
label="Lembaga Desa"
placeholder="Pilih Lembaga Desa"
value={chooseGroup.label}
required
bg={colors.card}
onPress={() => {
setValChoose(chooseGroup.val);
setValSelect("group");
setSelect(true);
}}
error={error.group}
errorText="Lembaga Desa tidak boleh kosong"
/>
)
}
{(entityUser.role === "supadmin" || entityUser.role === "developer") && (
<SelectForm
label="Lembaga Desa"
placeholder="Pilih Lembaga Desa"
value={chooseGroup.label}
required
bg={colors.card}
onPress={() => { setValChoose(chooseGroup.val); setValSelect("group"); setSelect(true) }}
error={error.group}
errorText="Lembaga Desa tidak boleh kosong"
/>
)}
<InputForm
label="Judul"
type="default"
@@ -238,8 +199,9 @@ export default function CreateDiscussionGeneral() {
error={error.title}
bg={colors.card}
errorText="Judul tidak boleh kosong"
onChange={(val) => { validationForm("title", val) }}
onChange={(val) => validationForm("title", val)}
/>
<InputForm
label="Diskusi"
type="default"
@@ -248,93 +210,107 @@ export default function CreateDiscussionGeneral() {
error={error.desc}
bg={colors.card}
errorText="Diskusi tidak boleh kosong"
onChange={(val) => { validationForm("desc", val) }}
onChange={(val) => validationForm("desc", val)}
multiline
/>
<ButtonSelect value="Upload File" onPress={pickDocumentAsync} />
{
fileForm.length > 0
&&
<>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>File</Text>
<Text style={[Styles.textDefault]}>{fileForm.length} file</Text>
{/* File */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={pickDocumentAsync}
style={[Styles.sectionActionRow, { marginBottom: fileForm.length > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.icon + '15' }]}>
<MaterialCommunityIcons name="paperclip" size={18} color={colors.dimmed} />
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, Styles.mb10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
fileForm.map((item, index) => (
<BorderBottomItem
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>File</Text>
{fileForm.length === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Opsional ketuk untuk upload</Text>
)}
</View>
{fileForm.length > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.dimmed + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{fileForm.length} file</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{fileForm.length > 0 && (
<View style={Styles.fileGrid}>
{fileForm.map((item, index) => {
const ext = item.name.split('.').pop()?.toLowerCase() ?? ''
const baseName = item.name.includes('.') ? item.name.split('.').slice(0, -1).join('.') : item.name
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={index}
borderType={fileForm.length - 1 == index ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name}
bgColor="transparent"
titleWeight="normal"
onPress={() => { setIndexDelFile(index); setModalFile(true) }}
/>
))
}
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{baseName}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
</View>
</>
}
<ButtonSelect
value="Pilih Anggota"
onPress={() => {
if (entityUser.role == "supadmin" || entityUser.role == "developer") {
if (chooseGroup.val != "") {
setSelect(true);
setValSelect("member");
} else {
Toast.show({ type: 'small', text1: 'Pilih Lembaga Desa terlebih dahulu', })
}
} else {
validationForm('group', userLogin.idGroup, userLogin.group);
setValChoose(userLogin.idGroup)
setSelect(true);
setValSelect("member");
}
)}
</View>
}}
/>
{
entitiesMember.length > 0 &&
<View>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>Anggota</Text>
<Text style={[Styles.textDefault]}>{entitiesMember.length} Anggota</Text>
{/* Anggota */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={handleOpenMemberPicker}
style={[Styles.sectionActionRow, { marginBottom: entitiesMember.length > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.tabActive + '18' }]}>
<MaterialIcons name="people" size={18} color={colors.tabActive} />
</View>
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>Anggota</Text>
{entitiesMember.length === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Belum ada anggota dipilih</Text>
)}
</View>
{entitiesMember.length > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.tabActive + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.tabActive }]}>{entitiesMember.length} anggota</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{entitiesMember.length > 0 && (
<View style={{ gap: 6 }}>
{entitiesMember.map((item: any, index: number) => (
<View key={index} style={[Styles.listItemCard, { borderColor: colors.icon + '18' }]}>
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
<Text style={[Styles.textDefault, Styles.flex1, { color: colors.text }]} numberOfLines={1}>
{item.name}
</Text>
</View>
))}
</View>
)}
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
entitiesMember.map((item: { img: any; name: any; }, index: any) => {
return (
<BorderBottomItem
key={index}
borderType={entitiesMember.length - 1 == index ? "none" : "bottom"}
icon={
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
}
title={item.name}
bgColor="transparent"
/>
)
})
}
</View>
</View>
}
</View>
</ScrollView>
<ModalSelect
category={valSelect}
close={setSelect}
onSelect={(value) => {
validationForm(valSelect, value.val, value.label);
}}
title={valSelect == "group" ? "Lembaga Desa" : "Pilih Anggota"}
onSelect={(value) => validationForm(valSelect, value.val, value.label)}
title={valSelect === "group" ? "Lembaga Desa" : "Pilih Anggota"}
open={isSelect}
idParent={valSelect == "member" ? chooseGroup.val : ""}
idParent={valSelect === "member" ? chooseGroup.val : ""}
valChoose={valChoose}
/>
@@ -343,7 +319,7 @@ export default function CreateDiscussionGeneral() {
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus"
onPress={() => { deleteFile(indexDelFile) }}
onPress={() => deleteFile(indexDelFile)}
/>
</View>
</DrawerBottom>

View File

@@ -1,12 +1,10 @@
import AppHeader from "@/components/AppHeader";
import Text from "@/components/Text";
import BorderBottomItem from "@/components/borderBottomItem";
import ButtonSaveHeader from "@/components/buttonSaveHeader";
import ButtonSelect from "@/components/buttonSelect";
import DrawerBottom from "@/components/drawerBottom";
import { InputForm } from "@/components/inputForm";
import LoadingCenter from "@/components/loadingCenter";
import MenuItemRow from "@/components/menuItemRow";
import Text from "@/components/Text";
import Styles from "@/constants/Styles";
import { apiEditDiscussionGeneral, apiGetDiscussionGeneralOne } from "@/lib/api";
import { setUpdateDiscussionGeneralDetail } from "@/lib/discussionGeneralDetail";
@@ -16,10 +14,30 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import * as DocumentPicker from "expo-document-picker";
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { SafeAreaView, ScrollView, View } from "react-native";
import { Pressable, SafeAreaView, ScrollView, View } from "react-native";
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
function getFileIcon(ext: string): keyof typeof MaterialCommunityIcons.glyphMap {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return 'image-outline'
if (ext === 'pdf') return 'file-pdf-box'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline'
if (['doc', 'docx'].includes(ext)) return 'file-word-outline'
if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline'
if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline'
return 'file-outline'
}
function getFileColor(ext: string): string {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return '#339AF0'
if (ext === 'pdf') return '#F03E3E'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return '#AE3EC9'
if (['doc', 'docx'].includes(ext)) return '#1C7ED6'
if (['xls', 'xlsx'].includes(ext)) return '#2F9E44'
if (['zip', 'rar', '7z'].includes(ext)) return '#E8590C'
return '#868E96'
}
export default function EditDiscussionGeneral() {
const { token, decryptToken } = useAuthSession();
const { colors } = useTheme();
@@ -32,136 +50,91 @@ export default function EditDiscussionGeneral() {
const [indexDelFile, setIndexDelFile] = useState<{ id: string | number; cat: "newFile" | "oldFile" }>({ id: "", cat: "newFile" })
const [dataFile, setDataFile] = useState<{ id: string; idStorage: string; name: string; extension: string; delete?: boolean }[]>([])
const update = useSelector((state: any) => state.discussionGeneralDetailUpdate)
const [dataForm, setDataForm] = useState({
title: "",
desc: "",
});
const [error, setError] = useState({
title: false,
desc: false,
})
const [dataForm, setDataForm] = useState({ title: "", desc: "" });
const [error, setError] = useState({ title: false, desc: false })
const visibleOldFiles = dataFile.filter(v => !v.delete)
const totalFiles = fileForm.length + visibleOldFiles.length
async function handleLoad() {
try {
const hasil = await decryptToken(String(token?.current));
const response = await apiGetDiscussionGeneralOne({
id: id,
user: hasil,
cat: "detail",
});
const responseFile = await apiGetDiscussionGeneralOne({
id: id,
user: hasil,
cat: "file",
});
if (response.success) {
setDataForm(response.data);
}
if (responseFile.success) {
setDataFile(responseFile.data);
}
const response = await apiGetDiscussionGeneralOne({ id, user: hasil, cat: "detail" });
const responseFile = await apiGetDiscussionGeneralOne({ id, user: hasil, cat: "file" });
if (response.success) setDataForm(response.data);
if (responseFile.success) setDataFile(responseFile.data);
} catch (error) {
console.error(error);
}
}
useEffect(() => {
handleLoad();
}, []);
useEffect(() => { handleLoad() }, []);
function validationForm(cat: string, val: any) {
if (cat == "title") {
if (cat === "title") {
setDataForm({ ...dataForm, title: val });
if (val == "" || val == "null") {
setError({ ...error, title: true });
} else {
setError({ ...error, title: false });
}
} else if (cat == "desc") {
setError({ ...error, title: val === "" || val === "null" });
} else if (cat === "desc") {
setDataForm({ ...dataForm, desc: val });
if (val == "" || val == "null") {
setError({ ...error, desc: true });
} else {
setError({ ...error, desc: false });
}
setError({ ...error, desc: val === "" || val === "null" });
}
}
function checkForm() {
if (Object.values(error).some((v) => v == true) == true || Object.values(dataForm).some((v) => v == "") == true) {
setDisableBtn(true)
} else {
setDisableBtn(false)
}
const hasError = Object.values(error).some(v => v)
const hasEmpty = Object.values(dataForm).some(v => v === "")
setDisableBtn(hasError || hasEmpty);
}
useEffect(() => {
checkForm()
}, [error, dataForm])
useEffect(() => { checkForm() }, [error, dataForm])
const pickDocumentAsync = async () => {
let result = await DocumentPicker.getDocumentAsync({
type: ["*/*"],
multiple: true
});
const result = await DocumentPicker.getDocumentAsync({ type: ["*/*"], multiple: true });
if (!result.canceled) {
for (let i = 0; i < result.assets?.length; i++) {
if (result.assets[i].uri) {
setFileForm((prev) => [...prev, result.assets[i]])
let skipped = 0
for (const asset of result.assets) {
if (!asset.uri) continue
const isDup = fileForm.some(f => f.name === asset.name) ||
visibleOldFiles.some(f => `${f.name}.${f.extension}` === asset.name)
if (isDup) {
skipped++
} else {
setFileForm(prev => [...prev, asset])
}
}
if (skipped > 0) Toast.show({ type: 'small', text1: 'Beberapa file sudah ditambahkan' })
}
};
function deleteFile(index: number | string, cat: "newFile" | "oldFile" | null) {
if (cat == "newFile") {
setFileForm([...fileForm.filter((val, i) => i !== index)])
if (cat === "newFile") {
setFileForm(fileForm.filter((_, i) => i !== index))
} else {
setDataFile(prev =>
prev.map(item =>
item.id === index
? { ...item, delete: true }
: item
)
);
setDataFile(prev => prev.map(item => item.id === index ? { ...item, delete: true } : item))
}
setModalFile(false)
}
async function handleEdit() {
try {
setLoading(true)
const hasil = await decryptToken(String(token?.current));
const fd = new FormData()
for (let i = 0; i < fileForm.length; i++) {
fd.append(`file${i}`, {
uri: fileForm[i].uri,
type: 'application/octet-stream',
name: fileForm[i].name,
} as any);
fd.append(`file${i}`, { uri: fileForm[i].uri, type: 'application/octet-stream', name: fileForm[i].name } as any);
}
fd.append("data", JSON.stringify(
{
user: hasil, title: dataForm.title, desc: dataForm.desc, oldFile: dataFile
}
))
fd.append("data", JSON.stringify({ user: hasil, title: dataForm.title, desc: dataForm.desc, oldFile: dataFile }))
const response = await apiEditDiscussionGeneral(fd, id);
if (response.success) {
dispatch(setUpdateDiscussionGeneralDetail(!update))
Toast.show({ type: 'small', text1: 'Berhasil mengubah data', })
Toast.show({ type: 'small', text1: 'Berhasil mengubah data' })
router.back();
} else {
Toast.show({ type: 'small', text1: 'Gagal mengubah data', })
Toast.show({ type: 'small', text1: 'Gagal mengubah data' })
}
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal mengubah data"
Toast.show({ type: 'small', text1: message })
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal mengubah data" })
} finally {
setLoading(false)
}
@@ -171,22 +144,6 @@ export default function EditDiscussionGeneral() {
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
// headerLeft: () => (
// <ButtonBackHeader
// onPress={() => {
// router.back();
// }}
// />
// ),
headerTitle: "Edit Diskusi",
headerTitleAlign: "center",
// headerRight: () => (
// <ButtonSaveHeader
// disable={disableBtn || loading ? true : false}
// category="update"
// onPress={() => { handleEdit() }}
// />
// ),
header: () => (
<AppHeader
title="Edit Diskusi"
@@ -194,9 +151,9 @@ export default function EditDiscussionGeneral() {
onPressLeft={() => router.back()}
right={
<ButtonSaveHeader
disable={disableBtn || loading ? true : false}
disable={disableBtn || loading}
category="update"
onPress={() => { handleEdit() }}
onPress={() => handleEdit()}
/>
}
/>
@@ -205,7 +162,8 @@ export default function EditDiscussionGeneral() {
/>
{loading && <LoadingCenter />}
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100, Styles.flex1, { backgroundColor: colors.background }]}>
<View style={[Styles.p15]}>
<View style={Styles.p15}>
<InputForm
label="Judul"
type="default"
@@ -217,6 +175,7 @@ export default function EditDiscussionGeneral() {
errorText="Judul tidak boleh kosong"
onChange={(val) => validationForm("title", val)}
/>
<InputForm
label="Diskusi"
type="default"
@@ -229,45 +188,77 @@ export default function EditDiscussionGeneral() {
onChange={(val) => validationForm("desc", val)}
multiline
/>
<ButtonSelect value="Upload File" onPress={pickDocumentAsync} />
{
(fileForm.length > 0 || dataFile.filter((val) => !val.delete).length > 0)
&&
<>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>File</Text>
<Text style={[Styles.textDefault]}>{fileForm.length + dataFile.filter((val) => !val.delete).length} file</Text>
{/* File */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={pickDocumentAsync}
style={[Styles.sectionActionRow, { marginBottom: totalFiles > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.icon + '15' }]}>
<MaterialCommunityIcons name="paperclip" size={18} color={colors.dimmed} />
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, Styles.mb10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
dataFile.filter((val) => !val.delete).map((item, index) => (
<BorderBottomItem
key={index}
borderType={dataFile.filter((val) => !val.delete).length - 1 == index && fileForm.length == 0 ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name + '.' + item.extension}
titleWeight="normal"
bgColor="transparent"
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>File</Text>
{totalFiles === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Opsional ketuk untuk upload</Text>
)}
</View>
{totalFiles > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.dimmed + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{totalFiles} file</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{totalFiles > 0 && (
<View style={Styles.fileGrid}>
{visibleOldFiles.map((item, index) => {
const ext = item.extension.toLowerCase()
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={`old-${index}`}
onPress={() => { setIndexDelFile({ id: item.id, cat: "oldFile" }); setModalFile(true) }}
/>
))
}
{
fileForm.map((item, index) => (
<BorderBottomItem
key={index}
borderType={fileForm.length - 1 == index ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name}
titleWeight="normal"
bgColor="transparent"
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{item.name}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
{fileForm.map((item, index) => {
const ext = item.name.split('.').pop()?.toLowerCase() ?? ''
const baseName = item.name.includes('.') ? item.name.split('.').slice(0, -1).join('.') : item.name
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={`new-${index}`}
onPress={() => { setIndexDelFile({ id: index, cat: "newFile" }); setModalFile(true) }}
/>
))
}
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{baseName}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
</View>
</>
}
)}
</View>
</View>
</ScrollView>
@@ -276,7 +267,7 @@ export default function EditDiscussionGeneral() {
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus"
onPress={() => { deleteFile(indexDelFile.id, indexDelFile.cat) }}
onPress={() => deleteFile(indexDelFile.id, indexDelFile.cat)}
/>
</View>
</DrawerBottom>

View File

@@ -87,11 +87,24 @@ export default function Discussion() {
const isOpen = (item: Props) => item.status === 1
const themed = {
background: { backgroundColor: colors.background },
card: { backgroundColor: colors.card, borderColor: colors.icon + '20' },
cardPressed: { backgroundColor: colors.icon + '10' },
iconCircle: { backgroundColor: colors.icon + '20' },
title: { color: colors.text },
dimmed: { color: colors.dimmed },
statusOpen: { borderColor: '#10B981' as const },
statusClosed: { borderColor: colors.dimmed + '80' },
statusTextOpen: { color: '#10B981' as const },
statusTextClosed: { color: colors.dimmed },
}
return (
<View style={[Styles.flex1, { backgroundColor: colors.background }]}>
<View style={[Styles.flex1, themed.background]}>
<GuideOverlay visible={guideVisible} steps={GUIDE_DISCUSSION} onDismiss={dismissGuide} />
{/* Header controls */}
<View style={[Styles.ph15, { paddingTop: 12 }]}>
<View style={[Styles.ph15, Styles.discussionHeaderPadding]}>
{entityUser.role != "user" && entityUser.role != "coadmin" && (
<WrapTab>
<ButtonTab
@@ -114,21 +127,21 @@ export default function Discussion() {
)}
<InputSearch onChange={setSearch} />
{(entityUser.role == "supadmin" || entityUser.role == "developer") && (
<View style={[Styles.mv05, Styles.rowItemsCenter]}>
<Text style={{ color: colors.dimmed, fontSize: 12 }}>Filter:</Text>
<View style={[Styles.mt10, Styles.rowOnly]}>
<Text>Filter :</Text>
<LabelStatus size="small" category="secondary" text={nameGroup} style={[Styles.mh05]} />
</View>
)}
</View>
{/* List */}
<View style={[Styles.flex1, Styles.ph15, { paddingTop: 8 }]}>
<View style={[Styles.flex1, Styles.ph15, Styles.discussionListPadding]}>
{isLoading ? (
[0, 1, 2, 3, 4].map((_, i) => <SkeletonContent key={i} />)
) : flatData.length === 0 ? (
<View style={[Styles.contentItemCenter, Styles.mt30]}>
<Feather name="message-circle" size={42} color={colors.icon + '40'} />
<Text style={[Styles.mt10, { color: colors.dimmed, fontSize: 14 }]}>
<Text style={[Styles.mt10, Styles.discussionEmptyText, themed.dimmed]}>
Tidak ada diskusi
</Text>
</View>
@@ -154,27 +167,25 @@ export default function Discussion() {
onPress={() => router.push(`/discussion/${item.id}`)}
style={({ pressed }) => [
Styles.discussionCard,
{
backgroundColor: pressed ? colors.icon + '10' : colors.card,
borderColor: colors.icon + '20',
}
themed.card,
pressed && themed.cardPressed,
]}
>
{/* Top row: icon + title + status badge */}
<View style={[Styles.rowItemsCenter, Styles.mb08]}>
{/* Discussion icon */}
<View style={[Styles.discussionIconCircle, { backgroundColor: colors.icon + '20' }]}>
<View style={[Styles.discussionIconCircle, themed.iconCircle]}>
<Feather name="message-circle" size={20} color={colors.icon} />
</View>
{/* Title + status badge */}
<View style={[Styles.flex1, { marginLeft: 10 }]}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]} numberOfLines={1}>
<View style={[Styles.flex1, Styles.discussionTitleCol]}>
<Text style={[Styles.textDefaultSemiBold, themed.title]} numberOfLines={1}>
{item.title}
</Text>
{status !== "false" && (
<View style={[Styles.discussionStatusPill, { borderColor: isOpen(item) ? '#10B981' : colors.dimmed + '80' }]}>
<Text style={[Styles.discussionStatusText, { color: isOpen(item) ? '#10B981' : colors.dimmed }]}>
<View style={[Styles.discussionStatusPill, isOpen(item) ? themed.statusOpen : themed.statusClosed]}>
<Text style={[Styles.discussionStatusText, isOpen(item) ? themed.statusTextOpen : themed.statusTextClosed]}>
{isOpen(item) ? 'Buka' : 'Tutup'}
</Text>
</View>
@@ -185,7 +196,7 @@ export default function Discussion() {
{/* Description */}
{item.desc ? (
<Text
style={[Styles.textMediumNormal, Styles.discussionCardIndent, { color: colors.dimmed, marginBottom: 10 }]}
style={[Styles.textMediumNormal, Styles.discussionCardIndent, Styles.discussionDescMargin, themed.title]}
numberOfLines={2}
>
{item.desc.replace(/<[^>]*>?/gm, ' ').replace(/\r?\n|\r/g, ' ')}
@@ -196,11 +207,11 @@ export default function Discussion() {
<View style={[Styles.rowItemsCenter, Styles.rowSpaceBetween, Styles.discussionCardIndent]}>
<View style={Styles.rowItemsCenter}>
<Feather name="message-square" size={14} color={colors.dimmed} />
<Text style={[Styles.discussionCommentText, { color: colors.dimmed }]}>
<Text style={[Styles.discussionCommentText, themed.dimmed]}>
{item.total_komentar} Komentar
</Text>
</View>
<Text style={[Styles.discussionDateText, { color: colors.dimmed }]}>
<Text style={[Styles.discussionDateText, themed.dimmed]}>
{item.createdAt}
</Text>
</View>

View File

@@ -1,7 +1,5 @@
import AppHeader from "@/components/AppHeader";
import BorderBottomItem from "@/components/borderBottomItem";
import ButtonSaveHeader from "@/components/buttonSaveHeader";
import ButtonSelect from "@/components/buttonSelect";
import DrawerBottom from "@/components/drawerBottom";
import { InputForm } from "@/components/inputForm";
import LoadingCenter from "@/components/loadingCenter";
@@ -16,10 +14,30 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import * as DocumentPicker from "expo-document-picker";
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { SafeAreaView, ScrollView, View } from "react-native";
import { Pressable, SafeAreaView, ScrollView, View } from "react-native";
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
function getFileIcon(ext: string): keyof typeof MaterialCommunityIcons.glyphMap {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return 'image-outline'
if (ext === 'pdf') return 'file-pdf-box'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline'
if (['doc', 'docx'].includes(ext)) return 'file-word-outline'
if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline'
if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline'
return 'file-outline'
}
function getFileColor(ext: string): string {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return '#339AF0'
if (ext === 'pdf') return '#F03E3E'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return '#AE3EC9'
if (['doc', 'docx'].includes(ext)) return '#1C7ED6'
if (['xls', 'xlsx'].includes(ext)) return '#2F9E44'
if (['zip', 'rar', '7z'].includes(ext)) return '#E8590C'
return '#868E96'
}
export default function DiscussionDivisionEdit() {
const { colors } = useTheme();
const { id, detail } = useLocalSearchParams<{ id: string; detail: string }>();
@@ -33,30 +51,49 @@ export default function DiscussionDivisionEdit() {
const [indexDelFile, setIndexDelFile] = useState<{ id: string | number; cat: "newFile" | "oldFile" }>({ id: "", cat: "newFile" })
const [dataFile, setDataFile] = useState<{ id: string; idStorage: string; name: string; extension: string; delete?: boolean }[]>([])
const visibleOldFiles = dataFile.filter(v => !v.delete)
const totalFiles = fileForm.length + visibleOldFiles.length
async function handleLoad() {
try {
const hasil = await decryptToken(String(token?.current));
const response = await apiGetDiscussionOne({
id: detail,
user: hasil,
cat: "data",
});
const response2 = await apiGetDiscussionOne({
id: detail,
user: hasil,
cat: "file",
});
setDataFile(response2.data);
const response = await apiGetDiscussionOne({ id: detail, user: hasil, cat: "data" });
const response2 = await apiGetDiscussionOne({ id: detail, user: hasil, cat: "file" });
setData(response.data.desc);
setDataFile(response2.data);
} catch (error) {
console.error(error);
}
}
useEffect(() => {
handleLoad();
}, []);
useEffect(() => { handleLoad() }, []);
const pickDocumentAsync = async () => {
const result = await DocumentPicker.getDocumentAsync({ type: ["*/*"], multiple: true });
if (!result.canceled) {
let skipped = 0
for (const asset of result.assets) {
if (!asset.uri) continue
const isDup = fileForm.some(f => f.name === asset.name) ||
visibleOldFiles.some(f => `${f.name}.${f.extension}` === asset.name)
if (isDup) {
skipped++
} else {
setFileForm(prev => [...prev, asset])
}
}
if (skipped > 0) Toast.show({ type: 'small', text1: 'Beberapa file sudah ditambahkan' })
}
};
function deleteFile(index: number | string, cat: "newFile" | "oldFile" | null) {
if (cat === "newFile") {
setFileForm(fileForm.filter((_, i) => i !== index))
} else {
setDataFile(prev => prev.map(item => item.id === index ? { ...item, delete: true } : item))
}
setModalFile(false)
}
async function handleUpdate() {
try {
@@ -64,94 +101,29 @@ export default function DiscussionDivisionEdit() {
const hasil = await decryptToken(String(token?.current));
const fd = new FormData()
for (let i = 0; i < fileForm.length; i++) {
fd.append(`file${i}`, {
uri: fileForm[i].uri,
type: 'application/octet-stream',
name: fileForm[i].name,
} as any);
fd.append(`file${i}`, { uri: fileForm[i].uri, type: 'application/octet-stream', name: fileForm[i].name } as any);
}
fd.append("data", JSON.stringify(
{
user: hasil, desc: data, oldFile: dataFile
}
))
fd.append("data", JSON.stringify({ user: hasil, desc: data, oldFile: dataFile }))
const response = await apiEditDiscussion(fd, detail);
// const response = await apiEditDiscussion({
// data: { user: hasil, desc: data },
// id: detail,
// });
if (response.success) {
Toast.show({ type: 'small', text1: 'Berhasil mengubah data', })
Toast.show({ type: 'small', text1: 'Berhasil mengubah data' })
dispatch(setUpdateDiscussion({ ...update, data: !update.data }));
router.back();
} else {
Toast.show({ type: 'small', text1: response.message, })
Toast.show({ type: 'small', text1: response.message })
}
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal mengubah data"
Toast.show({ type: 'small', text1: message })
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal mengubah data" })
} finally {
setLoading(false)
}
}
const pickDocumentAsync = async () => {
let result = await DocumentPicker.getDocumentAsync({
type: ["*/*"],
multiple: true
});
if (!result.canceled) {
for (let i = 0; i < result.assets?.length; i++) {
if (result.assets[i].uri) {
setFileForm((prev) => [...prev, result.assets[i]])
}
}
}
};
function deleteFile(index: number | string, cat: "newFile" | "oldFile" | null) {
if (cat == "newFile") {
setFileForm([...fileForm.filter((val, i) => i !== index)])
} else {
setDataFile(prev =>
prev.map(item =>
item.id === index
? { ...item, delete: true }
: item
)
);
}
setModalFile(false)
}
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
// headerLeft: () => (
// <ButtonBackHeader
// onPress={() => {
// router.back();
// }}
// />
// ),
headerTitle: "Edit Diskusi",
headerTitleAlign: "center",
// headerRight: () => (
// <ButtonSaveHeader
// disable={data == "" || loading}
// category="update"
// onPress={() => {
// handleUpdate();
// }}
// />
// ),
header: () => (
<AppHeader
title="Edit Diskusi"
@@ -161,9 +133,7 @@ export default function DiscussionDivisionEdit() {
<ButtonSaveHeader
disable={data == "" || loading}
category="update"
onPress={() => {
handleUpdate();
}}
onPress={() => handleUpdate()}
/>
}
/>
@@ -171,8 +141,8 @@ export default function DiscussionDivisionEdit() {
}}
/>
{loading && <LoadingCenter />}
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100]}>
<View style={[Styles.p15]}>
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100, Styles.flex1, { backgroundColor: colors.background }]}>
<View style={Styles.p15}>
<InputForm
label="Diskusi"
type="default"
@@ -184,57 +154,87 @@ export default function DiscussionDivisionEdit() {
bg={colors.card}
/>
<ButtonSelect value="Upload File" onPress={pickDocumentAsync} />
{
(fileForm.length > 0 || dataFile.filter((val) => !val.delete).length > 0)
&&
<>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>File</Text>
<Text style={[Styles.textDefault]}>{fileForm.length + dataFile.filter((val) => !val.delete).length} file</Text>
{/* File */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={pickDocumentAsync}
style={[Styles.sectionActionRow, { marginBottom: totalFiles > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.icon + '15' }]}>
<MaterialCommunityIcons name="paperclip" size={18} color={colors.dimmed} />
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, Styles.mb10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
dataFile.filter((val) => !val.delete).map((item, index) => (
<BorderBottomItem
key={index}
borderType={dataFile.filter((val) => !val.delete).length - 1 == index && fileForm.length == 0 ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name + '.' + item.extension}
titleWeight="normal"
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>File</Text>
{totalFiles === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Opsional ketuk untuk upload</Text>
)}
</View>
{totalFiles > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.dimmed + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{totalFiles} file</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{totalFiles > 0 && (
<View style={Styles.fileGrid}>
{visibleOldFiles.map((item, index) => {
const ext = item.extension.toLowerCase()
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={`old-${index}`}
onPress={() => { setIndexDelFile({ id: item.id, cat: "oldFile" }); setModalFile(true) }}
/>
))
}
{
fileForm.map((item, index) => (
<BorderBottomItem
key={index}
borderType={fileForm.length - 1 == index ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name}
titleWeight="normal"
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{item.name}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
{fileForm.map((item, index) => {
const ext = item.name.split('.').pop()?.toLowerCase() ?? ''
const baseName = item.name.includes('.') ? item.name.split('.').slice(0, -1).join('.') : item.name
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={`new-${index}`}
onPress={() => { setIndexDelFile({ id: index, cat: "newFile" }); setModalFile(true) }}
/>
))
}
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{baseName}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
</View>
</>
}
)}
</View>
</View>
</ScrollView>
<DrawerBottom animation="slide" isVisible={isModalFile} setVisible={setModalFile} title="Menu">
<View style={Styles.rowItemsCenter}>
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus"
onPress={() => { deleteFile(indexDelFile.id, indexDelFile.cat) }}
onPress={() => deleteFile(indexDelFile.id, indexDelFile.cat)}
/>
</View>
</DrawerBottom>
</SafeAreaView>
);
}

View File

@@ -1,7 +1,5 @@
import AppHeader from "@/components/AppHeader"
import BorderBottomItem from "@/components/borderBottomItem"
import ButtonSaveHeader from "@/components/buttonSaveHeader"
import ButtonSelect from "@/components/buttonSelect"
import DrawerBottom from "@/components/drawerBottom"
import { InputForm } from "@/components/inputForm"
import LoadingCenter from "@/components/loadingCenter"
@@ -16,10 +14,29 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"
import * as DocumentPicker from "expo-document-picker"
import { router, Stack, useLocalSearchParams } from "expo-router"
import { useState } from "react"
import { SafeAreaView, ScrollView, View } from "react-native"
import { Pressable, SafeAreaView, ScrollView, View } from "react-native"
import Toast from "react-native-toast-message"
import { useDispatch, useSelector } from "react-redux"
function getFileIcon(ext: string): keyof typeof MaterialCommunityIcons.glyphMap {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return 'image-outline'
if (ext === 'pdf') return 'file-pdf-box'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline'
if (['doc', 'docx'].includes(ext)) return 'file-word-outline'
if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline'
if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline'
return 'file-outline'
}
function getFileColor(ext: string): string {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return '#339AF0'
if (ext === 'pdf') return '#F03E3E'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return '#AE3EC9'
if (['doc', 'docx'].includes(ext)) return '#1C7ED6'
if (['xls', 'xlsx'].includes(ext)) return '#2F9E44'
if (['zip', 'rar', '7z'].includes(ext)) return '#E8590C'
return '#868E96'
}
export default function CreateDiscussionDivision() {
const { colors } = useTheme();
@@ -34,76 +51,55 @@ export default function CreateDiscussionDivision() {
const [indexDelFile, setIndexDelFile] = useState<number>(0)
const pickDocumentAsync = async () => {
let result = await DocumentPicker.getDocumentAsync({
type: ["*/*"],
multiple: true
});
const result = await DocumentPicker.getDocumentAsync({ type: ["*/*"], multiple: true });
if (!result.canceled) {
for (let i = 0; i < result.assets?.length; i++) {
if (result.assets[i].uri) {
setFileForm((prev) => [...prev, result.assets[i]])
let skipped = 0
for (const asset of result.assets) {
if (!asset.uri) continue
if (fileForm.some(f => f.name === asset.name)) {
skipped++
} else {
setFileForm(prev => [...prev, asset])
}
}
if (skipped > 0) Toast.show({ type: 'small', text1: 'Beberapa file sudah ditambahkan' })
}
};
function deleteFile(index: number) {
setFileForm([...fileForm.filter((val, i) => i !== index)])
setFileForm(fileForm.filter((_, i) => i !== index))
setModalFile(false)
}
async function handleCreate() {
try {
setLoading(true)
const hasil = await decryptToken(String(token?.current))
const fd = new FormData()
for (let i = 0; i < fileForm.length; i++) {
fd.append(`file${i}`, {
uri: fileForm[i].uri,
type: 'application/octet-stream',
name: fileForm[i].name,
} as any);
fd.append(`file${i}`, { uri: fileForm[i].uri, type: 'application/octet-stream', name: fileForm[i].name } as any);
}
fd.append("data", JSON.stringify(
{ user: hasil, desc, idDivision: id }
))
fd.append("data", JSON.stringify({ user: hasil, desc, idDivision: id }))
const response = await apiCreateDiscussion(fd)
// const response = await apiCreateDiscussion({ data: { user: hasil, desc, idDivision: id } })
if (response.success) {
Toast.show({ type: 'small', text1: 'Berhasil menambahkan data', })
Toast.show({ type: 'small', text1: 'Berhasil menambahkan data' })
dispatch(setUpdateDiscussion({ ...update, data: !update.data }));
router.back()
} else {
Toast.show({ type: 'small', text1: response.message, })
Toast.show({ type: 'small', text1: response.message })
}
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal menambahkan data"
Toast.show({ type: 'small', text1: message })
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menambahkan data" })
} finally {
setLoading(false)
}
}
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
headerTitle: 'Tambah Diskusi',
headerTitleAlign: 'center',
// headerRight: () => <ButtonSaveHeader
// disable={desc == "" || loading}
// category="create"
// onPress={() => {
// handleCreate()
// }} />
header: () => (
<AppHeader
title="Tambah Diskusi"
@@ -113,16 +109,15 @@ export default function CreateDiscussionDivision() {
<ButtonSaveHeader
disable={desc == "" || loading}
category="create"
onPress={() => {
handleCreate()
}} />
onPress={() => handleCreate()}
/>
}
/>
)
}}
/>
{loading && <LoadingCenter />}
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100]}>
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100, Styles.flex1, { backgroundColor: colors.background }]}>
<View style={[Styles.p15, Styles.mb100]}>
<InputForm
label="Diskusi"
@@ -133,32 +128,56 @@ export default function CreateDiscussionDivision() {
multiline
bg={colors.card}
/>
<ButtonSelect value="Upload File" onPress={pickDocumentAsync} />
{
fileForm.length > 0
&&
<>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>File</Text>
<Text style={[Styles.textDefault]}>{fileForm.length} file</Text>
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, Styles.mb10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
fileForm.map((item, index) => (
<BorderBottomItem
key={index}
borderType={fileForm.length - 1 == index ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name}
titleWeight="normal"
onPress={() => { setIndexDelFile(index); setModalFile(true) }}
/>
))
}
</View>
</>
}
{/* File */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={pickDocumentAsync}
style={[Styles.sectionActionRow, { marginBottom: fileForm.length > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.icon + '15' }]}>
<MaterialCommunityIcons name="paperclip" size={18} color={colors.dimmed} />
</View>
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>File</Text>
{fileForm.length === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Opsional ketuk untuk upload</Text>
)}
</View>
{fileForm.length > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.dimmed + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{fileForm.length} file</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{fileForm.length > 0 && (
<View style={Styles.fileGrid}>
{fileForm.map((item, index) => {
const ext = item.name.split('.').pop()?.toLowerCase() ?? ''
const baseName = item.name.includes('.') ? item.name.split('.').slice(0, -1).join('.') : item.name
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={index}
onPress={() => { setIndexDelFile(index); setModalFile(true) }}
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{baseName}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
</View>
)}
</View>
</View>
</ScrollView>
@@ -167,10 +186,10 @@ export default function CreateDiscussionDivision() {
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus"
onPress={() => { deleteFile(indexDelFile) }}
onPress={() => deleteFile(indexDelFile)}
/>
</View>
</DrawerBottom>
</SafeAreaView>
)
}
}

View File

@@ -98,11 +98,23 @@ export default function DiscussionDivision() {
const isOpen = (item: Props) => item.status === 1
const themed = {
background: { backgroundColor: colors.background },
card: { backgroundColor: colors.card, borderColor: colors.icon + '20' },
cardPressed: { backgroundColor: colors.icon + '10' },
title: { color: colors.text },
dimmed: { color: colors.dimmed },
statusOpen: { borderColor: '#10B981' as const },
statusClosed: { borderColor: colors.dimmed + '80' },
statusTextOpen: { color: '#10B981' as const },
statusTextClosed: { color: colors.dimmed },
}
return (
<View style={[Styles.flex1, { backgroundColor: colors.background }]}>
<View style={[Styles.flex1, themed.background]}>
<GuideOverlay visible={guideVisible} steps={GUIDE_DIVISION_DISCUSSION} onDismiss={dismissGuide} />
{((entityUser.role != "user" && entityUser.role != "coadmin") || isAdminDivision) && (
<View style={[Styles.ph15, { paddingTop: 12 }]}>
<View style={[Styles.ph15, Styles.discussionHeaderPadding]}>
<WrapTab>
<ButtonTab
active={status == "false" ? "false" : "true"}
@@ -125,13 +137,13 @@ export default function DiscussionDivision() {
</View>
)}
<View style={[Styles.flex1, Styles.ph15, { paddingTop: 8 }]}>
<View style={[Styles.flex1, Styles.ph15, Styles.discussionListPadding]}>
{loading ? (
arrSkeleton.map((_, i) => <SkeletonContent key={i} />)
) : data.length === 0 ? (
<View style={[Styles.contentItemCenter, Styles.mt30]}>
<Feather name="message-circle" size={42} color={colors.icon + '40'} />
<Text style={[Styles.mt10, { color: colors.dimmed, fontSize: 14 }]}>
<Text style={[Styles.mt10, Styles.discussionEmptyText, themed.dimmed]}>
Tidak ada diskusi
</Text>
</View>
@@ -151,18 +163,19 @@ export default function DiscussionDivision() {
onPress={() => router.push(`./discussion/${item.id}`)}
style={({ pressed }) => [
Styles.discussionCard,
{ backgroundColor: pressed ? colors.icon + '10' : colors.card, borderColor: colors.icon + '20' }
themed.card,
pressed && themed.cardPressed,
]}
>
<View style={[Styles.rowItemsCenter, Styles.mb08]}>
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
<View style={[Styles.flex1, { marginLeft: 10 }]}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]} numberOfLines={1}>
<View style={[Styles.flex1, Styles.discussionTitleCol]}>
<Text style={[Styles.textDefaultSemiBold, themed.title]} numberOfLines={1}>
{item.user_name}
</Text>
{status === "true" && (
<View style={[Styles.discussionStatusPill, { borderColor: isOpen(item) ? '#10B981' : colors.dimmed + '80' }]}>
<Text style={[Styles.discussionStatusText, { color: isOpen(item) ? '#10B981' : colors.dimmed }]}>
<View style={[Styles.discussionStatusPill, isOpen(item) ? themed.statusOpen : themed.statusClosed]}>
<Text style={[Styles.discussionStatusText, isOpen(item) ? themed.statusTextOpen : themed.statusTextClosed]}>
{isOpen(item) ? 'Buka' : 'Tutup'}
</Text>
</View>
@@ -171,7 +184,7 @@ export default function DiscussionDivision() {
</View>
{item.desc ? (
<Text style={[Styles.textMediumNormal, Styles.discussionCardIndent, { color: colors.dimmed, marginBottom: 10 }]} numberOfLines={2}>
<Text style={[Styles.textMediumNormal, Styles.discussionCardIndent, Styles.discussionDescMargin, themed.title]} numberOfLines={2}>
{item.desc}
</Text>
) : null}
@@ -179,11 +192,11 @@ export default function DiscussionDivision() {
<View style={[Styles.rowItemsCenter, Styles.rowSpaceBetween, Styles.discussionCardIndent]}>
<View style={Styles.rowItemsCenter}>
<Feather name="message-square" size={14} color={colors.dimmed} />
<Text style={[Styles.discussionCommentText, { color: colors.dimmed }]}>
<Text style={[Styles.discussionCommentText, themed.dimmed]}>
{item.total_komentar} Komentar
</Text>
</View>
<Text style={[Styles.discussionDateText, { color: colors.dimmed }]}>
<Text style={[Styles.discussionDateText, themed.dimmed]}>
{item.createdAt}
</Text>
</View>

View File

@@ -117,7 +117,7 @@ export default function CreateDivisionAddAdmin() {
return (
<Pressable
key={index}
style={[Styles.itemSelectModal]}
style={[Styles.itemSelectModal, { borderBottomColor: colors.icon + '20' }]}
onPress={() => {
!found && onChoose(item.idUser)
}}

View File

@@ -121,7 +121,7 @@ export default function CreateDivisionAddMember() {
return (
<Pressable
key={index}
style={[Styles.itemSelectModal]}
style={[Styles.itemSelectModal, { borderBottomColor: colors.icon + '20' }]}
onPress={() => {
!found && onChoose(item.id, item.name, item.img)
}}

View File

@@ -206,7 +206,7 @@ export default function ListDivision() {
</Pressable>
</View>
{(entityUser.role == "supadmin" || entityUser.role == "developer") && (
<View style={[Styles.mv05, Styles.rowOnly]}>
<View style={[Styles.mt10, Styles.rowOnly]}>
<Text>Filter :</Text>
<LabelStatus size="small" category="secondary" text={nameGroup} style={[Styles.mh05]} />
</View>

View File

@@ -142,7 +142,7 @@ export default function Index() {
<InputSearch onChange={setSearch} />
{
(entityUser.role == "supadmin" || entityUser.role == "developer") &&
<View style={[Styles.mv05, Styles.rowOnly]}>
<View style={[Styles.mt10, Styles.rowOnly]}>
<Text>Filter :</Text>
<LabelStatus size="small" category="secondary" text={nameGroup} style={[Styles.mh05]} />
</View>

View File

@@ -185,7 +185,7 @@ export default function Index() {
<InputSearch onChange={setSearch} />
{
(entityUser.role == "supadmin" || entityUser.role == "developer") &&
<View style={[Styles.mv05, Styles.rowOnly]}>
<View style={[Styles.mt10, Styles.rowOnly]}>
<Text>Filter :</Text>
<LabelStatus size="small" category="secondary" text={nameGroup} style={[Styles.mh05]} />
</View>

View File

@@ -1,4 +1,5 @@
import Styles from "@/constants/Styles";
import { useTheme } from "@/providers/ThemeProvider";
import { useState } from "react";
import { Image } from "react-native";
@@ -9,12 +10,16 @@ type Props = {
onError?: (val:boolean) => void
}
export default function ImageUser({ src, size, onError }: Props) {
export default function ImageUser({ src, size, border, onError }: Props) {
const [error, setError] = useState(false)
const { colors } = useTheme()
return (
<Image
source={error ? require('../assets/images/user.jpg') : { uri: src }}
style={[size == 'xs' ? Styles.userProfileExtraSmall : size == 'lg' ? Styles.userProfileBig : Styles.userProfileSmall, Styles.borderAll]}
style={[
size == 'xs' ? Styles.userProfileExtraSmall : size == 'lg' ? Styles.userProfileBig : Styles.userProfileSmall,
border && { borderWidth: 1, borderColor: colors.icon + '40', borderRadius: 100 }
]}
onError={() => {
setError(true)
onError?.(true)

View File

@@ -0,0 +1,39 @@
import { StyleSheet } from "react-native";
const AnnouncementStyles = StyleSheet.create({
// list (index.tsx)
announcementListContainer: { padding: 15, paddingBottom: 0 },
announcementListInner: { marginTop: 10 },
announcementListCard: { borderRadius: 10, borderWidth: 1, padding: 12 },
announcementListCardHeader: { marginBottom: 6 },
announcementListTitleRow: { flex: 1, gap: 8, marginRight: 8, flexDirection: 'row', alignItems: 'center' },
announcementListIconBox: { width: 28, height: 28, borderRadius: 8, alignItems: 'center', justifyContent: 'center' },
announcementListTitleText: { flex: 1 },
announcementListDateText: { flexShrink: 0 },
announcementListDescText: { lineHeight: 20 },
announcementListSeparator: { height: 8 },
announcementListSkeletonCard: { gap: 8 },
announcementListSkeletonHeader: { marginBottom: 4 },
announcementListSkeletonTitleRow: { gap: 8, flex: 1, flexDirection: 'row', alignItems: 'center' },
// detail ([id].tsx)
announcementDetailContainer: { padding: 15, paddingBottom: 50, gap: 12 },
announcementDetailCard: { borderRadius: 10 },
announcementDetailSkeletonGap: { gap: 8 },
announcementDetailSkeletonIconRow: { gap: 10, marginBottom: 2 },
announcementDetailTitleRow: { gap: 10, marginBottom: 10 },
announcementDetailIconBox: { width: 38, height: 38, borderRadius: 10 },
announcementDetailTitleText: { fontSize: 17, lineHeight: 24, flex: 1 },
announcementDetailSectionLabelRow: { marginBottom: 8, gap: 6 },
announcementDetailFileCardPadding: { padding: 10 },
announcementDetailFileChipList: { gap: 8 },
announcementDetailFileChip: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingHorizontal: 12, paddingVertical: 8, borderRadius: 20, borderWidth: 1 },
announcementDetailFileChipText: { maxWidth: 120 },
announcementDetailRecipientGap: { gap: 10 },
announcementDetailGroupSeparator: { marginTop: 12, paddingTop: 12, borderTopWidth: 1 },
announcementDetailGroupLabel: { marginBottom: 6 },
announcementDetailDivisionRow: { gap: 8, paddingVertical: 6 },
announcementDetailDivisionIconCircle: { width: 26, height: 26, borderRadius: 13, alignItems: 'center', justifyContent: 'center' },
});
export default AnnouncementStyles;

View File

@@ -37,8 +37,8 @@ const ButtonStyles = StyleSheet.create({
padding: 5,
borderWidth: 1,
},
labelStatus: { paddingHorizontal: 15, borderRadius: 10 },
labelStatusSmall: { paddingHorizontal: 10, borderRadius: 10 },
labelStatus: { paddingHorizontal: 15, paddingVertical: 4, borderRadius: 10 },
labelStatusSmall: { paddingHorizontal: 10, paddingVertical: 3, borderRadius: 10 },
});
export default ButtonStyles;

View File

@@ -86,6 +86,11 @@ const ComponentStyles = StyleSheet.create({
discussionDateText: { fontSize: 11 },
discussionCommentCard: { borderRadius: 10, borderWidth: 1, padding: 12, marginBottom: 8, flexDirection: 'row' },
discussionEditedText: { fontSize: 10, fontStyle: 'italic' },
discussionHeaderPadding: { paddingTop: 12 },
discussionListPadding: { paddingTop: 8 },
discussionTitleCol: { marginLeft: 10 },
discussionDescMargin: { marginBottom: 10 },
discussionEmptyText: { fontSize: 14 },
// guide overlay
guideOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', alignItems: 'center' },

View File

@@ -11,6 +11,7 @@ import HeaderStyles from './header.styles';
import ComponentStyles from './component.styles';
import NotificationStyles from './notification.styles';
import ApprovalStyles from './approval.styles';
import AnnouncementStyles from './announcement.styles';
const Styles = StyleSheet.create({
...SpacingStyles,
@@ -25,6 +26,7 @@ const Styles = StyleSheet.create({
...ComponentStyles,
...NotificationStyles,
...ApprovalStyles,
...AnnouncementStyles,
});
export default Styles;