diff --git a/app/(application)/division/[id]/(fitur-division)/task/[detail]/index.tsx b/app/(application)/division/[id]/(fitur-division)/task/[detail]/index.tsx index 9a27a0b..029b676 100644 --- a/app/(application)/division/[id]/(fitur-division)/task/[detail]/index.tsx +++ b/app/(application)/division/[id]/(fitur-division)/task/[detail]/index.tsx @@ -32,6 +32,7 @@ export default function DetailTaskDivision() { const [data, setData] = useState() const [loading, setLoading] = useState(true) const [progress, setProgress] = useState(0) + const [taskStats, setTaskStats] = useState<{ done: number, total: number } | undefined>() const update = useSelector((state: any) => state.taskUpdate) const [refreshing, setRefreshing] = useState(false) const [isMemberDivision, setIsMemberDivision] = useState(false); @@ -65,6 +66,17 @@ export default function DetailTaskDivision() { }, []) + async function handleLoadTaskStats() { + try { + const hasil = await decryptToken(String(token?.current)) + const response = await apiGetTaskOne({ id: detail, user: hasil, cat: 'task' }) + const tasks: { status: number }[] = response.data + setTaskStats({ done: tasks.filter(t => t.status === 1).length, total: tasks.length }) + } catch (error) { + console.error(error) + } + } + async function handleLoad(cat: 'data' | 'progress') { try { if (cat == 'data') setLoading(true) @@ -90,10 +102,15 @@ export default function DetailTaskDivision() { handleLoad('progress') }, [update.progress]) + useEffect(() => { + handleLoadTaskStats() + }, [update.task]) + const handleRefresh = async () => { setRefreshing(true) await handleLoad('data') await handleLoad('progress') + await handleLoadTaskStats() await new Promise(resolve => setTimeout(resolve, 2000)); setRefreshing(false) }; @@ -135,7 +152,7 @@ export default function DetailTaskDivision() { { data?.reason != null && data?.reason != "" && } - + diff --git a/app/(application)/division/[id]/(fitur-division)/task/[detail]/tugas-file/[taskId].tsx b/app/(application)/division/[id]/(fitur-division)/task/[detail]/tugas-file/[taskId].tsx new file mode 100644 index 0000000..2112500 --- /dev/null +++ b/app/(application)/division/[id]/(fitur-division)/task/[detail]/tugas-file/[taskId].tsx @@ -0,0 +1,382 @@ +import AppHeader from "@/components/AppHeader"; +import BorderBottomItem from "@/components/borderBottomItem"; +import { ButtonForm } from "@/components/buttonForm"; +import ButtonSelect from "@/components/buttonSelect"; +import DrawerBottom from "@/components/drawerBottom"; +import ModalConfirmation from "@/components/ModalConfirmation"; +import ModalLoading from "@/components/modalLoading"; +import MenuItemRow from "@/components/menuItemRow"; +import Skeleton from "@/components/skeleton"; +import Text from "@/components/Text"; +import { ConstEnv } from "@/constants/ConstEnv"; +import Styles from "@/constants/Styles"; +import { + apiAddTugasTaskFile, + apiDeleteTugasTaskFile, + apiGetTaskOne, + apiGetTugasTaskFile, + apiLinkTugasTaskFile, +} from "@/lib/api"; +import { setUpdateTask } from "@/lib/taskUpdate"; +import { useAuthSession } from "@/providers/AuthProvider"; +import { useTheme } from "@/providers/ThemeProvider"; +import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; +import * as DocumentPicker from "expo-document-picker"; +import * as FileSystem from "expo-file-system"; +import { startActivityAsync } from "expo-intent-launcher"; +import { router, Stack, useLocalSearchParams } from "expo-router"; +import * as Sharing from "expo-sharing"; +import { useEffect, useState } from "react"; +import { + ActivityIndicator, + Alert, + Platform, + SafeAreaView, + ScrollView, + View, +} from "react-native"; +import * as mime from "react-native-mime-types"; +import Toast from "react-native-toast-message"; +import { useDispatch, useSelector } from "react-redux"; + +type FileItem = { + id: string; // DivisionProjectTaskFile.id + idFile: string; // DivisionProjectFile.id + name: string; + extension: string; + idStorage: string; +}; + +type ProjectFile = { + id: string; + name: string; + extension: string; + idStorage: string; +}; + +export default function TugasFileScreen() { + const { colors } = useTheme(); + const { id, detail, taskId, member: memberParam } = useLocalSearchParams<{ + id: string; + detail: string; + taskId: string; + member: string; + }>(); + const { token, decryptToken } = useAuthSession(); + const dispatch = useDispatch(); + const update = useSelector((state: any) => state.taskUpdate); + const entityUser = useSelector((state: any) => state.user); + const isMember = memberParam === "true"; + const canEdit = isMember || (entityUser.role !== "user" && entityUser.role !== "coadmin"); + + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingOpen, setLoadingOpen] = useState(false); + const [loadingUpload, setLoadingUpload] = useState(false); + const [loadingLink, setLoadingLink] = useState(false); + + const [selectFile, setSelectFile] = useState(null); + const [isMenuModal, setMenuModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + + const [projectFiles, setProjectFiles] = useState([]); + const [isPickerModal, setPickerModal] = useState(false); + const [loadingProjectFiles, setLoadingProjectFiles] = useState(false); + const [selectedProjectFiles, setSelectedProjectFiles] = useState([]); + + const arrSkeleton = Array.from({ length: 4 }); + + async function loadFiles() { + try { + setLoading(true); + const hasil = await decryptToken(String(token?.current)); + const response = await apiGetTugasTaskFile({ user: hasil, id: taskId }); + setData(response.data ?? []); + } catch (error) { + console.error(error); + } finally { + setLoading(false); + } + } + + async function loadProjectFiles() { + try { + setLoadingProjectFiles(true); + const hasil = await decryptToken(String(token?.current)); + const response = await apiGetTaskOne({ id: detail, user: hasil, cat: "file" }); + setProjectFiles(response.data ?? []); + } catch (error) { + console.error(error); + } finally { + setLoadingProjectFiles(false); + } + } + + useEffect(() => { + loadFiles(); + }, []); + + const openFile = () => { + setMenuModal(false); + setLoadingOpen(true); + const remoteUrl = ConstEnv.url_storage + "/files/" + selectFile?.idStorage; + const fileName = selectFile?.name + "." + selectFile?.extension; + const localPath = `${FileSystem.documentDirectory}/${fileName}`; + const mimeType = mime.lookup(fileName); + + FileSystem.downloadAsync(remoteUrl, localPath).then(async ({ uri }) => { + const contentURL = await FileSystem.getContentUriAsync(uri); + try { + if (Platform.OS === "android") { + await startActivityAsync("android.intent.action.VIEW", { + data: contentURL, + flags: 1, + type: mimeType as string, + }); + } else { + Sharing.shareAsync(localPath); + } + } catch { + Alert.alert("INFO", "Gagal membuka file, tidak ada aplikasi yang dapat membuka file ini"); + } finally { + setLoadingOpen(false); + } + }); + }; + + async function handleDelete() { + try { + const hasil = await decryptToken(String(token?.current)); + const response = await apiDeleteTugasTaskFile({ user: hasil }, String(selectFile?.id)); + if (response.success) { + Toast.show({ type: "small", text1: "Berhasil menghapus file" }); + dispatch(setUpdateTask({ ...update, task: !update.task })); + loadFiles(); + } else { + Toast.show({ type: "small", text1: response.message }); + } + } catch (error: any) { + const message = error?.response?.data?.message || "Gagal menghapus file"; + Toast.show({ type: "small", text1: message }); + } finally { + setMenuModal(false); + } + } + + async function handleUpload() { + const result = await DocumentPicker.getDocumentAsync({ type: ["*/*"], multiple: true }); + if (result.canceled) return; + + try { + setLoadingUpload(true); + const hasil = await decryptToken(String(token?.current)); + const fd = new FormData(); + + for (let i = 0; i < result.assets.length; i++) { + fd.append(`file${i}`, { + uri: result.assets[i].uri, + type: "application/octet-stream", + name: result.assets[i].name, + } as any); + } + fd.append("data", JSON.stringify({ user: hasil })); + + const response = await apiAddTugasTaskFile({ data: fd, id: taskId }); + if (response.success) { + Toast.show({ type: "small", text1: "Berhasil menambahkan file" }); + dispatch(setUpdateTask({ ...update, task: !update.task })); + loadFiles(); + } else { + Toast.show({ type: "small", text1: response.message }); + } + } catch (error: any) { + const message = error?.response?.data?.message || "Gagal menambahkan file"; + Toast.show({ type: "small", text1: message }); + } finally { + setLoadingUpload(false); + } + } + + function toggleProjectFileSelect(id: string) { + setSelectedProjectFiles((prev) => + prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id] + ); + } + + async function handleLinkFiles() { + if (selectedProjectFiles.length === 0) return; + try { + setLoadingLink(true); + const hasil = await decryptToken(String(token?.current)); + for (const idFile of selectedProjectFiles) { + await apiLinkTugasTaskFile({ user: hasil, idFile, id: taskId }); + } + Toast.show({ type: "small", text1: "Berhasil menambahkan file" }); + dispatch(setUpdateTask({ ...update, task: !update.task })); + setPickerModal(false); + setSelectedProjectFiles([]); + loadFiles(); + } catch (error: any) { + const message = error?.response?.data?.message || "Gagal menambahkan file"; + Toast.show({ type: "small", text1: message }); + } finally { + setLoadingLink(false); + } + } + + const attachedFileIds = new Set(data.map((f) => f.idFile)); + + return ( + + ( + router.back()} + /> + ), + }} + /> + + + + + {canEdit && ( + <> + + { + setSelectedProjectFiles([]); + setPickerModal(true); + loadProjectFiles(); + }} + /> + + )} + + {loadingUpload && } + + + File Terlampir + + {loading ? ( + arrSkeleton.map((_, index) => ( + + )) + ) : data.length > 0 ? ( + data.map((item, index) => ( + } + title={item.name + "." + item.extension} + titleWeight="normal" + onPress={() => { + setSelectFile(item); + setMenuModal(true); + }} + /> + )) + ) : ( + + Tidak ada file + + )} + + + + + + {/* Menu per file */} + + + } + title="Lihat / Share" + onPress={openFile} + /> + {canEdit && ( + } + title="Hapus" + onPress={() => { + setMenuModal(false); + setTimeout(() => setShowDeleteModal(true), 600); + }} + /> + )} + + + + { + setShowDeleteModal(false); + handleDelete(); + }} + onCancel={() => setShowDeleteModal(false)} + confirmText="Hapus" + cancelText="Batal" + /> + + {/* Picker file dari proyek */} + + + {loadingProjectFiles ? ( + + ) : projectFiles.length > 0 ? ( + projectFiles.map((item, index) => { + const isAttached = attachedFileIds.has(item.id); + const isSelected = selectedProjectFiles.includes(item.id); + return ( + + + ) : ( + + ) + } + title={item.name + "." + item.extension} + titleWeight="normal" + onPress={() => !isAttached && toggleProjectFileSelect(item.id)} + bgColor="transparent" + /> + + ); + }) + ) : ( + + Tidak ada file tersedia + + )} + + {projectFiles.length > 0 && ( + + + + )} + + + ); +} diff --git a/app/(application)/division/[id]/(fitur-division)/task/create.tsx b/app/(application)/division/[id]/(fitur-division)/task/create.tsx index f6d2b34..ecb12cc 100644 --- a/app/(application)/division/[id]/(fitur-division)/task/create.tsx +++ b/app/(application)/division/[id]/(fitur-division)/task/create.tsx @@ -1,7 +1,5 @@ import AppHeader from "@/components/AppHeader"; -import BorderBottomItem from "@/components/borderBottomItem"; import ButtonSaveHeader from "@/components/buttonSaveHeader"; -import ButtonSelect from "@/components/buttonSelect"; import DrawerBottom from "@/components/drawerBottom"; import ImageUser from "@/components/imageNew"; import { InputForm } from "@/components/inputForm"; @@ -21,11 +19,31 @@ 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 CreateTaskDivision() { const { colors } = useTheme(); const { id } = useLocalSearchParams(); @@ -168,59 +186,131 @@ export default function CreateTaskDivision() { bg={colors.card} errorText="Judul Tugas tidak boleh kosong" /> - { router.push(`/division/${id}/task/create/task`); }} /> - - { router.push(`/division/${id}/task/create/member`); }} /> - - { - fileForm.length > 0 && ( - - File - - { - fileForm.map((item, index) => ( - } - title={item.name} - titleWeight="normal" - onPress={() => { setIndexDelFile(index); setModal(true) }} - /> - )) - } - - - ) - } - {entitiesMember.length > 0 && ( - - - Anggota - Total {entitiesMember.length} Anggota - - - {entitiesMember.map( - (item: { img: any; name: any }, index: any) => { - return ( - - } - title={item.name} - /> - ); - } + {/* Tanggal & Tugas */} + + router.push(`/division/${id}/task/create/task`)} + style={[Styles.sectionActionRow, { marginBottom: taskCreate.length > 0 ? 12 : 0 }]} + > + + + + + Tanggal & Tugas + {taskCreate.length === 0 && ( + Belum ada tugas ditambahkan )} - - )} + {taskCreate.length > 0 && ( + + {taskCreate.length} tugas + + )} + + + {taskCreate.length > 0 && } + + + {/* File */} + + 0 ? 12 : 0 }]} + > + + + + + File + {fileForm.length === 0 && ( + Opsional — ketuk untuk upload + )} + + {fileForm.length > 0 && ( + + {fileForm.length} file + + )} + + + {fileForm.length > 0 && ( + + {fileForm.map((item, index) => { + const ext = item.name.split('.').pop()?.toLowerCase() ?? '' + const baseName = item.name.includes('.') ? item.name.split('.').slice(0, -1).join('.') : item.name + const iconName = getFileIcon(ext) + const iconColor = getFileColor(ext) + return ( + { setIndexDelFile(index); setModal(true) }} + style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]} + > + + + + + {baseName} + {ext.toUpperCase()} + + + ) + })} + + )} + + + {/* Anggota */} + + router.push(`/division/${id}/task/create/member`)} + style={[Styles.sectionActionRow, { marginBottom: entitiesMember.length > 0 ? 12 : 0 }]} + > + + + + + Anggota + {entitiesMember.length === 0 && ( + Belum ada anggota dipilih + )} + + {entitiesMember.length > 0 && ( + + {entitiesMember.length} orang + + )} + + + {entitiesMember.length > 0 && ( + + {entitiesMember.map((item: { img: any; name: any; position?: string }, index: any) => ( + + + {item.name} + {item.position && ( + + {item.position} + + )} + + ))} + + )} + diff --git a/app/(application)/project/[id]/index.tsx b/app/(application)/project/[id]/index.tsx index 80698e6..095e86c 100644 --- a/app/(application)/project/[id]/index.tsx +++ b/app/(application)/project/[id]/index.tsx @@ -37,6 +37,7 @@ export default function DetailProject() { const { id } = useLocalSearchParams<{ id: string }>(); const [data, setData] = useState() const [progress, setProgress] = useState(0) + const [taskStats, setTaskStats] = useState<{ done: number, total: number } | undefined>() const [loading, setLoading] = useState(true) const update = useSelector((state: any) => state.projectUpdate) const [isMember, setIsMember] = useState(false) @@ -60,6 +61,17 @@ export default function DetailProject() { } } + async function handleLoadTaskStats() { + try { + const hasil = await decryptToken(String(token?.current)) + const response = await apiGetProjectOne({ user: hasil, cat: 'task', id: id }) + const tasks: { status: number }[] = response.data + setTaskStats({ done: tasks.filter(t => t.status === 1).length, total: tasks.length }) + } catch (error) { + console.error(error) + } + } + async function checkMember() { try { const hasil = await decryptToken(String(token?.current)) @@ -79,6 +91,10 @@ export default function DetailProject() { handleLoad('progress') }, [update.progress]) + useEffect(() => { + handleLoadTaskStats() + }, [update.task]) + useEffect(() => { checkMember() }, []) @@ -88,6 +104,7 @@ export default function DetailProject() { setRefreshing(true) await handleLoad('data') await handleLoad('progress') + await handleLoadTaskStats() await new Promise(resolve => setTimeout(resolve, 2000)); setRefreshing(false) }; @@ -126,7 +143,7 @@ export default function DetailProject() { { data?.reason != null && data?.reason != "" && } - + diff --git a/app/(application)/project/[id]/tugas-file/[taskId].tsx b/app/(application)/project/[id]/tugas-file/[taskId].tsx new file mode 100644 index 0000000..3f47532 --- /dev/null +++ b/app/(application)/project/[id]/tugas-file/[taskId].tsx @@ -0,0 +1,377 @@ +import AppHeader from "@/components/AppHeader"; +import BorderBottomItem from "@/components/borderBottomItem"; +import { ButtonForm } from "@/components/buttonForm"; +import ButtonSelect from "@/components/buttonSelect"; +import DrawerBottom from "@/components/drawerBottom"; +import MenuItemRow from "@/components/menuItemRow"; +import ModalConfirmation from "@/components/ModalConfirmation"; +import ModalLoading from "@/components/modalLoading"; +import Skeleton from "@/components/skeleton"; +import Text from "@/components/Text"; +import { ConstEnv } from "@/constants/ConstEnv"; +import Styles from "@/constants/Styles"; +import { + apiAddProjectTaskFile, + apiDeleteProjectTaskFile, + apiGetProjectOne, + apiGetProjectTaskFile, + apiLinkProjectTaskFile, +} from "@/lib/api"; +import { setUpdateProject } from "@/lib/projectUpdate"; +import { useAuthSession } from "@/providers/AuthProvider"; +import { useTheme } from "@/providers/ThemeProvider"; +import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; +import * as DocumentPicker from "expo-document-picker"; +import * as FileSystem from "expo-file-system"; +import { startActivityAsync } from "expo-intent-launcher"; +import { router, Stack, useLocalSearchParams } from "expo-router"; +import * as Sharing from "expo-sharing"; +import { useEffect, useState } from "react"; +import { + ActivityIndicator, + Alert, + Platform, + SafeAreaView, + ScrollView, + View, +} from "react-native"; +import * as mime from "react-native-mime-types"; +import Toast from "react-native-toast-message"; +import { useDispatch, useSelector } from "react-redux"; + +type FileItem = { + id: string; // ProjectTaskFile.id + idFile: string; // ProjectFile.id + name: string; + extension: string; + idStorage: string; +}; + +type ProjectFile = { + id: string; + name: string; + extension: string; + idStorage: string; +}; + +export default function ProjectTugasFileScreen() { + const { colors } = useTheme(); + const { id, taskId, member: memberParam } = useLocalSearchParams<{ id: string; taskId: string; member: string }>(); + const { token, decryptToken } = useAuthSession(); + const dispatch = useDispatch(); + const update = useSelector((state: any) => state.projectUpdate); + const entityUser = useSelector((state: any) => state.user); + const isMember = memberParam === "true"; + const canEdit = isMember || (entityUser.role !== "user" && entityUser.role !== "coadmin"); + + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingOpen, setLoadingOpen] = useState(false); + const [loadingUpload, setLoadingUpload] = useState(false); + const [loadingLink, setLoadingLink] = useState(false); + + const [selectFile, setSelectFile] = useState(null); + const [isMenuModal, setMenuModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + + const [projectFiles, setProjectFiles] = useState([]); + const [isPickerModal, setPickerModal] = useState(false); + const [loadingProjectFiles, setLoadingProjectFiles] = useState(false); + const [selectedProjectFiles, setSelectedProjectFiles] = useState([]); + + const arrSkeleton = Array.from({ length: 4 }); + + async function loadFiles() { + try { + setLoading(true); + const hasil = await decryptToken(String(token?.current)); + const response = await apiGetProjectTaskFile({ user: hasil, id: taskId }); + setData(response.data ?? []); + } catch (error) { + console.error(error); + } finally { + setLoading(false); + } + } + + async function loadProjectFiles() { + try { + setLoadingProjectFiles(true); + const hasil = await decryptToken(String(token?.current)); + const response = await apiGetProjectOne({ user: hasil, cat: "file", id }); + setProjectFiles(response.data ?? []); + } catch (error) { + console.error(error); + } finally { + setLoadingProjectFiles(false); + } + } + + useEffect(() => { + loadFiles(); + }, []); + + const openFile = () => { + setMenuModal(false); + setLoadingOpen(true); + const remoteUrl = ConstEnv.url_storage + "/files/" + selectFile?.idStorage; + const fileName = selectFile?.name + "." + selectFile?.extension; + const localPath = `${FileSystem.documentDirectory}/${fileName}`; + const mimeType = mime.lookup(fileName); + + FileSystem.downloadAsync(remoteUrl, localPath).then(async ({ uri }) => { + const contentURL = await FileSystem.getContentUriAsync(uri); + try { + if (Platform.OS === "android") { + await startActivityAsync("android.intent.action.VIEW", { + data: contentURL, + flags: 1, + type: mimeType as string, + }); + } else { + Sharing.shareAsync(localPath); + } + } catch { + Alert.alert("INFO", "Gagal membuka file, tidak ada aplikasi yang dapat membuka file ini"); + } finally { + setLoadingOpen(false); + } + }); + }; + + async function handleDelete() { + try { + const hasil = await decryptToken(String(token?.current)); + const response = await apiDeleteProjectTaskFile({ user: hasil }, String(selectFile?.id)); + if (response.success) { + Toast.show({ type: "small", text1: "Berhasil menghapus file" }); + dispatch(setUpdateProject({ ...update, task: !update.task })); + loadFiles(); + } else { + Toast.show({ type: "small", text1: response.message }); + } + } catch (error: any) { + const message = error?.response?.data?.message || "Gagal menghapus file"; + Toast.show({ type: "small", text1: message }); + } finally { + setMenuModal(false); + } + } + + async function handleUpload() { + const result = await DocumentPicker.getDocumentAsync({ type: ["*/*"], multiple: true }); + if (result.canceled) return; + + try { + setLoadingUpload(true); + const hasil = await decryptToken(String(token?.current)); + const fd = new FormData(); + + for (let i = 0; i < result.assets.length; i++) { + fd.append(`file${i}`, { + uri: result.assets[i].uri, + type: "application/octet-stream", + name: result.assets[i].name, + } as any); + } + fd.append("data", JSON.stringify({ user: hasil })); + + const response = await apiAddProjectTaskFile({ data: fd, id: taskId }); + if (response.success) { + Toast.show({ type: "small", text1: "Berhasil menambahkan file" }); + dispatch(setUpdateProject({ ...update, task: !update.task })); + loadFiles(); + } else { + Toast.show({ type: "small", text1: response.message }); + } + } catch (error: any) { + const message = error?.response?.data?.message || "Gagal menambahkan file"; + Toast.show({ type: "small", text1: message }); + } finally { + setLoadingUpload(false); + } + } + + function toggleProjectFileSelect(fileId: string) { + setSelectedProjectFiles((prev) => + prev.includes(fileId) ? prev.filter((v) => v !== fileId) : [...prev, fileId] + ); + } + + async function handleLinkFiles() { + if (selectedProjectFiles.length === 0) return; + try { + setLoadingLink(true); + const hasil = await decryptToken(String(token?.current)); + for (const idFile of selectedProjectFiles) { + await apiLinkProjectTaskFile({ user: hasil, idFile, id: taskId }); + } + Toast.show({ type: "small", text1: "Berhasil menambahkan file" }); + dispatch(setUpdateProject({ ...update, task: !update.task })); + setPickerModal(false); + setSelectedProjectFiles([]); + loadFiles(); + } catch (error: any) { + const message = error?.response?.data?.message || "Gagal menambahkan file"; + Toast.show({ type: "small", text1: message }); + } finally { + setLoadingLink(false); + } + } + + const attachedFileIds = new Set(data.map((f) => f.idFile)); + + return ( + + ( + router.back()} + /> + ), + }} + /> + + + + + {canEdit && ( + <> + + { + setSelectedProjectFiles([]); + setPickerModal(true); + loadProjectFiles(); + }} + /> + + )} + + {loadingUpload && } + + + File Terlampir + + {loading ? ( + arrSkeleton.map((_, index) => ( + + )) + ) : data.length > 0 ? ( + data.map((item, index) => ( + } + title={item.name + "." + item.extension} + titleWeight="normal" + onPress={() => { + setSelectFile(item); + setMenuModal(true); + }} + /> + )) + ) : ( + + Tidak ada file + + )} + + + + + + {/* Menu per file */} + + + } + title="Lihat / Share" + onPress={openFile} + /> + {canEdit && ( + } + title="Hapus" + onPress={() => { + setMenuModal(false); + setTimeout(() => setShowDeleteModal(true), 600); + }} + /> + )} + + + + { + setShowDeleteModal(false); + handleDelete(); + }} + onCancel={() => setShowDeleteModal(false)} + confirmText="Hapus" + cancelText="Batal" + /> + + {/* Picker file dari proyek */} + + + {loadingProjectFiles ? ( + + ) : projectFiles.length > 0 ? ( + projectFiles.map((item, index) => { + const isAttached = attachedFileIds.has(item.id); + const isSelected = selectedProjectFiles.includes(item.id); + return ( + + + ) : ( + + ) + } + title={item.name + "." + item.extension} + titleWeight="normal" + onPress={() => !isAttached && toggleProjectFileSelect(item.id)} + bgColor="transparent" + /> + + ); + }) + ) : ( + + Tidak ada file tersedia + + )} + + {projectFiles.length > 0 && ( + + + + )} + + + ); +} diff --git a/app/(application)/project/create.tsx b/app/(application)/project/create.tsx index 34de7be..9b74e04 100644 --- a/app/(application)/project/create.tsx +++ b/app/(application)/project/create.tsx @@ -1,7 +1,5 @@ import AppHeader from "@/components/AppHeader"; -import BorderBottomItem from "@/components/borderBottomItem"; import ButtonSaveHeader from "@/components/buttonSaveHeader"; -import ButtonSelect from "@/components/buttonSelect"; import DrawerBottom from "@/components/drawerBottom"; import ImageUser from "@/components/imageNew"; import { InputForm } from "@/components/inputForm"; @@ -25,6 +23,7 @@ import * as DocumentPicker from "expo-document-picker"; import { router, Stack } from "expo-router"; import { useEffect, useState } from "react"; import { + Pressable, SafeAreaView, ScrollView, View @@ -32,6 +31,26 @@ import { 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 CreateProject() { const { colors } = useTheme(); const [loading, setLoading] = useState(false) @@ -241,26 +260,23 @@ export default function CreateProject() { style={[Styles.h100, { backgroundColor: colors.background }]} > - { - (entityUser.role == "supadmin" || entityUser.role == "developer") - && - ( - { - setValChoose(chooseGroup.val); - setValSelect("group"); - setSelect(true); - }} - error={error.group} - errorText="Lembaga Desa tidak boleh kosong" - /> - ) - } + {(entityUser.role == "supadmin" || entityUser.role == "developer") && ( + { + setValChoose(chooseGroup.val); + setValSelect("group"); + setSelect(true); + }} + error={error.group} + errorText="Lembaga Desa tidak boleh kosong" + /> + )} + { - validationForm("title", val); - }} + onChange={(val) => validationForm("title", val)} /> - { - router.push(`/project/create/task`); - }} - error={error.task} - errorText="Tanggal & Tugas tidak boleh kosong" - /> - - { - if (entityUser.role == "supadmin" || entityUser.role == "developer") { - if (chooseGroup.val != "") { - router.push(`/project/create/member`); - } else { - Toast.show({ type: 'small', text1: "Pilih Lembaga Desa terlebih dahulu", }) - } - } else { - router.push(`/project/create/member`); - } - }} - error={error.member} - errorText="Anggota tidak boleh kosong" - /> - - { - fileForm.length > 0 && ( - - File - - { - fileForm.map((item, index) => ( - } - title={item.name} - titleWeight="normal" - onPress={() => { setIndexDelFile(index); setModal(true) }} - /> - )) - } - - - ) - } - {entitiesMember.length > 0 && ( - - - Anggota - Total {entitiesMember.length} Anggota - - - {entitiesMember.map( - (item: { img: any; name: any }, index: any) => { - return ( - - } - title={item.name} - /> - ); - } + {/* Tanggal & Tugas */} + + router.push(`/project/create/task`)} + style={[Styles.sectionActionRow, { marginBottom: taskCreate.length > 0 ? 12 : 0 }]} + > + + + + + Tanggal & Tugas + {taskCreate.length === 0 && ( + Belum ada tugas ditambahkan )} - - )} + {taskCreate.length > 0 && ( + + {taskCreate.length} tugas + + )} + + + {taskCreate.length > 0 && } + {error.task && ( + + Tanggal & Tugas tidak boleh kosong + + )} + + + {/* File */} + + 0 ? 12 : 0 }]} + > + + + + + File + {fileForm.length === 0 && ( + Opsional — ketuk untuk upload + )} + + {fileForm.length > 0 && ( + + {fileForm.length} file + + )} + + + {fileForm.length > 0 && ( + + {fileForm.map((item, index) => { + const ext = item.name.split('.').pop()?.toLowerCase() ?? '' + const baseName = item.name.includes('.') ? item.name.split('.').slice(0, -1).join('.') : item.name + const iconName = getFileIcon(ext) + const iconColor = getFileColor(ext) + return ( + { setIndexDelFile(index); setModal(true) }} + style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]} + > + + + + + {baseName} + {ext.toUpperCase()} + + + ) + })} + + )} + + + {/* Anggota */} + + { + if (entityUser.role == "supadmin" || entityUser.role == "developer") { + if (chooseGroup.val != "") { + router.push(`/project/create/member`); + } else { + Toast.show({ type: 'small', text1: "Pilih Lembaga Desa terlebih dahulu" }) + } + } else { + router.push(`/project/create/member`); + } + }} + style={[Styles.sectionActionRow, { marginBottom: entitiesMember.length > 0 ? 12 : 0 }]} + > + + + + + Anggota + {entitiesMember.length === 0 && ( + Belum ada anggota dipilih + )} + + {entitiesMember.length > 0 && ( + + {entitiesMember.length} orang + + )} + + + {entitiesMember.length > 0 && ( + + {entitiesMember.map((item: { img: any; name: any; position?: string }, index: any) => ( + + + {item.name} + {item.position && ( + + {item.position} + + )} + + ))} + + )} + {error.member && ( + + Anggota tidak boleh kosong + + )} + + diff --git a/components/itemSectionTanggalTugas.tsx b/components/itemSectionTanggalTugas.tsx index 8030a9f..b5763e3 100644 --- a/components/itemSectionTanggalTugas.tsx +++ b/components/itemSectionTanggalTugas.tsx @@ -1,62 +1,184 @@ import Styles from "@/constants/Styles"; import { useTheme } from "@/providers/ThemeProvider"; import { MaterialCommunityIcons } from "@expo/vector-icons"; -import { Pressable, View } from "react-native"; +import { useState } from "react"; +import { LayoutChangeEvent, Pressable, View } from "react-native"; import Text from "./Text"; +type FileItem = { + name: string + extension: string +} + type Props = { done?: boolean title: string dateStart: string dateEnd: string + files?: FileItem[] onPress?: () => void } -export default function ItemSectionTanggalTugas({ done, title, dateStart, dateEnd, onPress }: Props) { - const { colors } = useTheme(); +const CHAR_W = 6.5 +const ICON_W = 17 +const PAD_H = 16 +const GAP = 6 +const PLUS_W = 72 + +function estimateChipWidth(label: string) { + return PAD_H + ICON_W + label.length * CHAR_W +} + +function getVisibleChips(files: FileItem[], containerWidth: number) { + if (containerWidth === 0) return { visible: [], extra: files.length } + + let used = 0 + const visible: FileItem[] = [] + + for (let i = 0; i < files.length; i++) { + const label = `${files[i].name}.${files[i].extension}` + const chipW = estimateChipWidth(label) + const isLast = i === files.length - 1 + const plusChipW = isLast ? 0 : PLUS_W + GAP + const gapW = visible.length > 0 ? GAP : 0 + + if (used + gapW + chipW + plusChipW <= containerWidth) { + visible.push(files[i]) + used += gapW + chipW + } else { + break + } + } + + return { visible, extra: files.length - visible.length } +} + +function getFileIcon(extension: string): keyof typeof MaterialCommunityIcons.glyphMap { + const ext = extension.toLowerCase() + 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' +} + +export default function ItemSectionTanggalTugas({ done, title, dateStart, dateEnd, files = [], onPress }: Props) { + const { colors, activeTheme } = useTheme() + const [containerWidth, setContainerWidth] = useState(0) + + const { visible, extra } = getVisibleChips(files, containerWidth) + + function onChipsLayout(e: LayoutChangeEvent) { + const w = e.nativeEvent.layout.width + if (w !== containerWidth) setContainerWidth(w) + } + + const dimmed = colors.dimmed.slice(0, 7) + const successColor = activeTheme === 'dark' ? '#51CF66' : colors.success + const accentColor = done === true ? successColor : dimmed + '80' return ( - - - { - done != undefined ? - done ? - <> - - Selesai - - : - <> - - Belum Selesai - - : - <> - } + + {/* Accent bar kiri */} + {done !== undefined && ( + + )} - - - - - - {title} - + {/* Konten */} + + + {/* Judul + badge status */} + + {title} + {done !== undefined && ( + + + {done ? 'Selesai' : 'Belum Selesai'} + + + )} - - - - Tanggal Mulai - - {dateStart} - + + {/* Tanggal */} + 0 ? 8 : 0 }}> + + {dateStart} + + {dateEnd} - - Tanggal Berakhir - - {dateEnd} + + {/* Chips lampiran */} + {files.length > 0 && ( + + {visible.map((file, index) => { + const label = `${file.name}.${file.extension}` + const chipW = Math.min(estimateChipWidth(label), containerWidth * 0.55) + return ( + + + + {label} + + + ) + })} + {extra > 0 && ( + + + +{extra} lainnya + + + )} - + )} ) -} \ No newline at end of file +} diff --git a/components/modalSelect.tsx b/components/modalSelect.tsx index 2b68035..e8418a0 100644 --- a/components/modalSelect.tsx +++ b/components/modalSelect.tsx @@ -108,12 +108,12 @@ export default function ModalSelect({ open, close, title, category, idParent, on setChooseValue({ ...chooseValue, val: valChoose }) }, [dispatch, open, search]); - function onChoose(val: string, label: string, img?: string) { + function onChoose(val: string, label: string, img?: string, position?: string) { if (category == "member") { if (selectMember.some((i: any) => i.idUser == val)) { setSelectMember(selectMember.filter((i: any) => i.idUser != val)) } else { - setSelectMember([...selectMember, { idUser: val, name: label, img }]) + setSelectMember([...selectMember, { idUser: val, name: label, img, position }]) } } else { setChooseValue({ val, label }) @@ -144,7 +144,7 @@ export default function ModalSelect({ open, close, title, category, idParent, on key={index} label={item.name} src={`${ConstEnv.url_storage}/files/${item.img}`} - onClick={() => onChoose(item.idUser, item.name, item.img)} + onClick={() => onChoose(item.idUser, item.name, item.img, item.position)} /> )) } @@ -162,7 +162,7 @@ export default function ModalSelect({ open, close, title, category, idParent, on category != 'status-task' ? data.length > 0 ? data.map((item: any, index: any) => ( - { onChoose(item.id, item.name, item.img) }}> + { onChoose(item.id, item.name, item.img, item.position) }}> { category == 'member' ? diff --git a/components/project/itemSectionLink.tsx b/components/project/itemSectionLink.tsx new file mode 100644 index 0000000..910729c --- /dev/null +++ b/components/project/itemSectionLink.tsx @@ -0,0 +1,100 @@ +import { urlCompleted } from "@/lib/fun_urlCompleted"; +import { useTheme } from "@/providers/ThemeProvider"; +import { MaterialCommunityIcons } from "@expo/vector-icons"; +import { Linking, Pressable, View } from "react-native"; +import Text from "../Text"; +import Styles from "@/constants/Styles"; + +type Props = { + link: string + canDelete: boolean + onLongPress: () => void +} + +type DomainConfig = { + icon: keyof typeof MaterialCommunityIcons.glyphMap + color: string + label: string +} + +function getDomainConfig(url: string): DomainConfig { + try { + const hostname = new URL(urlCompleted(url)).hostname.replace('www.', '') + if (hostname.includes('youtube.com') || hostname.includes('youtu.be')) + return { icon: 'youtube', color: '#FF0000', label: 'YouTube' } + if (hostname.includes('drive.google.com')) + return { icon: 'google-drive', color: '#4285F4', label: 'Google Drive' } + if (hostname.includes('docs.google.com')) + return { icon: 'google', color: '#4285F4', label: 'Google Docs' } + if (hostname.includes('sheets.google.com')) + return { icon: 'google-spreadsheet', color: '#0F9D58', label: 'Google Sheets' } + if (hostname.includes('github.com')) + return { icon: 'github', color: '#24292E', label: 'GitHub' } + if (hostname.includes('wa.me') || hostname.includes('whatsapp.com')) + return { icon: 'whatsapp', color: '#25D366', label: 'WhatsApp' } + if (hostname.includes('instagram.com')) + return { icon: 'instagram', color: '#E1306C', label: 'Instagram' } + if (hostname.includes('facebook.com')) + return { icon: 'facebook', color: '#1877F2', label: 'Facebook' } + if (hostname.includes('figma.com')) + return { icon: 'vector-bezier', color: '#F24E1E', label: 'Figma' } + if (hostname.includes('notion.so')) + return { icon: 'notebook-outline', color: '#000000', label: 'Notion' } + return { icon: 'link-variant', color: '#6366F1', label: hostname } + } catch { + return { icon: 'link-variant', color: '#6366F1', label: url } + } +} + +function getDisplayUrl(url: string) { + try { + const full = urlCompleted(url) + const parsed = new URL(full) + const path = parsed.pathname + parsed.search + return path.length > 1 ? path : '' + } catch { + return '' + } +} + +export default function ItemSectionLink({ link, canDelete, onLongPress }: Props) { + const { colors, activeTheme } = useTheme() + const config = getDomainConfig(link) + const displayPath = getDisplayUrl(link) + + const iconBg = activeTheme === 'dark' ? config.color + '25' : config.color + '15' + const iconColor = activeTheme === 'dark' && config.color === '#24292E' ? '#ECEDEE' : config.color + + return ( + Linking.openURL(urlCompleted(link))} + onLongPress={canDelete ? onLongPress : undefined} + style={({ pressed }) => ([ + Styles.fileCard, + { + width: '100%', + marginBottom: 10, + borderColor: colors.icon + '18', + backgroundColor: pressed ? colors.icon + '10' : colors.card, + }, + ])} + > + + + + + + + {config.label} + + {displayPath.length > 0 && ( + + {displayPath} + + )} + + + + + ) +} diff --git a/components/project/sectionFile.tsx b/components/project/sectionFile.tsx index c4643dd..58cf85b 100644 --- a/components/project/sectionFile.tsx +++ b/components/project/sectionFile.tsx @@ -10,19 +10,17 @@ import { startActivityAsync } from 'expo-intent-launcher'; import { useLocalSearchParams } from "expo-router"; import * as Sharing from 'expo-sharing'; import { useEffect, useState } from "react"; -import { Alert, Platform, View } from "react-native"; +import { Alert, Platform, Pressable, View } from "react-native"; import * as mime from 'react-native-mime-types'; import Toast from "react-native-toast-message"; import { useDispatch, useSelector } from "react-redux"; -import ModalConfirmation from "../ModalConfirmation"; -import BorderBottomItem from "../borderBottomItem"; import DrawerBottom from "../drawerBottom"; import MenuItemRow from "../menuItemRow"; +import ModalConfirmation from "../ModalConfirmation"; import ModalLoading from "../modalLoading"; import Skeleton from "../skeleton"; import Text from "../Text"; - type Props = { id: string name: string @@ -30,6 +28,28 @@ type Props = { idStorage: string } +function getFileIcon(extension: string): keyof typeof MaterialCommunityIcons.glyphMap { + const ext = extension.toLowerCase() + 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(extension: string): string { + const ext = extension.toLowerCase() + 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 SectionFile({ status, member, refreshing }: { status: number | undefined, member: boolean, refreshing?: boolean }) { const { colors } = useTheme(); const entityUser = useSelector((state: any) => state.user) @@ -40,7 +60,7 @@ export default function SectionFile({ status, member, refreshing }: { status: nu const update = useSelector((state: any) => state.projectUpdate) const dispatch = useDispatch() const [loading, setLoading] = useState(true) - const arrSkeleton = Array.from({ length: 3 }) + const arrSkeleton = Array.from({ length: 4 }) const [selectFile, setSelectFile] = useState(null) const [showDeleteModal, setShowDeleteModal] = useState(false) const [loadingOpen, setLoadingOpen] = useState(false) @@ -49,11 +69,7 @@ export default function SectionFile({ status, member, refreshing }: { status: nu try { setLoading(loading) const hasil = await decryptToken(String(token?.current)); - const response = await apiGetProjectOne({ - user: hasil, - cat: "file", - id: id, - }); + const response = await apiGetProjectOne({ user: hasil, cat: "file", id }); setData(response.data); } catch (error) { console.error(error); @@ -62,110 +78,90 @@ export default function SectionFile({ status, member, refreshing }: { status: nu } } - useEffect(() => { - handleLoad(false); - }, [update.file]); - - useEffect(() => { - if (refreshing) - handleLoad(false); - }, [refreshing]); - - - useEffect(() => { - handleLoad(true); - }, []); + useEffect(() => { handleLoad(false) }, [update.file]); + useEffect(() => { if (refreshing) handleLoad(false) }, [refreshing]); + useEffect(() => { handleLoad(true) }, []); async function handleDelete() { try { const hasil = await decryptToken(String(token?.current)); const response = await apiDeleteFileProject({ user: hasil }, String(selectFile?.id)); if (response.success) { - Toast.show({ type: 'small', text1: 'Berhasil menghapus file', }) + Toast.show({ type: 'small', text1: 'Berhasil menghapus file' }) dispatch(setUpdateProject({ ...update, file: !update.file })) } 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 menghapus file" - - Toast.show({ type: 'small', text1: message }) + } catch (error: any) { + Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menghapus file" }) } finally { setModal(false) } } - const openFile = () => { setModal(false) setLoadingOpen(true) - let remoteUrl = ConstEnv.url_storage + '/files/' + selectFile?.idStorage; - const fileName = selectFile?.name + '.' + selectFile?.extension; - let localPath = `${FileSystem.documentDirectory}/${fileName}`; + const remoteUrl = ConstEnv.url_storage + '/files/' + selectFile?.idStorage + const fileName = selectFile?.name + '.' + selectFile?.extension + const localPath = `${FileSystem.documentDirectory}/${fileName}` const mimeType = mime.lookup(fileName) FileSystem.downloadAsync(remoteUrl, localPath).then(async ({ uri }) => { - const contentURL = await FileSystem.getContentUriAsync(uri); + const contentURL = await FileSystem.getContentUriAsync(uri) try { - - if (Platform.OS == 'android') { - // open with android intent - await startActivityAsync( - 'android.intent.action.VIEW', - { - data: contentURL, - flags: 1, - type: mimeType as string, - } - ); - // or - // Sharing.shareAsync(localPath); - - } else if (Platform.OS == 'ios') { - Sharing.shareAsync(localPath); + if (Platform.OS === 'android') { + await startActivityAsync('android.intent.action.VIEW', { data: contentURL, flags: 1, type: mimeType as string }) + } else { + Sharing.shareAsync(localPath) } - } catch (error) { - Alert.alert('INFO', 'Gagal membuka file, tidak ada aplikasi yang dapat membuka file ini'); + } catch { + Alert.alert('INFO', 'Gagal membuka file, tidak ada aplikasi yang dapat membuka file ini') } finally { setLoadingOpen(false) } - }); - }; - - + }) + } return ( <> File - - { - loading ? - arrSkeleton.map((item, index) => { - return ( - - ) - }) - : - data.length > 0 ? - data.map((item, index) => { - return ( - } - title={item.name + '.' + item.extension} - titleWeight="normal" - onPress={() => { setSelectFile(item); setModal(true) }} - /> - ) - }) - : - Tidak ada file - } - + + {loading ? ( + + {arrSkeleton.map((_, index) => ( + + ))} + + ) : data.length > 0 ? ( + + {data.map((item, index) => { + const iconName = getFileIcon(item.extension) + const iconColor = getFileColor(item.extension) + return ( + { setSelectFile(item); setModal(true) }} + style={[Styles.fileCard, { backgroundColor: colors.card, borderColor: colors.icon + '18' }]} + > + + + + + {item.name} + + {item.extension.toUpperCase()} + + + + ) + })} + + ) : ( + Tidak ada file + )} @@ -173,26 +169,20 @@ export default function SectionFile({ status, member, refreshing }: { status: nu } title="Lihat / Share" - onPress={() => { - openFile() - }} + onPress={openFile} /> - { - !member && (entityUser.role == "user" || entityUser.role == "coadmin") ? <> - : - } - title="Hapus" - disabled={status == 3} - onPress={() => { - if (status == 3) return - setModal(false) - setTimeout(() => { - setShowDeleteModal(true) - }, 600) - }} - /> - } + {(!member && (entityUser.role === "user" || entityUser.role === "coadmin")) ? null : ( + } + title="Hapus" + disabled={status === 3} + onPress={() => { + if (status === 3) return + setModal(false) + setTimeout(() => setShowDeleteModal(true), 600) + }} + /> + )} @@ -200,14 +190,11 @@ export default function SectionFile({ status, member, refreshing }: { status: nu visible={showDeleteModal} title="Konfirmasi" message="Apakah Anda yakin ingin menghapus file ini? File yang dihapus tidak dapat dikembalikan" - onConfirm={() => { - setShowDeleteModal(false) - handleDelete() - }} + onConfirm={() => { setShowDeleteModal(false); handleDelete() }} onCancel={() => setShowDeleteModal(false)} confirmText="Hapus" cancelText="Batal" /> ) -} \ No newline at end of file +} diff --git a/components/project/sectionLink.tsx b/components/project/sectionLink.tsx index 5685007..e2fcf93 100644 --- a/components/project/sectionLink.tsx +++ b/components/project/sectionLink.tsx @@ -1,20 +1,19 @@ import Styles from "@/constants/Styles"; import { apiDeleteLinkProject, apiGetProjectOne } from "@/lib/api"; -import { urlCompleted } from "@/lib/fun_urlCompleted"; import { setUpdateProject } from "@/lib/projectUpdate"; import { useAuthSession } from "@/providers/AuthProvider"; import { useTheme } from "@/providers/ThemeProvider"; -import { Feather, Ionicons } from "@expo/vector-icons"; +import { Ionicons } from "@expo/vector-icons"; import { useLocalSearchParams } from "expo-router"; import { useEffect, useState } from "react"; -import { Linking, View } from "react-native"; +import { View } from "react-native"; import Toast from "react-native-toast-message"; import { useDispatch, useSelector } from "react-redux"; import ModalConfirmation from "../ModalConfirmation"; -import BorderBottomItem from "../borderBottomItem"; import DrawerBottom from "../drawerBottom"; import MenuItemRow from "../menuItemRow"; import Text from "../Text"; +import ItemSectionLink from "./itemSectionLink"; type Props = { @@ -87,17 +86,16 @@ export default function SectionLink({ status, member, refreshing }: { status: nu <> Link - + { data.map((item, index) => { + const canDelete = member || (entityUser.role !== "user" && entityUser.role !== "coadmin") return ( - } - title={item.link} - titleWeight="normal" - onPress={() => { setSelectLink(item); setModal(true) }} + link={item.link} + canDelete={canDelete && status !== 3} + onLongPress={() => { setSelectLink(item); setModal(true) }} /> ) }) @@ -108,28 +106,13 @@ export default function SectionLink({ status, member, refreshing }: { status: nu } - title="Buka Link" + icon={} + title="Hapus Link" onPress={() => { - Linking.openURL(urlCompleted(String(selectLink?.link))) + setModal(false) + setTimeout(() => setShowDeleteModal(true), 600) }} /> - { - !member && (entityUser.role == "user" || entityUser.role == "coadmin") ? <> - : - } - title="Hapus" - disabled={status == 3} - onPress={() => { - if (status == 3) return - setModal(false) - setTimeout(() => { - setShowDeleteModal(true) - }, 600) - }} - /> - } diff --git a/components/project/sectionListAddTask.tsx b/components/project/sectionListAddTask.tsx index 197ef87..345a159 100644 --- a/components/project/sectionListAddTask.tsx +++ b/components/project/sectionListAddTask.tsx @@ -10,7 +10,7 @@ import ItemSectionTanggalTugas from "../itemSectionTanggalTugas"; import MenuItemRow from "../menuItemRow"; import Text from "../Text"; -export default function SectionListAddTask() { +export default function SectionListAddTask({ showTitle = true }: { showTitle?: boolean }) { const { colors } = useTheme(); const taskCreate = useSelector((state: any) => state.taskCreate) const [select, setSelect] = useState(null) @@ -22,42 +22,32 @@ export default function SectionListAddTask() { setModal(false) } + const items = taskCreate.map((item: { status: number; title: string; dateStart: string; dateEnd: string; }, index: Key | null | undefined) => ( + { + setSelect(index) + setModal(true) + }} + /> + )) + return ( <> - { - taskCreate.length > 0 - && + {taskCreate.length > 0 && ( <> - - - Tanggal & Tugas - - - { - taskCreate.map((item: { status: number; title: string; dateStart: string; dateEnd: string; }, index: Key | null | undefined) => { - return ( - { - setSelect(index) - setModal(true) - }} - /> - ); - }) - } - + {showTitle ? ( + + Tanggal & Tugas + {items} - - + ) : ( + {items} + )} + } @@ -67,7 +57,7 @@ export default function SectionListAddTask() { - } + )} ) } \ No newline at end of file diff --git a/components/project/sectionMember.tsx b/components/project/sectionMember.tsx index 9999ec5..5a4dd99 100644 --- a/components/project/sectionMember.tsx +++ b/components/project/sectionMember.tsx @@ -7,15 +7,14 @@ import { useTheme } from "@/providers/ThemeProvider"; import { MaterialCommunityIcons } from "@expo/vector-icons"; import { router, useLocalSearchParams } from "expo-router"; import { useEffect, useState } from "react"; -import { View } from "react-native"; +import { Pressable, View } from "react-native"; import Toast from "react-native-toast-message"; import { useDispatch, useSelector } from "react-redux"; -import ModalConfirmation from "../ModalConfirmation"; -import BorderBottomItem from "../borderBottomItem"; import DrawerBottom from "../drawerBottom"; import ImageUser from "../imageNew"; import MenuItemRow from "../menuItemRow"; -import SkeletonTwoItem from "../skeletonTwoItem"; +import ModalConfirmation from "../ModalConfirmation"; +import Skeleton from "../skeleton"; import Text from "../Text"; type Props = { @@ -35,26 +34,17 @@ export default function SectionMember({ status, refreshing }: { status: number | const [isModal, setModal] = useState(false); const { token, decryptToken } = useAuthSession(); const { id } = useLocalSearchParams<{ id: string }>(); - const [selectLink, setSelectLink] = useState(null); const [showDeleteModal, setShowDeleteModal] = useState(false); const [data, setData] = useState([]); const [loading, setLoading] = useState(true) - const arrSkeleton = Array.from({ length: 3 }) - const [memberChoose, setMemberChoose] = useState({ - id: '', - name: '', - }) - + const arrSkeleton = Array.from({ length: 4 }) + const [memberChoose, setMemberChoose] = useState({ id: '', name: '' }) async function handleLoad(loading: boolean) { try { setLoading(loading) const hasil = await decryptToken(String(token?.current)); - const response = await apiGetProjectOne({ - user: hasil, - cat: "member", - id: id, - }); + const response = await apiGetProjectOne({ user: hasil, cat: "member", id }); setData(response.data); } catch (error) { console.error(error); @@ -63,36 +53,21 @@ export default function SectionMember({ status, refreshing }: { status: number | } } - useEffect(() => { - handleLoad(false); - }, [update.member]); - - useEffect(() => { - if (refreshing) - handleLoad(false); - }, [refreshing]); - - useEffect(() => { - handleLoad(true); - }, []); + useEffect(() => { handleLoad(false) }, [update.member]); + useEffect(() => { if (refreshing) handleLoad(false) }, [refreshing]); + useEffect(() => { handleLoad(true) }, []); async function handleDeleteMember() { try { const hasil = await decryptToken(String(token?.current)); - const response = await apiDeleteProjectMember({ - user: hasil, - idUser: memberChoose.id, - }, id) + const response = await apiDeleteProjectMember({ user: hasil, idUser: memberChoose.id }, id) if (response.success) { - Toast.show({ type: 'small', text1: 'Berhasil menghapus anggota', }) + Toast.show({ type: 'small', text1: 'Berhasil menghapus anggota' }) dispatch(setUpdateProject({ ...update, member: !update.member })) setModal(false); } - } catch (error : any ) { - console.error(error); - const message = error?.response?.data?.message || "Gagal menghapus anggota" - - Toast.show({ type: 'small', text1: message }) + } catch (error: any) { + Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menghapus anggota" }) } } @@ -101,84 +76,85 @@ export default function SectionMember({ status, refreshing }: { status: number | Anggota - Total {data.length} Anggota + {!loading && data.length > 0 && ( + {data.length} orang + )} - - { - loading ? - arrSkeleton.map((item, index) => { - return ( - - ) - }) - : - data.length > 0 - ? - data.map((item, index) => { - return ( - } - title={item.name} - onPress={() => { - if (status == 3) return - setMemberChoose({ - id: item.idUser, - name: item.name, - }) - setModal(true); - }} - /> - ); - }) - : - Tidak ada anggota - } + + {loading ? ( + arrSkeleton.map((_, index) => ( + + + + + + + + )) + ) : data.length > 0 ? ( + data.map((item, index) => ( + { + if (status === 3) return + setMemberChoose({ id: item.idUser, name: item.name }) + setModal(true) + }} + style={{ + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.card, + borderRadius: 10, + borderWidth: 1, + borderColor: colors.icon + '18', + paddingHorizontal: 12, + paddingVertical: 10, + gap: 12, + }} + > + + + {item.name} + + + + {item.position} + + + + )) + ) : ( + Tidak ada anggota + )} - + - } + icon={} title="Lihat Profil" onPress={() => { - setModal(false); - router.push(`/member/${memberChoose.id}`); + setModal(false) + router.push(`/member/${memberChoose.id}`) }} /> - { - entityUser.role != "user" && entityUser.role != "coadmin" && + {entityUser.role !== "user" && entityUser.role !== "coadmin" && ( - } + icon={} title="Keluarkan" onPress={() => { setModal(false) - setTimeout(() => { - setShowDeleteModal(true) - }, 600) + setTimeout(() => setShowDeleteModal(true), 600) }} /> - } + )} @@ -186,10 +162,7 @@ export default function SectionMember({ status, refreshing }: { status: number | visible={showDeleteModal} title="Konfirmasi" message="Apakah Anda yakin ingin mengeluarkan anggota?" - onConfirm={() => { - setShowDeleteModal(false) - handleDeleteMember() - }} + onConfirm={() => { setShowDeleteModal(false); handleDeleteMember() }} onCancel={() => setShowDeleteModal(false)} confirmText="Keluarkan" cancelText="Batal" diff --git a/components/project/sectionReportProject.tsx b/components/project/sectionReportProject.tsx index 0d3a139..3e44390 100644 --- a/components/project/sectionReportProject.tsx +++ b/components/project/sectionReportProject.tsx @@ -2,6 +2,7 @@ import Styles from "@/constants/Styles"; import { apiGetProjectOne } from "@/lib/api"; import { useAuthSession } from "@/providers/AuthProvider"; import { useTheme } from "@/providers/ThemeProvider"; +import { MaterialCommunityIcons } from "@expo/vector-icons"; import { useLocalSearchParams } from "expo-router"; import { useEffect, useState } from "react"; import { View } from "react-native"; @@ -19,39 +20,37 @@ export default function SectionReportProject({ refreshing }: { refreshing?: bool async function handleLoad() { try { const hasil = await decryptToken(String(token?.current)); - const response = await apiGetProjectOne({ - user: hasil, - cat: "data", - id: id, - }); + const response = await apiGetProjectOne({ user: hasil, cat: "data", id: id }); setData(response.data.report); } catch (error) { console.error(error); } } - useEffect(() => { - handleLoad(); - }, [update.report]); + useEffect(() => { handleLoad() }, [update.report]); + useEffect(() => { if (refreshing) handleLoad() }, [refreshing]); - useEffect(() => { - if (refreshing) - handleLoad(); - }, [refreshing]); + if (!data || data === "") return null; return ( - <> - { - data != "" && data != null && - - - Laporan Kegiatan - - - - + + + + - } - + + Laporan Kegiatan + + + + + + + ); } diff --git a/components/project/sectionTanggalTugas.tsx b/components/project/sectionTanggalTugas.tsx index 183adee..9b553d8 100644 --- a/components/project/sectionTanggalTugas.tsx +++ b/components/project/sectionTanggalTugas.tsx @@ -9,10 +9,10 @@ import { useEffect, useState } from "react"; import { View } from "react-native"; import Toast from "react-native-toast-message"; import { useDispatch, useSelector } from "react-redux"; -import ModalConfirmation from "../ModalConfirmation"; import DrawerBottom from "../drawerBottom"; import ItemSectionTanggalTugas from "../itemSectionTanggalTugas"; import MenuItemRow from "../menuItemRow"; +import ModalConfirmation from "../ModalConfirmation"; import ModalSelect from "../modalSelect"; import SkeletonTask from "../skeletonTask"; import Text from "../Text"; @@ -26,6 +26,7 @@ type Props = { dateStart: string; dateEnd: string; createdAt: string; + files?: { name: string; extension: string }[]; }; export default function SectionTanggalTugasProject({ status, member, refreshing }: { status: number | undefined, member: boolean, refreshing?: boolean }) { @@ -91,7 +92,7 @@ export default function SectionTanggalTugasProject({ status, member, refreshing setSelect(false); Toast.show({ type: 'small', text1: 'Berhasil mengubah data', }) } - } catch (error : any ) { + } catch (error: any) { console.error(error); const message = error?.response?.data?.message || "Gagal mengubah data" @@ -111,7 +112,7 @@ export default function SectionTanggalTugasProject({ status, member, refreshing setModal(false); Toast.show({ type: 'small', text1: 'Berhasil menghapus data', }) } - } catch (error : any ) { + } catch (error: any) { console.error(error); const message = error?.response?.data?.message || "Gagal menghapus data" @@ -125,7 +126,7 @@ export default function SectionTanggalTugasProject({ status, member, refreshing Tanggal & Tugas - + { loading ? arrSkeleton.map((item, index) => { @@ -144,12 +145,9 @@ export default function SectionTanggalTugasProject({ status, member, refreshing title={item.title} dateStart={item.dateStart} dateEnd={item.dateEnd} + files={item.files ?? []} onPress={() => { - if (status == 3 || (!member && (entityUser.role == "user" || entityUser.role == "coadmin"))) return - setTugas({ - id: item.id, - status: item.status - }) + setTugas({ id: item.id, status: item.status }) setModal(true) }} /> @@ -168,66 +166,53 @@ export default function SectionTanggalTugasProject({ status, member, refreshing title="Menu" > + {(member || (entityUser.role != "user" && entityUser.role != "coadmin")) && status != 3 && ( + } + title="Update Status" + onPress={() => { + setModal(false); + setTimeout(() => setSelect(true), 600) + }} + /> + )} - } - title="Update Status" + icon={} + title="File Tugas" onPress={() => { setModal(false); - setTimeout(() => { - setSelect(true); - }, 600) + router.push(`/project/${id}/tugas-file/${tugas.id}?member=${member}`); }} /> - } - title="Edit Tugas" - onPress={() => { - setModal(false); - router.push(`/project/update/${tugas.id}`); - }} - /> - - - } + icon={} title="Detail Waktu" onPress={() => { setModal(false); - setTimeout(() => { - setModalDetail(true) - }, 600) - }} - /> - - - } - title="Hapus Tugas" - onPress={() => { - setModal(false) - setTimeout(() => { - setShowDeleteModal(true) - }, 600) + setTimeout(() => setModalDetail(true), 600) }} /> + {(member || (entityUser.role != "user" && entityUser.role != "coadmin")) && status != 3 && ( + + } + title="Edit Tugas" + onPress={() => { + setModal(false); + router.push(`/project/update/${tugas.id}`); + }} + /> + } + title="Hapus Tugas" + onPress={() => { + setModal(false) + setTimeout(() => setShowDeleteModal(true), 600) + }} + /> + + )} - - - {title ? title : 'Kegiatan Dibatalkan'} + + + + + + + {title ?? 'Kegiatan Dibatalkan'} + - { - text && ( - - {text} - - ) - } + + {text && ( + + {text} + + )} ) -} \ No newline at end of file +} diff --git a/components/sectionProgress.tsx b/components/sectionProgress.tsx index fc1132d..0eceed5 100644 --- a/components/sectionProgress.tsx +++ b/components/sectionProgress.tsx @@ -1,27 +1,87 @@ import Styles from "@/constants/Styles"; import { useTheme } from "@/providers/ThemeProvider"; -import { AntDesign } from "@expo/vector-icons"; -import { View } from "react-native"; -import ProgressBar from "./progressBar"; +import { MaterialCommunityIcons } from "@expo/vector-icons"; +import { useEffect, useRef } from "react"; +import { Animated, View } from "react-native"; import Text from "./Text"; type Props = { - text: string, progress: number + doneCount?: number + totalCount?: number } -export default function SectionProgress({ text, progress }: Props) { +export default function SectionProgress({ progress, doneCount, totalCount }: Props) { const { colors } = useTheme(); + const animatedWidth = useRef(new Animated.Value(0)).current; + + const progressColor = colors.tabActive; + + const statusLabel = progress === 100 + ? 'Selesai' + : progress > 0 + ? 'Sedang berlangsung' + : 'Belum dimulai'; + + useEffect(() => { + animatedWidth.setValue(0); + Animated.timing(animatedWidth, { + toValue: progress, + duration: 900, + useNativeDriver: false, + }).start(); + }, [progress]); return ( - - - + + + + + + + + + Kemajuan Kegiatan + + + + {statusLabel} + + + + + + + {progress}% + + + {totalCount !== undefined && doneCount !== undefined && ( + + + {doneCount}/{totalCount} tugas + + + )} + - - {text} - + + + - ) -} \ No newline at end of file + ); +} diff --git a/components/task/sectionFileTask.tsx b/components/task/sectionFileTask.tsx index 66d8675..8fa002e 100644 --- a/components/task/sectionFileTask.tsx +++ b/components/task/sectionFileTask.tsx @@ -10,15 +10,13 @@ import { startActivityAsync } from 'expo-intent-launcher'; import { useLocalSearchParams } from "expo-router"; import * as Sharing from 'expo-sharing'; import { useEffect, useState } from "react"; -import { Alert, Platform, View } from "react-native"; +import { Alert, Platform, Pressable, View } from "react-native"; import * as mime from 'react-native-mime-types'; import Toast from "react-native-toast-message"; import { useDispatch, useSelector } from "react-redux"; -import ModalConfirmation from "../ModalConfirmation"; -import ButtonMenuHeader from "../buttonMenuHeader"; -import BorderBottomItem from "../borderBottomItem"; import DrawerBottom from "../drawerBottom"; import MenuItemRow from "../menuItemRow"; +import ModalConfirmation from "../ModalConfirmation"; import ModalLoading from "../modalLoading"; import Skeleton from "../skeleton"; import Text from "../Text"; @@ -30,6 +28,28 @@ type Props = { idStorage: string } +function getFileIcon(extension: string): keyof typeof MaterialCommunityIcons.glyphMap { + const ext = extension.toLowerCase() + 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(extension: string): string { + const ext = extension.toLowerCase() + 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 SectionFileTask({ refreshing, isMemberDivision }: { refreshing: boolean, isMemberDivision: boolean }) { const { colors } = useTheme() const [isModal, setModal] = useState(false) @@ -119,7 +139,7 @@ export default function SectionFileTask({ refreshing, isMemberDivision }: { refr } else { Toast.show({ type: 'small', text1: response.message, }) } - } catch (error : any ) { + } catch (error: any) { console.error(error); const message = error?.response?.data?.message || "Gagal menghapus file" @@ -134,32 +154,39 @@ export default function SectionFileTask({ refreshing, isMemberDivision }: { refr File - - { - loading ? - arrSkeleton.map((item, index) => { - return ( - - ) - }) - : - data.length > 0 ? - data.map((item, index) => { - return ( - } - title={item.name + '.' + item.extension} - titleWeight="normal" - onPress={() => { setSelectFile(item); setModal(true) }} - /> - ) - }) - : - Tidak ada file - } - + {loading ? ( + + {arrSkeleton.map((_, index) => ( + + ))} + + ) : data.length > 0 ? ( + + {data.map((item, index) => { + const iconName = getFileIcon(item.extension) + const iconColor = getFileColor(item.extension) + return ( + { setSelectFile(item); setModal(true) }} + style={[Styles.fileCard, { backgroundColor: colors.card, borderColor: colors.icon + '18' }]} + > + + + + + {item.name} + + {item.extension.toUpperCase()} + + + + ) + })} + + ) : ( + Tidak ada file + )} diff --git a/components/task/sectionLinkTask.tsx b/components/task/sectionLinkTask.tsx index db89d58..85ff267 100644 --- a/components/task/sectionLinkTask.tsx +++ b/components/task/sectionLinkTask.tsx @@ -1,20 +1,19 @@ import Styles from "@/constants/Styles"; import { apiDeleteLinkTask, apiGetTaskOne } from "@/lib/api"; -import { urlCompleted } from "@/lib/fun_urlCompleted"; import { setUpdateTask } from "@/lib/taskUpdate"; import { useAuthSession } from "@/providers/AuthProvider"; import { useTheme } from "@/providers/ThemeProvider"; -import { Feather, Ionicons } from "@expo/vector-icons"; +import { Ionicons } from "@expo/vector-icons"; import { useLocalSearchParams } from "expo-router"; import { useEffect, useState } from "react"; -import { Linking, View } from "react-native"; +import { View } from "react-native"; import Toast from "react-native-toast-message"; import { useDispatch, useSelector } from "react-redux"; import ModalConfirmation from "../ModalConfirmation"; -import BorderBottomItem from "../borderBottomItem"; import DrawerBottom from "../drawerBottom"; import MenuItemRow from "../menuItemRow"; import Text from "../Text"; +import ItemSectionLink from "../project/itemSectionLink"; type Props = { id: string @@ -62,84 +61,60 @@ export default function SectionLinkTask({ refreshing, isMemberDivision }: { refr } else { Toast.show({ type: 'small', text1: response.message, }) } - } catch (error : any ) { + } catch (error: any) { console.error(error); const message = error?.response?.data?.message || "Gagal menghapus link" - Toast.show({ type: 'small', text1: message }) } finally { setModal(false) } } + if (data.length === 0) return null; + + const canDelete = (entityUser.role !== "user" && entityUser.role !== "coadmin") || isMemberDivision; + return ( <> - { - data.length > 0 && - <> - - Link - - { - data.map((item, index) => { - return ( - } - title={item.link} - titleWeight="normal" - onPress={() => { setSelectLink(item); setModal(true) }} - /> - ) - }) - } - - + + Link + + {data.map((item, index) => ( + { setSelectLink(item); setModal(true) }} + /> + ))} + + - - - } - title="Buka Link" - onPress={() => { - Linking.openURL(urlCompleted(String(selectLink?.link))) - }} - /> - { - (entityUser.role != "user" && entityUser.role != "coadmin") || isMemberDivision - ? - } - title="Hapus" - onPress={() => { - setModal(false) - setTimeout(() => { - setShowDeleteModal(true) - }, 600) - }} - /> - : - <> - } - - - - - { - setShowDeleteModal(false) - handleDelete() + + + } + title="Hapus Link" + onPress={() => { + setModal(false) + setTimeout(() => setShowDeleteModal(true), 600) }} - onCancel={() => setShowDeleteModal(false)} - confirmText="Hapus" - cancelText="Batal" /> - - } + + + + { + setShowDeleteModal(false) + handleDelete() + }} + onCancel={() => setShowDeleteModal(false)} + confirmText="Hapus" + cancelText="Batal" + /> ) -} \ No newline at end of file +} diff --git a/components/task/sectionMemberTask.tsx b/components/task/sectionMemberTask.tsx index 61f3f0d..54209b7 100644 --- a/components/task/sectionMemberTask.tsx +++ b/components/task/sectionMemberTask.tsx @@ -7,15 +7,14 @@ import { useTheme } from "@/providers/ThemeProvider"; import { MaterialCommunityIcons } from "@expo/vector-icons"; import { router, useLocalSearchParams } from "expo-router"; import { useEffect, useState } from "react"; -import { View } from "react-native"; +import { Pressable, View } from "react-native"; import Toast from "react-native-toast-message"; import { useDispatch, useSelector } from "react-redux"; -import ModalConfirmation from "../ModalConfirmation"; -import BorderBottomItem from "../borderBottomItem"; import DrawerBottom from "../drawerBottom"; import ImageUser from "../imageNew"; import MenuItemRow from "../menuItemRow"; -import SkeletonTwoItem from "../skeletonTwoItem"; +import ModalConfirmation from "../ModalConfirmation"; +import Skeleton from "../skeleton"; import Text from "../Text"; type Props = { @@ -88,7 +87,7 @@ export default function SectionMemberTask({ refreshing, isAdminDivision }: { ref } else { Toast.show({ type: 'small', text1: response.message, }) } - } catch (error : any ) { + } catch (error: any) { console.error(error); const message = error?.response?.data?.message || "Gagal menghapus anggota" @@ -103,28 +102,46 @@ export default function SectionMemberTask({ refreshing, isAdminDivision }: { ref Anggota - Total {data.length} Anggota + {!loading && data.length > 0 && ( + {data.length} orang + )} - + { loading ? arrSkeleton.map((item, index) => { return ( - + + + + + + + ) }) : data.length > 0 ? ( data.map((item, index) => { return ( - + // } + // title={item.name} + // onPress={() => { + // setMemberChoose({ + // id: item.idUser, + // name: item.name, + // }); + // setModal(true); + // }} + // /> + - } - title={item.name} onPress={() => { setMemberChoose({ id: item.idUser, @@ -132,7 +149,33 @@ export default function SectionMemberTask({ refreshing, isAdminDivision }: { ref }); setModal(true); }} - /> + style={{ + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.card, + borderRadius: 10, + borderWidth: 1, + borderColor: colors.icon + '18', + paddingHorizontal: 12, + paddingVertical: 10, + gap: 12, + }} + > + + + {item.name} + + + + {item.position} + + + ); }) ) : ( diff --git a/components/task/sectionReportTask.tsx b/components/task/sectionReportTask.tsx index 64d270b..7514cdd 100644 --- a/components/task/sectionReportTask.tsx +++ b/components/task/sectionReportTask.tsx @@ -2,6 +2,7 @@ import Styles from "@/constants/Styles"; import { apiGetTaskOne } from "@/lib/api"; import { useAuthSession } from "@/providers/AuthProvider"; import { useTheme } from "@/providers/ThemeProvider"; +import { MaterialCommunityIcons } from "@expo/vector-icons"; import { useLocalSearchParams } from "expo-router"; import { useEffect, useState } from "react"; import { View } from "react-native"; @@ -9,7 +10,6 @@ import { useSelector } from "react-redux"; import Text from "../Text"; import TextExpandable from "../textExpandable"; - export default function SectionReportTask({ refreshing }: { refreshing: boolean }) { const { colors } = useTheme() const update = useSelector((state: any) => state.taskUpdate) @@ -27,29 +27,30 @@ export default function SectionReportTask({ refreshing }: { refreshing: boolean } } - useEffect(() => { - handleLoad() - }, [update.report]) - - useEffect(() => { - if (refreshing) - handleLoad(); - }, [refreshing]); + useEffect(() => { handleLoad() }, [update.report]) + useEffect(() => { if (refreshing) handleLoad() }, [refreshing]) + if (!data || data === "") return null; return ( - <> - { - data != "" && data != null && - - - Laporan Kegiatan - - - - + + + + - } - + + Laporan Kegiatan + + + + + + + ) -} \ No newline at end of file +} diff --git a/components/task/sectionTanggalTugasTask.tsx b/components/task/sectionTanggalTugasTask.tsx index c23022d..34d48d4 100644 --- a/components/task/sectionTanggalTugasTask.tsx +++ b/components/task/sectionTanggalTugasTask.tsx @@ -9,10 +9,10 @@ import { useEffect, useState } from "react"; import { View } from "react-native"; import Toast from "react-native-toast-message"; import { useDispatch, useSelector } from "react-redux"; -import ModalConfirmation from "../ModalConfirmation"; import DrawerBottom from "../drawerBottom"; import ItemSectionTanggalTugas from "../itemSectionTanggalTugas"; import MenuItemRow from "../menuItemRow"; +import ModalConfirmation from "../ModalConfirmation"; import ModalSelect from "../modalSelect"; import SkeletonTask from "../skeletonTask"; import Text from "../Text"; @@ -25,6 +25,7 @@ type Props = { status: number; dateStart: string; dateEnd: string; + files?: { name: string; extension: string }[]; } export default function SectionTanggalTugasTask({ refreshing, isMemberDivision }: { refreshing: boolean, isMemberDivision: boolean }) { @@ -73,7 +74,7 @@ export default function SectionTanggalTugasTask({ refreshing, isMemberDivision } } else { Toast.show({ type: 'small', text1: response.message, }) } - } catch (error : any ) { + } catch (error: any) { console.error(error); const message = error?.response?.data?.message || "Gagal mengubah data" @@ -111,7 +112,7 @@ export default function SectionTanggalTugasTask({ refreshing, isMemberDivision } } else { Toast.show({ type: 'small', text1: response.message, }) } - } catch (error : any ) { + } catch (error: any) { console.error(error); const message = error?.response?.data?.message || "Gagal menghapus data" @@ -126,7 +127,7 @@ export default function SectionTanggalTugasTask({ refreshing, isMemberDivision } <> Tanggal & Tugas - + { loading ? arrSkeleton.map((item, index) => { @@ -145,6 +146,7 @@ export default function SectionTanggalTugasTask({ refreshing, isMemberDivision } title={item.title} dateStart={item.dateStart} dateEnd={item.dateEnd} + files={item.files ?? []} onPress={() => { setTugas({ id: item.id, @@ -163,68 +165,53 @@ export default function SectionTanggalTugasTask({ refreshing, isMemberDivision } + {((entityUser.role != "user" && entityUser.role != "coadmin") || isMemberDivision) && ( + } + title="Update Status" + onPress={() => { + setModal(false) + setTimeout(() => setSelect(true), 600) + }} + /> + )} - } + icon={} + title="File Tugas" + onPress={() => { + setModal(false); + router.push(`/division/${id}/task/${detail}/tugas-file/${tugas.id}?member=${isMemberDivision}`) + }} + /> + } title="Detail Waktu" onPress={() => { setModal(false); - setTimeout(() => { - setModalDetail(true) - }, 600) + setTimeout(() => setModalDetail(true), 600) }} /> - { - (entityUser.role != "user" && entityUser.role != "coadmin") || isMemberDivision - ? - <> - } - title="Update Status" - onPress={() => { - setModal(false) - setTimeout(() => { - setSelect(true) - }, 600); - }} - /> - } - title="Edit Tugas" - onPress={() => { - setModal(false) - router.push(`./update/${tugas.id}`) - }} - /> - - : - <> - } - - { - (entityUser.role != "user" && entityUser.role != "coadmin") || isMemberDivision - ? - - } - title="Hapus Tugas" - onPress={() => { - setModal(false) - setTimeout(() => { - setShowDeleteModal(true) - }, 600) - }} - /> - - : - <> - } + {((entityUser.role != "user" && entityUser.role != "coadmin") || isMemberDivision) && ( + + } + title="Edit Tugas" + onPress={() => { + setModal(false) + router.push(`./update/${tugas.id}`) + }} + /> + } + title="Hapus Tugas" + onPress={() => { + setModal(false) + setTimeout(() => setShowDeleteModal(true), 600) + }} + /> + + )} { + setCollapsedHeight(0); + setFullHeight(0); + setShouldShowMore(false); + setIsExpanded(false); + }, [content]); + const measureCollapsed = (e: any) => { - if (collapsedHeight === 0) { - setCollapsedHeight(e.nativeEvent.layout.height); - animatedHeight.setValue(e.nativeEvent.layout.height); - } + const h = e.nativeEvent.layout.height; + setCollapsedHeight(h); + animatedHeight.setValue(h); }; const measureFull = (e: any) => { - if (fullHeight === 0) { - setFullHeight(e.nativeEvent.layout.height); - } + setFullHeight(e.nativeEvent.layout.height); }; - // Cek apakah memang perlu "View More" useEffect(() => { if (collapsedHeight > 0 && fullHeight > 0) { - setShouldShowMore(fullHeight > collapsedHeight + 1); // +1 untuk toleransi float + setShouldShowMore(fullHeight > collapsedHeight + 1); } }, [collapsedHeight, fullHeight]); @@ -41,41 +47,34 @@ export default function TextExpandable({ content, maxLines }: { content: string, return ( - {/* Hidden full text for measurement */} {content} - {/* Collapsed text for measurement */} - + {content} - {/* Animated visible text */} - + {content} {shouldShowMore && ( - - - {isExpanded ? 'View Less' : 'View More'} + + + {isExpanded ? 'Sembunyikan' : 'Lihat selengkapnya'} + )} diff --git a/constants/Colors.ts b/constants/Colors.ts index 22215ae..99ed48a 100644 --- a/constants/Colors.ts +++ b/constants/Colors.ts @@ -13,7 +13,7 @@ export const Colors = { tabActive: '#2563EB', header: '#234881', homeGradient: '#346CC4', - dimmed: '#707887ff', + dimmed: '#707887', success: '#40C057', warning: '#FBBF24', error: '#F87171', diff --git a/constants/Styles.ts b/constants/Styles.ts index 6f5753a..976a1aa 100644 --- a/constants/Styles.ts +++ b/constants/Styles.ts @@ -813,6 +813,109 @@ const Styles = StyleSheet.create({ width: '48.5%', marginBottom: 10, }, + sectionCard: { + borderRadius: 12, + padding: 16, + borderWidth: 1, + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + }, + sectionHeaderRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 10, + }, + sectionIconBox: { + width: 30, + height: 30, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + }, + badgeCol: { + alignItems: 'center', + gap: 6, + }, + progressBadge: { + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 5, + borderWidth: 1, + alignItems: 'center', + }, + taskCountBadge: { + borderRadius: 6, + paddingHorizontal: 7, + paddingVertical: 2, + }, + textProgressPercent: { + fontSize: 22, + fontWeight: 'bold', + lineHeight: 28, + }, + progressTrack: { + height: 8, + borderRadius: 4, + overflow: 'hidden', + }, + progressFill: { + height: '100%', + borderRadius: 4, + }, + reportContent: { + borderLeftWidth: 3, + paddingLeft: 12, + }, + expandBtn: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'flex-start', + marginTop: 8, + gap: 4, + }, + fileGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + fileCard: { + width: '48%', + borderRadius: 10, + borderWidth: 1, + padding: 12, + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + sectionActionRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + sectionBadge: { + borderRadius: 10, + paddingHorizontal: 8, + paddingVertical: 2, + }, + positionBadge: { + borderRadius: 20, + paddingHorizontal: 8, + paddingVertical: 3, + }, + listItemCard: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'transparent', + borderRadius: 10, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 10, + gap: 12, + }, flex1: { flex: 1 }, diff --git a/lib/api.ts b/lib/api.ts index ffb7da1..a744165 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -357,6 +357,28 @@ export const apiDeleteProjectMember = async (data: { user: string, idUser: strin return response.data }; +export const apiGetProjectTaskFile = async ({ user, id }: { user: string, id: string }) => { + const response = await api.get(`/mobile/project/task/file/${id}`, { params: { user } }) + return response.data; +}; + +export const apiAddProjectTaskFile = async ({ data, id }: { data: FormData, id: string }) => { + const response = await api.post(`/mobile/project/task/file/${id}`, data, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return response.data; +}; + +export const apiLinkProjectTaskFile = async ({ user, idFile, id }: { user: string, idFile: string, id: string }) => { + const response = await api.patch(`/mobile/project/task/file/${id}`, { user, idFile }) + return response.data; +}; + +export const apiDeleteProjectTaskFile = async (data: { user: string }, id: string) => { + const response = await api.delete(`/mobile/project/task/file/${id}`, { data }) + return response.data; +}; + export const apiAddMemberProject = async ({ data, id }: { data: { user: string, member: any[] }, id: string }) => { const response = await api.post(`/mobile/project/${id}/member`, data) @@ -664,6 +686,28 @@ export const apiAddFileTask = async ({ data, id }: { data: FormData, id: string return response.data; }; +export const apiGetTugasTaskFile = async ({ user, id }: { user: string, id: string }) => { + const response = await api.get(`/mobile/task/tugas/file/${id}`, { params: { user } }) + return response.data; +}; + +export const apiAddTugasTaskFile = async ({ data, id }: { data: FormData, id: string }) => { + const response = await api.post(`/mobile/task/tugas/file/${id}`, data, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return response.data; +}; + +export const apiLinkTugasTaskFile = async ({ user, idFile, id }: { user: string, idFile: string, id: string }) => { + const response = await api.patch(`/mobile/task/tugas/file/${id}`, { user, idFile }) + return response.data; +}; + +export const apiDeleteTugasTaskFile = async (data: { user: string }, id: string) => { + const response = await api.delete(`/mobile/task/tugas/file/${id}`, { data }) + return response.data; +}; + export const apiEditTask = async (data: { title: string, user: string }, id: string) => { const response = await api.put(`/mobile/task/${id}`, data) return response.data;