From b61cd516280e61a1e818d33d4ea1b59f101595ba Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Wed, 6 May 2026 16:22:52 +0800 Subject: [PATCH] feat: redesign section progress, report, link, file, dan cancel pada project & division/task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SectionProgress: progress bar animated, badge persentase, label status, task count - SectionReport: header ikon, left accent border, TextExpandable dengan label Indonesia - SectionLink: tap langsung buka URL, ikon per domain, long press untuk hapus - SectionFile: icon container konsisten 30×30 di semua section - SectionCancel: card subtle dengan warna error, konsisten dengan visual language baru - TextExpandable: fix bug show/hide tidak muncul setelah content diupdate - Tambah 14 style class baru di Styles.ts untuk menggantikan inline style - Terapkan semua perubahan ke fitur division/task - Fix menu "Edit Tugas" di sectionTanggalTugasTask yang terpotong karena overflow Co-Authored-By: Claude Sonnet 4.6 --- .../(fitur-division)/task/[detail]/index.tsx | 19 +- app/(application)/project/[id]/index.tsx | 19 +- components/itemSectionTanggalTugas.tsx | 145 ++++++------- components/project/itemSectionLink.tsx | 100 +++++++++ components/project/sectionFile.tsx | 201 ++++++++---------- components/project/sectionLink.tsx | 43 ++-- components/project/sectionReportProject.tsx | 47 ++-- components/project/sectionTanggalTugas.tsx | 62 +++--- components/sectionCancel.tsx | 39 ++-- components/sectionProgress.tsx | 86 ++++++-- components/task/sectionFileTask.tsx | 89 +++++--- components/task/sectionLinkTask.tsx | 115 ++++------ components/task/sectionReportTask.tsx | 45 ++-- components/task/sectionTanggalTugasTask.tsx | 107 ++++------ components/textExpandable.tsx | 51 +++-- constants/Styles.ts | 78 +++++++ 16 files changed, 736 insertions(+), 510 deletions(-) create mode 100644 components/project/itemSectionLink.tsx 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)/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/components/itemSectionTanggalTugas.tsx b/components/itemSectionTanggalTugas.tsx index 5cffd5e..b5763e3 100644 --- a/components/itemSectionTanggalTugas.tsx +++ b/components/itemSectionTanggalTugas.tsx @@ -19,12 +19,11 @@ type Props = { onPress?: () => void } -// estimasi lebar chip berdasarkan panjang teks -const CHAR_W = 6.5 // lebar rata-rata per karakter (font size 10) -const ICON_W = 17 // icon 13px + margin 4px -const PAD_H = 16 // paddingHorizontal 8 * 2 +const CHAR_W = 6.5 +const ICON_W = 17 +const PAD_H = 16 const GAP = 6 -const PLUS_W = 72 // lebar chip "+X lainnya" +const PLUS_W = 72 function estimateChipWidth(label: string) { return PAD_H + ICON_W + label.length * CHAR_W @@ -65,92 +64,90 @@ function getFileIcon(extension: string): keyof typeof MaterialCommunityIcons.gly return 'file-outline' } -const chipStyle = (colors: any) => ({ - flexDirection: 'row' as const, - alignItems: 'center' as const, - backgroundColor: colors.dimmed + '5', - borderRadius: 6, - borderWidth: 0.5, - borderColor: colors.icon + '20', - paddingHorizontal: 8, - paddingVertical: 4, -}) - export default function ItemSectionTanggalTugas({ done, title, dateStart, dateEnd, files = [], onPress }: Props) { - const { colors } = useTheme() + const { colors, activeTheme } = useTheme() const [containerWidth, setContainerWidth] = useState(0) const { visible, extra } = getVisibleChips(files, containerWidth) - function onRowLayout(e: LayoutChangeEvent) { + 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 ( - + + {/* Accent bar kiri */} + {done !== undefined && ( + + )} - {/* Status */} - - {done != undefined && ( - done ? ( - <> - - Selesai - - ) : ( - <> - - Belum Selesai - - ) - )} - + {/* Konten */} + - {/* Judul tugas */} - - - - - {title} - + {/* Judul + badge status */} + + {title} + {done !== undefined && ( + + + {done ? 'Selesai' : 'Belum Selesai'} + + + )} - - {/* Tanggal */} - - - Tanggal Mulai - - {dateStart} - + {/* Tanggal */} + 0 ? 8 : 0 }}> + + {dateStart} + + {dateEnd} - - Tanggal Berakhir - - {dateEnd} - - - - {/* Lampiran file */} - {files.length > 0 && ( - - - - - {files.length} Lampiran - - + {/* 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 ( - + 0 && ( - + +{extra} lainnya )} - - )} - + )} + ) } 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/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 38c7a81..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"; @@ -92,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" @@ -112,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" @@ -126,7 +126,7 @@ export default function SectionTanggalTugasProject({ status, member, refreshing Tanggal & Tugas - + { loading ? arrSkeleton.map((item, index) => { @@ -166,14 +166,16 @@ export default function SectionTanggalTugasProject({ status, member, refreshing title="Menu" > - } - title="Detail Waktu" - onPress={() => { - setModal(false); - setTimeout(() => setModalDetail(true), 600) - }} - /> + {(member || (entityUser.role != "user" && entityUser.role != "coadmin")) && status != 3 && ( + } + title="Update Status" + onPress={() => { + setModal(false); + setTimeout(() => setSelect(true), 600) + }} + /> + )} } title="File Tugas" @@ -182,29 +184,25 @@ export default function SectionTanggalTugasProject({ status, member, refreshing router.push(`/project/${id}/tugas-file/${tugas.id}?member=${member}`); }} /> - {(member || (entityUser.role != "user" && entityUser.role != "coadmin")) && status != 3 && ( - <> - } - title="Update Status" - onPress={() => { - setModal(false); - setTimeout(() => setSelect(true), 600) - }} - /> - } - title="Edit Tugas" - onPress={() => { - setModal(false); - router.push(`/project/update/${tugas.id}`); - }} - /> - - )} + } + title="Detail Waktu" + onPress={() => { + setModal(false); + 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" diff --git a/components/sectionCancel.tsx b/components/sectionCancel.tsx index a3120d0..dee06fd 100644 --- a/components/sectionCancel.tsx +++ b/components/sectionCancel.tsx @@ -1,12 +1,11 @@ -import { ColorsStatus } from "@/constants/ColorsStatus"; import Styles from "@/constants/Styles"; import { useTheme } from "@/providers/ThemeProvider"; -import { AntDesign } from "@expo/vector-icons"; -import Text from "./Text"; +import { MaterialCommunityIcons } from "@expo/vector-icons"; import { View } from "react-native"; +import Text from "./Text"; type Props = { - text?: string, + text?: string title?: string } @@ -14,18 +13,26 @@ export default function SectionCancel({ text, title }: Props) { const { colors } = useTheme(); return ( - - - - {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/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 8b4df51..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"; @@ -74,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" @@ -112,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" @@ -127,7 +127,7 @@ export default function SectionTanggalTugasTask({ refreshing, isMemberDivision } <> Tanggal & Tugas - + { loading ? arrSkeleton.map((item, index) => { @@ -165,22 +165,16 @@ export default function SectionTanggalTugasTask({ refreshing, isMemberDivision } - - } - title="Detail Waktu" - onPress={() => { - setModal(false); - setTimeout(() => { - setModalDetail(true) - }, 600) - }} - /> + {((entityUser.role != "user" && entityUser.role != "coadmin") || isMemberDivision) && ( + } + title="Update Status" + onPress={() => { + setModal(false) + setTimeout(() => setSelect(true), 600) + }} + /> + )} } title="File Tugas" @@ -189,52 +183,35 @@ export default function SectionTanggalTugasTask({ refreshing, isMemberDivision } router.push(`/division/${id}/task/${detail}/tugas-file/${tugas.id}?member=${isMemberDivision}`) }} /> - { - (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}`) - }} - /> - - : - <> - } - + } + title="Detail Waktu" + onPress={() => { + setModal(false); + setTimeout(() => setModalDetail(true), 600) + }} + /> - { - (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/Styles.ts b/constants/Styles.ts index 6f5753a..6348a68 100644 --- a/constants/Styles.ts +++ b/constants/Styles.ts @@ -813,6 +813,84 @@ 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, + }, flex1: { flex: 1 },