From e48456ea7f0d1539458ff2acd2c87a59fa631b42 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 7 May 2026 16:04:02 +0800 Subject: [PATCH 1/3] feat: tambah fitur approval task pada project dan divisi - tambah komponen ModalRiwayatApproval dan ModalTolakApproval - update itemSectionTanggalTugas untuk mendukung status menunggu persetujuan - update sectionTanggalTugas (project) dan sectionTanggalTugasTask (divisi) dengan alur approval lengkap - tambah API approval project task dan division task di lib/api.ts - tambah toggle approver di headerMemberDetail dan tampilkan badge approver di detail member - update carouselHome untuk dispatch isApprover ke Redux - update drawerBottom untuk mendukung scroll pada modal - ganti label 'Belum dimulai' menjadi 'Belum ada tugas yang diselesaikan' --- .../(fitur-division)/task/[detail]/index.tsx | 2 +- app/(application)/member/[id].tsx | 22 +- components/ModalRiwayatApproval.tsx | 140 +++++++ components/ModalTolakApproval.tsx | 78 ++++ components/drawerBottom.tsx | 7 +- components/home/carouselHome.tsx | 2 +- components/home/carouselHome2.tsx | 2 +- components/itemSectionTanggalTugas.tsx | 26 +- components/member/headerMemberDetail.tsx | 71 +++- components/project/sectionTanggalTugas.tsx | 354 +++++++++++------ components/sectionProgress.tsx | 2 +- components/task/sectionTanggalTugasTask.tsx | 359 +++++++++++------- lib/api.ts | 35 ++ 13 files changed, 811 insertions(+), 289 deletions(-) create mode 100644 components/ModalRiwayatApproval.tsx create mode 100644 components/ModalTolakApproval.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 029b676..f1d7118 100644 --- a/app/(application)/division/[id]/(fitur-division)/task/[detail]/index.tsx +++ b/app/(application)/division/[id]/(fitur-division)/task/[detail]/index.tsx @@ -154,7 +154,7 @@ export default function DetailTaskDivision() { } - + diff --git a/app/(application)/member/[id].tsx b/app/(application)/member/[id].tsx index 71c0165..4b5cbab 100644 --- a/app/(application)/member/[id].tsx +++ b/app/(application)/member/[id].tsx @@ -30,6 +30,7 @@ type Props = { group: string, img: string, isActive: boolean, + isApprover: boolean, role: string } @@ -89,7 +90,7 @@ export default function MemberDetail() { showBack={true} onPressLeft={() => router.back()} right={ - (entityUser.role != "user") && isEdit ? : <> + (entityUser.role != "user") && isEdit ? : <> } /> ) @@ -130,11 +131,20 @@ export default function MemberDetail() { Informasi - + + {data?.isApprover && ( + + )} + + { loading ? diff --git a/components/ModalRiwayatApproval.tsx b/components/ModalRiwayatApproval.tsx new file mode 100644 index 0000000..3cc8523 --- /dev/null +++ b/components/ModalRiwayatApproval.tsx @@ -0,0 +1,140 @@ +import Styles from "@/constants/Styles" +import { useTheme } from "@/providers/ThemeProvider" +import { MaterialCommunityIcons } from "@expo/vector-icons" +import { useRef, useState } from "react" +import { ScrollView, View } from "react-native" +import DrawerBottom from "./drawerBottom" +import Skeleton from "./skeleton" +import Text from "./Text" + +type ApprovalRecord = { + id: string + status: number // 0=pending, 1=approved, 2=rejected + note?: string + submitter: { name: string } + approver?: { name: string } + createdAt: string +} + +type Props = { + isVisible: boolean + setVisible: (value: boolean) => void + data: ApprovalRecord[] + loading: boolean +} + +function ApprovalStatusBadge({ status }: { status: number }) { + const { colors } = useTheme() + const config = + status === 1 + ? { label: 'Disetujui', color: colors.success } + : status === 2 + ? { label: 'Ditolak', color: colors.error } + : { label: 'Menunggu', color: '#FFA94D' } + + return ( + + + {config.label} + + + ) +} + +export default function ModalRiwayatApproval({ isVisible, setVisible, data, loading }: Props) { + const { colors } = useTheme() + const arrSkeleton = Array.from({ length: 3 }) + const scrollRef = useRef(null) + const [scrollOffset, setScrollOffset] = useState(0) + + return ( + scrollRef.current?.scrollTo(p)} + > + setScrollOffset(nativeEvent.contentOffset.y)} + scrollEventThrottle={16} + > + {loading ? ( + arrSkeleton.map((_, i) => ( + + + + )) + ) : data.length > 0 ? ( + data.map((item, index) => ( + + {/* Status + tanggal */} + + + + {item.createdAt} + + + + {/* Pengaju */} + + + Diajukan Oleh: + {item.submitter.name} + + + {/* Approver */} + + + Disetujui Oleh: + + {item.approver?.name ?? '-'} + + + + {/* Catatan penolakan */} + {item.note && ( + + + Alasan Penolakan + + + {item.note} + + + )} + + )) + ) : ( + + Belum ada riwayat persetujuan + + )} + + + ) +} diff --git a/components/ModalTolakApproval.tsx b/components/ModalTolakApproval.tsx new file mode 100644 index 0000000..3f56707 --- /dev/null +++ b/components/ModalTolakApproval.tsx @@ -0,0 +1,78 @@ +import Styles from "@/constants/Styles" +import { useTheme } from "@/providers/ThemeProvider" +import { useState } from "react" +import { TouchableOpacity, View } from "react-native" +import DrawerBottom from "./drawerBottom" +import { InputForm } from "./inputForm" +import Text from "./Text" + +type Props = { + isVisible: boolean + setVisible: (value: boolean) => void + onTolak: (note: string) => void + loading?: boolean +} + +export default function ModalTolakApproval({ isVisible, setVisible, onTolak, loading }: Props) { + const { colors } = useTheme() + const [note, setNote] = useState('') + const [error, setError] = useState(false) + + function handleClose(value: boolean) { + setNote('') + setError(false) + setVisible(value) + } + + function handleSubmit() { + if (!note.trim()) { + setError(true) + return + } + onTolak(note.trim()) + setNote('') + setError(false) + } + + return ( + + + + + + + + {loading ? 'Memproses...' : 'Tolak Tugas'} + + + + ) +} diff --git a/components/drawerBottom.tsx b/components/drawerBottom.tsx index 9b964c9..3eb96e2 100644 --- a/components/drawerBottom.tsx +++ b/components/drawerBottom.tsx @@ -14,9 +14,11 @@ type Props = { height?: number backdropPressable?: boolean keyboard?: boolean + scrollOffset?: number + scrollTo?: (p: any) => void } -export default function DrawerBottom({ isVisible, setVisible, title, children, animation, height, backdropPressable = true, keyboard = false }: Props) { +export default function DrawerBottom({ isVisible, setVisible, title, children, animation, height, backdropPressable = true, keyboard = false, scrollOffset, scrollTo }: Props) { const tinggiScreen = Dimensions.get("window").height; const { colors } = useTheme(); const tinggiInput = height != undefined ? height : 25 @@ -38,6 +40,9 @@ export default function DrawerBottom({ isVisible, setVisible, title, children, a backdropTransitionOutTiming={500} useNativeDriverForBackdrop={true} propagateSwipe={true} + scrollTo={scrollTo} + scrollOffset={scrollOffset} + scrollOffsetMax={200} > { keyboard ? diff --git a/components/home/carouselHome.tsx b/components/home/carouselHome.tsx index d570cc1..80c5e20 100644 --- a/components/home/carouselHome.tsx +++ b/components/home/carouselHome.tsx @@ -36,7 +36,7 @@ export default function CaraouselHome({ refreshing }: { refreshing: boolean }) { async function handleUser() { const hasil = await decryptToken(String(token?.current)) const response = await apiGetProfile({ id: hasil }) - dispatch(setEntityUser({ role: response.data.idUserRole, admin: false })) + dispatch(setEntityUser({ role: response.data.idUserRole, admin: false, isApprover: response.data.isApprover ?? false })) } useEffect(() => { diff --git a/components/home/carouselHome2.tsx b/components/home/carouselHome2.tsx index a73ac74..501385f 100644 --- a/components/home/carouselHome2.tsx +++ b/components/home/carouselHome2.tsx @@ -59,7 +59,7 @@ export default function CaraouselHome2({ refreshing }: { refreshing: boolean }) // Sync User Role to Redux useEffect(() => { if (profile) { - dispatch(setEntityUser({ role: profile.idUserRole, admin: false })) + dispatch(setEntityUser({ role: profile.idUserRole, admin: false, isApprover: profile.isApprover ?? false })) } }, [profile, dispatch]) diff --git a/components/itemSectionTanggalTugas.tsx b/components/itemSectionTanggalTugas.tsx index b5763e3..423a94c 100644 --- a/components/itemSectionTanggalTugas.tsx +++ b/components/itemSectionTanggalTugas.tsx @@ -11,7 +11,7 @@ type FileItem = { } type Props = { - done?: boolean + status?: number // 0=belum selesai, 1=selesai, 2=menunggu persetujuan title: string dateStart: string dateEnd: string @@ -64,7 +64,15 @@ function getFileIcon(extension: string): keyof typeof MaterialCommunityIcons.gly return 'file-outline' } -export default function ItemSectionTanggalTugas({ done, title, dateStart, dateEnd, files = [], onPress }: Props) { +const AMBER = '#FFA94D' + +function getStatusStyle(status: number | undefined, successColor: string, dimmed: string) { + if (status === 1) return { accent: successColor, badge: successColor + '25', text: successColor, label: 'Selesai' } + if (status === 2) return { accent: AMBER, badge: AMBER + '25', text: AMBER, label: 'Menunggu Persetujuan' } + return { accent: dimmed + '80', badge: dimmed + '18', text: dimmed, label: 'Belum Selesai' } +} + +export default function ItemSectionTanggalTugas({ status, title, dateStart, dateEnd, files = [], onPress }: Props) { const { colors, activeTheme } = useTheme() const [containerWidth, setContainerWidth] = useState(0) @@ -77,7 +85,7 @@ export default function ItemSectionTanggalTugas({ done, title, dateStart, dateEn const dimmed = colors.dimmed.slice(0, 7) const successColor = activeTheme === 'dark' ? '#51CF66' : colors.success - const accentColor = done === true ? successColor : dimmed + '80' + const statusStyle = getStatusStyle(status, successColor, dimmed) return ( {/* Accent bar kiri */} - {done !== undefined && ( - + {status !== undefined && ( + )} {/* Konten */} @@ -103,16 +111,16 @@ export default function ItemSectionTanggalTugas({ done, title, dateStart, dateEn {/* Judul + badge status */} {title} - {done !== undefined && ( + {status !== undefined && ( - - {done ? 'Selesai' : 'Belum Selesai'} + + {statusStyle.label} )} diff --git a/components/member/headerMemberDetail.tsx b/components/member/headerMemberDetail.tsx index 3b8044c..7dd153b 100644 --- a/components/member/headerMemberDetail.tsx +++ b/components/member/headerMemberDetail.tsx @@ -1,5 +1,5 @@ import Styles from "@/constants/Styles" -import { apiDeleteUser } from "@/lib/api" +import { apiDeleteUser, apiToggleApprover } from "@/lib/api" import { setUpdateMember } from "@/lib/memberSlice" import { useAuthSession } from "@/providers/AuthProvider" import { useTheme } from "@/providers/ThemeProvider" @@ -16,14 +16,17 @@ import MenuItemRow from "../menuItemRow" type Props = { active: any, - id: string + id: string, + isApprover: boolean, } -export default function HeaderRightMemberDetail({ active, id }: Props) { +export default function HeaderRightMemberDetail({ active, id, isApprover }: Props) { const { token, decryptToken } = useAuthSession() const [isVisible, setVisible] = useState(false) const update = useSelector((state: any) => state.memberUpdate) - const [showModal, setShowModal] = useState(false) + const entityUser = useSelector((state: any) => state.user) + const [showModalActive, setShowModalActive] = useState(false) + const [showModalApprover, setShowModalApprover] = useState(false) const { colors } = useTheme(); const dispatch = useDispatch() @@ -37,17 +40,36 @@ export default function HeaderRightMemberDetail({ active, id }: Props) { } else { Toast.show({ type: 'small', text1: response.message, }) } - } catch (error : any ) { + } catch (error: any) { console.error(error); const message = error?.response?.data?.message || "Gagal mengupdate data" - Toast.show({ type: 'small', text1: message }) } finally { setVisible(false) } - } + async function handleToggleApprover() { + try { + const hasil = await decryptToken(String(token?.current)) + const response = await apiToggleApprover({ user: hasil, isApprover: !isApprover }, id) + if (response.success) { + Toast.show({ type: 'small', text1: response.message }) + dispatch(setUpdateMember(!update)) + } else { + Toast.show({ type: 'small', text1: response.message }) + } + } catch (error: any) { + console.error(error); + const message = error?.response?.data?.message || "Gagal mengupdate data" + Toast.show({ type: 'small', text1: message }) + } finally { + setShowModalApprover(false) + } + } + + const canManageApprover = ['supadmin', 'developer'].includes(entityUser.role) + return ( <> { setVisible(true) }} /> @@ -55,12 +77,10 @@ export default function HeaderRightMemberDetail({ active, id }: Props) { } - title={active ? "Non Aktifkan" : "Aktifkan"} + title={active ? "Nonaktifkan" : "Aktifkan"} onPress={() => { setVisible(false) - setTimeout(() => { - setShowModal(true) - }, 600) + setTimeout(() => setShowModalActive(true), 600) }} /> + {canManageApprover && ( + } + title={isApprover ? "Revoke Approver" : "Jadikan Approver"} + color={colors.text} + onPress={() => { + setVisible(false) + setTimeout(() => setShowModalApprover(true), 600) + }} + /> + )} { - setShowModal(false) + setShowModalActive(false) handleActive() }} - onCancel={() => setShowModal(false)} + onCancel={() => setShowModalActive(false)} + confirmText="Konfirmasi" + cancelText="Batal" + /> + + setShowModalApprover(false)} confirmText="Konfirmasi" cancelText="Batal" /> diff --git a/components/project/sectionTanggalTugas.tsx b/components/project/sectionTanggalTugas.tsx index 9b553d8..19483f6 100644 --- a/components/project/sectionTanggalTugas.tsx +++ b/components/project/sectionTanggalTugas.tsx @@ -1,5 +1,5 @@ import Styles from "@/constants/Styles"; -import { apiDeleteProjectTask, apiGetProjectOne, apiUpdateStatusProjectTask } from "@/lib/api"; +import { apiApproveRejectProjectTask, apiDeleteProjectTask, apiGetProjectOne, apiGetProjectTaskApprovals, apiSubmitProjectTask } from "@/lib/api"; import { setUpdateProject } from "@/lib/projectUpdate"; import { useAuthSession } from "@/providers/AuthProvider"; import { useTheme } from "@/providers/ThemeProvider"; @@ -13,7 +13,8 @@ import DrawerBottom from "../drawerBottom"; import ItemSectionTanggalTugas from "../itemSectionTanggalTugas"; import MenuItemRow from "../menuItemRow"; import ModalConfirmation from "../ModalConfirmation"; -import ModalSelect from "../modalSelect"; +import ModalRiwayatApproval from "../ModalRiwayatApproval"; +import ModalTolakApproval from "../ModalTolakApproval"; import SkeletonTask from "../skeletonTask"; import Text from "../Text"; import ModalListDetailTugasProject from "./modalListDetailTugasProject"; @@ -22,41 +23,52 @@ type Props = { id: string; title: string; desc: string; - status: 1; + status: number; dateStart: string; dateEnd: string; createdAt: string; files?: { name: string; extension: string }[]; }; +type ApprovalRecord = { + id: string + status: number + note?: string + submitter: { name: string } + approver?: { name: string } + createdAt: string +} + export default function SectionTanggalTugasProject({ status, member, refreshing }: { status: number | undefined, member: boolean, refreshing?: boolean }) { const { colors } = useTheme(); const entityUser = useSelector((state: any) => state.user) const dispatch = useDispatch() const update = useSelector((state: any) => state.projectUpdate) const [isModal, setModal] = useState(false); - const [isSelect, setSelect] = useState(false); const { token, decryptToken } = useAuthSession(); const [modalDetail, setModalDetail] = useState(false) + const [modalRiwayat, setModalRiwayat] = useState(false) + const [modalTolak, setModalTolak] = useState(false) + const [modalKonfirmasiSetujui, setModalKonfirmasiSetujui] = useState(false) + const [modalPersetujuan, setModalPersetujuan] = useState(false) const { id } = useLocalSearchParams<{ id: string }>(); const [data, setData] = useState([]); const [loading, setLoading] = useState(true) + const [loadingAction, setLoadingAction] = useState(false) + const [loadingRiwayat, setLoadingRiwayat] = useState(false) + const [riwayatData, setRiwayatData] = useState([]) const arrSkeleton = Array.from({ length: 5 }); - const [tugas, setTugas] = useState({ - id: '', - status: 0, - }) + const [tugas, setTugas] = useState({ id: '', status: 0 }) const [showDeleteModal, setShowDeleteModal] = useState(false) + const isApprover = entityUser.isApprover || ['supadmin', 'developer'].includes(entityUser.role) + const isAdmin = entityUser.role !== 'user' && entityUser.role !== 'coadmin' + async function handleLoad(loading: boolean) { try { setLoading(loading) const hasil = await decryptToken(String(token?.current)); - const response = await apiGetProjectOne({ - user: hasil, - cat: "task", - id: id, - }); + const response = await apiGetProjectOne({ user: hasil, cat: "task", id: id }); setData(response.data); } catch (error) { console.error(error); @@ -65,178 +77,268 @@ export default function SectionTanggalTugasProject({ status, member, refreshing } } - useEffect(() => { - handleLoad(false); - }, [update.task]); + useEffect(() => { handleLoad(false) }, [update.task]); + useEffect(() => { if (refreshing) handleLoad(false) }, [refreshing]); + useEffect(() => { handleLoad(true) }, []); - useEffect(() => { - if (refreshing) - handleLoad(false); - }, [refreshing]); - - useEffect(() => { - handleLoad(true); - }, []); - - - async function handleUpdate(status: number) { + async function handleLoadRiwayat() { try { - const hasil = await decryptToken(String(token?.current)); - const response = await apiUpdateStatusProjectTask({ - user: hasil, - idProject: id, - status: status, - }, tugas.id); + setLoadingRiwayat(true) + const hasil = await decryptToken(String(token?.current)) + const response = await apiGetProjectTaskApprovals({ user: hasil, id: tugas.id }) + setRiwayatData(response.data ?? []) + } catch (error) { + console.error(error) + } finally { + setLoadingRiwayat(false) + } + } + + async function handleSubmitAjukan() { + try { + setLoadingAction(true) + const hasil = await decryptToken(String(token?.current)) + const response = await apiSubmitProjectTask({ user: hasil, id: tugas.id }) if (response.success) { dispatch(setUpdateProject({ ...update, progress: !update.progress, task: !update.task })) - setSelect(false); - Toast.show({ type: 'small', text1: 'Berhasil mengubah data', }) + Toast.show({ type: 'small', text1: 'Berhasil mengajukan task untuk persetujuan' }) + } else { + Toast.show({ type: 'small', text1: response.message }) } } catch (error: any) { - console.error(error); - const message = error?.response?.data?.message || "Gagal mengubah data" - + const message = error?.response?.data?.message || "Gagal mengajukan persetujuan" Toast.show({ type: 'small', text1: message }) + } finally { + setLoadingAction(false) + setModal(false) + } + } + + async function handleSetujui() { + try { + setLoadingAction(true) + const hasil = await decryptToken(String(token?.current)) + const response = await apiApproveRejectProjectTask({ user: hasil, id: tugas.id, action: 'approve' }) + if (response.success) { + dispatch(setUpdateProject({ ...update, progress: !update.progress, task: !update.task })) + Toast.show({ type: 'small', text1: 'Tugas berhasil disetujui' }) + } else { + Toast.show({ type: 'small', text1: response.message }) + } + } catch (error: any) { + const message = error?.response?.data?.message || "Gagal menyetujui tugas" + Toast.show({ type: 'small', text1: message }) + } finally { + setLoadingAction(false) + setModalKonfirmasiSetujui(false) + } + } + + async function handleTolak(note: string) { + try { + setLoadingAction(true) + const hasil = await decryptToken(String(token?.current)) + const response = await apiApproveRejectProjectTask({ user: hasil, id: tugas.id, action: 'reject', note }) + if (response.success) { + dispatch(setUpdateProject({ ...update, progress: !update.progress, task: !update.task })) + Toast.show({ type: 'small', text1: 'Tugas berhasil ditolak' }) + } else { + Toast.show({ type: 'small', text1: response.message }) + } + } catch (error: any) { + const message = error?.response?.data?.message || "Gagal menolak tugas" + Toast.show({ type: 'small', text1: message }) + } finally { + setLoadingAction(false) + setModalTolak(false) } } async function handleDelete() { try { const hasil = await decryptToken(String(token?.current)); - const response = await apiDeleteProjectTask({ - user: hasil, - idProject: id, - }, tugas.id); + const response = await apiDeleteProjectTask({ user: hasil, idProject: id }, tugas.id); if (response.success) { dispatch(setUpdateProject({ ...update, progress: !update.progress, task: !update.task })) - setModal(false); - Toast.show({ type: 'small', text1: 'Berhasil menghapus data', }) + Toast.show({ type: 'small', text1: 'Berhasil menghapus data' }) } } catch (error: any) { - console.error(error); const message = error?.response?.data?.message || "Gagal menghapus data" - Toast.show({ type: 'small', text1: message }) } } + const canTakeAction = member || isAdmin + const showAjukan = (member || isApprover) && tugas.status === 0 && status !== 3 + const showApproverActions = isApprover && tugas.status === 2 + return ( <> - - Tanggal & Tugas - + Tanggal & Tugas - { - loading ? - arrSkeleton.map((item, index) => { - return ( - - ) - }) - : - data.length > 0 - ? - data.map((item, index) => { - return ( - { - setTugas({ id: item.id, status: item.status }) - setModal(true) - }} - /> - ); - }) - : - Tidak ada tugas + {loading + ? arrSkeleton.map((_, index) => ) + : data.length > 0 + ? data.map((item, index) => ( + { + setTugas({ id: item.id, status: item.status }) + setModal(true) + }} + /> + )) + : Tidak ada tugas } - - - {(member || (entityUser.role != "user" && entityUser.role != "coadmin")) && status != 3 && ( - } - title="Update Status" - onPress={() => { - setModal(false); - setTimeout(() => setSelect(true), 600) - }} - /> - )} + {/* Drawer menu */} + + + + {/* Baris 1 — selalu tampil */} } title="File Tugas" onPress={() => { - setModal(false); - router.push(`/project/${id}/tugas-file/${tugas.id}?member=${member}`); + setModal(false) + router.push(`/project/${id}/tugas-file/${tugas.id}?member=${member}`) }} /> } title="Detail Waktu" onPress={() => { - setModal(false); + setModal(false) setTimeout(() => setModalDetail(true), 600) }} /> - - {(member || (entityUser.role != "user" && entityUser.role != "coadmin")) && status != 3 && ( - + } + title="Riwayat" + onPress={() => { + setModal(false) + handleLoadRiwayat() + setTimeout(() => setModalRiwayat(true), 600) + }} + /> + + {/* Separator antar baris */} + {(showAjukan || showApproverActions || (canTakeAction && isAdmin && status !== 3)) && ( + + )} + + {/* Baris 2 — semua aksi kondisional dalam satu baris */} + {showAjukan && ( } - title="Edit Tugas" - onPress={() => { - setModal(false); - router.push(`/project/update/${tugas.id}`); - }} - /> - } - title="Hapus Tugas" + icon={} + title="Ajukan Selesai" + disabled={loadingAction} onPress={() => { setModal(false) - setTimeout(() => setShowDeleteModal(true), 600) + setTimeout(() => handleSubmitAjukan(), 600) }} /> - - )} + )} + {showApproverActions && ( + } + title="Persetujuan" + disabled={loadingAction} + onPress={() => { + setModal(false) + setTimeout(() => setModalPersetujuan(true), 600) + }} + /> + )} + {canTakeAction && isAdmin && status !== 3 && ( + <> + } + title="Edit Tugas" + onPress={() => { + setModal(false) + router.push(`/project/update/${tugas.id}`) + }} + /> + } + title="Hapus Tugas" + onPress={() => { + setModal(false) + setTimeout(() => setShowDeleteModal(true), 600) + }} + /> + + )} + + + + {/* Drawer persetujuan */} + + + } + title="Setujui" + color={colors.success} + disabled={loadingAction} + onPress={() => { + setModalPersetujuan(false) + setTimeout(() => setModalKonfirmasiSetujui(true), 600) + }} + /> + } + title="Tolak" + color={colors.error} + disabled={loadingAction} + onPress={() => { + setModalPersetujuan(false) + setTimeout(() => setModalTolak(true), 600) + }} + /> + { - setShowDeleteModal(false) - handleDelete() - }} + onConfirm={() => { setShowDeleteModal(false); handleDelete() }} onCancel={() => setShowDeleteModal(false)} confirmText="Hapus" cancelText="Batal" /> - { setSelect(false) }} - onSelect={(value) => { - handleUpdate(Number(value.val)) - }} - title="Status" - open={isSelect} - valChoose={String(tugas.status)} + setModalKonfirmasiSetujui(false)} + confirmText="Setujui" + cancelText="Batal" + /> + + + + 0 ? 'Sedang berlangsung' - : 'Belum dimulai'; + : 'Belum ada tugas yang diselesaikan'; useEffect(() => { animatedWidth.setValue(0); diff --git a/components/task/sectionTanggalTugasTask.tsx b/components/task/sectionTanggalTugasTask.tsx index 34d48d4..5a4e013 100644 --- a/components/task/sectionTanggalTugasTask.tsx +++ b/components/task/sectionTanggalTugasTask.tsx @@ -1,5 +1,5 @@ import Styles from "@/constants/Styles"; -import { apiDeleteTaskTugas, apiGetTaskOne, apiUpdateStatusTaskDivision } from "@/lib/api"; +import { apiApproveRejectTaskTugas, apiDeleteTaskTugas, apiGetTaskOne, apiGetTaskTugasApprovals, apiSubmitTaskTugas } from "@/lib/api"; import { setUpdateTask } from "@/lib/taskUpdate"; import { useAuthSession } from "@/providers/AuthProvider"; import { useTheme } from "@/providers/ThemeProvider"; @@ -13,7 +13,8 @@ import DrawerBottom from "../drawerBottom"; import ItemSectionTanggalTugas from "../itemSectionTanggalTugas"; import MenuItemRow from "../menuItemRow"; import ModalConfirmation from "../ModalConfirmation"; -import ModalSelect from "../modalSelect"; +import ModalRiwayatApproval from "../ModalRiwayatApproval"; +import ModalTolakApproval from "../ModalTolakApproval"; import SkeletonTask from "../skeletonTask"; import Text from "../Text"; import ModalListDetailTugasTask from "./modalListDetailTugasTask"; @@ -28,25 +29,41 @@ type Props = { files?: { name: string; extension: string }[]; } -export default function SectionTanggalTugasTask({ refreshing, isMemberDivision }: { refreshing: boolean, isMemberDivision: boolean }) { +type ApprovalRecord = { + id: string + status: number + note?: string + submitter: { name: string } + approver?: { name: string } + createdAt: string +} + +export default function SectionTanggalTugasTask({ refreshing, isMemberDivision, isAdminDivision, status }: { refreshing: boolean, isMemberDivision: boolean, isAdminDivision: boolean, status?: number }) { const { colors } = useTheme() const dispatch = useDispatch() const entityUser = useSelector((state: any) => state.user); const update = useSelector((state: any) => state.taskUpdate) const [isModal, setModal] = useState(false) - const [isSelect, setSelect] = useState(false) const { token, decryptToken } = useAuthSession() const [loading, setLoading] = useState(true) + const [loadingAction, setLoadingAction] = useState(false) + const [loadingRiwayat, setLoadingRiwayat] = useState(false) const arrSkeleton = Array.from({ length: 5 }) const [modalDetail, setModalDetail] = useState(false) + const [modalRiwayat, setModalRiwayat] = useState(false) + const [modalTolak, setModalTolak] = useState(false) + const [modalKonfirmasiSetujui, setModalKonfirmasiSetujui] = useState(false) + const [modalPersetujuan, setModalPersetujuan] = useState(false) + const [riwayatData, setRiwayatData] = useState([]) const { id, detail } = useLocalSearchParams<{ id: string, detail: string }>(); const [data, setData] = useState([]) - const [tugas, setTugas] = useState({ - id: '', - status: 0, - }) + const [tugas, setTugas] = useState({ id: '', status: 0 }) const [showDeleteModal, setShowDeleteModal] = useState(false) + const isApprover = entityUser.isApprover || ['supadmin', 'developer'].includes(entityUser.role) + const isAdmin = entityUser.role !== 'user' && entityUser.role !== 'coadmin' + const canTakeAction = isMemberDivision || isAdmin + async function handleLoad(loading: boolean) { try { setLoading(loading) @@ -60,126 +77,136 @@ export default function SectionTanggalTugasTask({ refreshing, isMemberDivision } } } - async function handleUpdate(status: number) { - try { - const hasil = await decryptToken(String(token?.current)); - const response = await apiUpdateStatusTaskDivision({ - user: hasil, - idProject: detail, - status: status, - }, tugas.id); - if (response.success) { - dispatch(setUpdateTask({ ...update, progress: !update.progress, task: !update.task })) - Toast.show({ type: 'small', text1: 'Berhasil mengubah data', }) - } else { - Toast.show({ type: 'small', text1: response.message, }) - } - } catch (error: any) { - console.error(error); - const message = error?.response?.data?.message || "Gagal mengubah data" + useEffect(() => { handleLoad(false) }, [update.task]) + useEffect(() => { if (refreshing) handleLoad(false) }, [refreshing]) + useEffect(() => { handleLoad(true) }, []) - Toast.show({ type: 'small', text1: message }) + async function handleLoadRiwayat() { + try { + setLoadingRiwayat(true) + const hasil = await decryptToken(String(token?.current)) + const response = await apiGetTaskTugasApprovals({ user: hasil, id: tugas.id }) + setRiwayatData(response.data ?? []) + } catch (error) { + console.error(error) } finally { - setSelect(false) + setLoadingRiwayat(false) } } - useEffect(() => { - handleLoad(false) - }, [update.task]) + async function handleSubmitAjukan() { + try { + setLoadingAction(true) + const hasil = await decryptToken(String(token?.current)) + const response = await apiSubmitTaskTugas({ user: hasil, id: tugas.id }) + if (response.success) { + dispatch(setUpdateTask({ ...update, progress: !update.progress, task: !update.task })) + Toast.show({ type: 'small', text1: 'Berhasil mengajukan task untuk persetujuan' }) + } else { + Toast.show({ type: 'small', text1: response.message }) + } + } catch (error: any) { + Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal mengajukan persetujuan" }) + } finally { + setLoadingAction(false) + setModal(false) + } + } - useEffect(() => { - if (refreshing) - handleLoad(false); - }, [refreshing]); - - - useEffect(() => { - handleLoad(true) - }, []) + async function handleSetujui() { + try { + setLoadingAction(true) + const hasil = await decryptToken(String(token?.current)) + const response = await apiApproveRejectTaskTugas({ user: hasil, id: tugas.id, action: 'approve' }) + if (response.success) { + dispatch(setUpdateTask({ ...update, progress: !update.progress, task: !update.task })) + Toast.show({ type: 'small', text1: 'Tugas berhasil disetujui' }) + } else { + Toast.show({ type: 'small', text1: response.message }) + } + } catch (error: any) { + Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menyetujui tugas" }) + } finally { + setLoadingAction(false) + setModalKonfirmasiSetujui(false) + } + } + async function handleTolak(note: string) { + try { + setLoadingAction(true) + const hasil = await decryptToken(String(token?.current)) + const response = await apiApproveRejectTaskTugas({ user: hasil, id: tugas.id, action: 'reject', note }) + if (response.success) { + dispatch(setUpdateTask({ ...update, progress: !update.progress, task: !update.task })) + Toast.show({ type: 'small', text1: 'Tugas berhasil ditolak' }) + } else { + Toast.show({ type: 'small', text1: response.message }) + } + } catch (error: any) { + Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menolak tugas" }) + } finally { + setLoadingAction(false) + setModalTolak(false) + } + } async function handleDelete() { try { const hasil = await decryptToken(String(token?.current)); - const response = await apiDeleteTaskTugas({ - user: hasil, - idProject: detail, - }, tugas.id); + const response = await apiDeleteTaskTugas({ user: hasil, idProject: detail }, tugas.id); if (response.success) { dispatch(setUpdateTask({ ...update, progress: !update.progress, task: !update.task })) - Toast.show({ type: 'small', text1: 'Berhasil menghapus data', }) + Toast.show({ type: 'small', text1: 'Berhasil menghapus data' }) } 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 data" - - Toast.show({ type: 'small', text1: message }) - } finally { - setModal(false); + Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menghapus data" }) } } + const canApprove = isApprover || isAdminDivision + const showAjukan = (isMemberDivision || canApprove) && tugas.status === 0 && status !== 3 + const showApproverActions = canApprove && tugas.status === 2 return ( <> Tanggal & Tugas - { - loading ? - arrSkeleton.map((item, index) => { - return ( - - ) - }) - : - data.length > 0 - ? - data.map((item, index) => { - return ( - { - setTugas({ - id: item.id, - status: item.status - }) - setModal(true) - }} - /> - ); - }) - : - Tidak ada tugas + {loading + ? arrSkeleton.map((_, index) => ) + : data.length > 0 + ? data.map((item, index) => ( + { + setTugas({ id: item.id, status: item.status }) + setModal(true) + }} + /> + )) + : Tidak ada tugas } - - - {((entityUser.role != "user" && entityUser.role != "coadmin") || isMemberDivision) && ( - } - title="Update Status" - onPress={() => { - setModal(false) - setTimeout(() => setSelect(true), 600) - }} - /> - )} + {/* Drawer menu */} + + + + {/* Baris 1 — selalu tampil */} } title="File Tugas" onPress={() => { - setModal(false); + setModal(false) router.push(`/division/${id}/task/${detail}/tugas-file/${tugas.id}?member=${isMemberDivision}`) }} /> @@ -187,53 +214,129 @@ export default function SectionTanggalTugasTask({ refreshing, isMemberDivision } icon={} title="Detail Waktu" onPress={() => { - setModal(false); + setModal(false) setTimeout(() => setModalDetail(true), 600) }} /> + } + title="Riwayat" + onPress={() => { + setModal(false) + handleLoadRiwayat() + setTimeout(() => setModalRiwayat(true), 600) + }} + /> + + {/* Separator */} + {(showAjukan || showApproverActions || (canTakeAction && isAdmin && status !== 3)) && ( + + )} + + {/* Baris 2 — aksi kondisional */} + {showAjukan && ( + } + title="Ajukan Selesai" + disabled={loadingAction} + onPress={() => { + setModal(false) + setTimeout(() => handleSubmitAjukan(), 600) + }} + /> + )} + {showApproverActions && ( + } + title="Persetujuan" + disabled={loadingAction} + onPress={() => { + setModal(false) + setTimeout(() => setModalPersetujuan(true), 600) + }} + /> + )} + {canTakeAction && isAdmin && status !== 3 && ( + <> + } + title="Edit Tugas" + onPress={() => { + setModal(false) + router.push(`./update/${tugas.id}`) + }} + /> + } + title="Hapus Tugas" + onPress={() => { + setModal(false) + setTimeout(() => setShowDeleteModal(true), 600) + }} + /> + + )} + + + + {/* Drawer persetujuan */} + + + } + title="Setujui" + color={colors.success} + disabled={loadingAction} + onPress={() => { + setModalPersetujuan(false) + setTimeout(() => setModalKonfirmasiSetujui(true), 600) + }} + /> + } + title="Tolak" + color={colors.error} + disabled={loadingAction} + onPress={() => { + setModalPersetujuan(false) + setTimeout(() => setModalTolak(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) - }} - /> - - )} { - setShowDeleteModal(false) - handleDelete() - }} + onConfirm={() => { setShowDeleteModal(false); handleDelete() }} onCancel={() => setShowDeleteModal(false)} confirmText="Hapus" cancelText="Batal" /> - setSelect(false)} - onSelect={(value) => { handleUpdate(Number(value.val)) }} - title="Status" - open={isSelect} - valChoose={String(tugas.status)} + setModalKonfirmasiSetujui(false)} + confirmText="Setujui" + cancelText="Batal" + /> + + + + ) -} \ No newline at end of file +} diff --git a/lib/api.ts b/lib/api.ts index a744165..17a3967 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -166,6 +166,11 @@ export const apiDeleteUser = async (data: { user: string, isActive: boolean }, i return response.data }; +export const apiToggleApprover = async (data: { user: string, isApprover: boolean }, id: string) => { + const response = await api.patch(`mobile/user/${id}`, data) + return response.data +}; + export const apiEditUser = async (data: FormData, id: string) => { const response = await api.put(`/mobile/user/${id}`, data, { headers: { @@ -379,6 +384,21 @@ export const apiDeleteProjectTaskFile = async (data: { user: string }, id: strin return response.data; }; +export const apiGetProjectTaskApprovals = async ({ user, id }: { user: string, id: string }) => { + const response = await api.get(`/mobile/project/task/${id}/approval`, { params: { user } }) + return response.data; +}; + +export const apiSubmitProjectTask = async ({ user, id }: { user: string, id: string }) => { + const response = await api.post(`/mobile/project/task/${id}/approval`, { user }) + return response.data; +}; + +export const apiApproveRejectProjectTask = async ({ user, id, action, note }: { user: string, id: string, action: 'approve' | 'reject', note?: string }) => { + const response = await api.put(`/mobile/project/task/${id}/approval`, { user, action, note }) + 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) @@ -686,6 +706,21 @@ export const apiAddFileTask = async ({ data, id }: { data: FormData, id: string return response.data; }; +export const apiGetTaskTugasApprovals = async ({ user, id }: { user: string, id: string }) => { + const response = await api.get(`/mobile/task/tugas/${id}/approval`, { params: { user } }) + return response.data; +}; + +export const apiSubmitTaskTugas = async ({ user, id }: { user: string, id: string }) => { + const response = await api.post(`/mobile/task/tugas/${id}/approval`, { user }) + return response.data; +}; + +export const apiApproveRejectTaskTugas = async ({ user, id, action, note }: { user: string, id: string, action: 'approve' | 'reject', note?: string }) => { + const response = await api.put(`/mobile/task/tugas/${id}/approval`, { user, action, note }) + 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; -- 2.49.1 From fad89fc910d647aec16b5e1098e3b6aadd464199 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 7 May 2026 16:16:21 +0800 Subject: [PATCH 2/3] feat: persist view mode (grid/list) across division, project, dan division/task - Simpan preferensi tampilan ke AsyncStorage dengan key 'division_view_mode' - Load preferensi saat halaman dibuka agar tidak reset - Samakan style list item division/task dengan project (bg transparent, icon hitam) - Sejajarkan toggle dengan input pencarian pada halaman division/task --- .../[id]/(fitur-division)/task/index.tsx | 26 +++++++++++++------ app/(application)/division/index.tsx | 19 ++++++++++---- app/(application)/project/index.tsx | 19 ++++++++++---- 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/app/(application)/division/[id]/(fitur-division)/task/index.tsx b/app/(application)/division/[id]/(fitur-division)/task/index.tsx index ada348b..b946d3b 100644 --- a/app/(application)/division/[id]/(fitur-division)/task/index.tsx +++ b/app/(application)/division/[id]/(fitur-division)/task/index.tsx @@ -21,6 +21,7 @@ import { router, useLocalSearchParams } from "expo-router"; import { useEffect, useState } from "react"; import { Pressable, RefreshControl, ScrollView, View, VirtualizedList } from "react-native"; import { useSelector } from "react-redux"; +import AsyncStorage from "@react-native-async-storage/async-storage"; type Props = { id: string; @@ -36,6 +37,18 @@ export default function ListTask() { const { id, status, year } = useLocalSearchParams<{ id: string; status: string; year: string }>() const [isList, setList] = useState(false) const { token, decryptToken } = useAuthSession() + + useEffect(() => { + AsyncStorage.getItem('division_view_mode').then((val) => { + if (val !== null) setList(val === 'list') + }) + }, []) + + function toggleView() { + const next = !isList + setList(next) + AsyncStorage.setItem('division_view_mode', next ? 'list' : 'grid') + } const [data, setData] = useState([]) const [search, setSearch] = useState("") const update = useSelector((state: any) => state.taskUpdate) @@ -172,13 +185,9 @@ export default function ListTask() { n={4} /> - + - { - setList(!isList); - }} - > + - + + } title={item.title} diff --git a/app/(application)/division/index.tsx b/app/(application)/division/index.tsx index 40bbc82..05a6afd 100644 --- a/app/(application)/division/index.tsx +++ b/app/(application)/division/index.tsx @@ -17,6 +17,7 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; +import AsyncStorage from "@react-native-async-storage/async-storage"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { router, useLocalSearchParams } from "expo-router"; import { useEffect, useMemo, useState } from "react"; @@ -37,6 +38,18 @@ export default function ListDivision() { cat?: string; }>(); const [isList, setList] = useState(false); + + useEffect(() => { + AsyncStorage.getItem('division_view_mode').then((val) => { + if (val !== null) setList(val === 'list') + }) + }, []) + + function toggleView() { + const next = !isList + setList(next) + AsyncStorage.setItem('division_view_mode', next ? 'list' : 'grid') + } const entityUser = useSelector((state: any) => state.user) const { token, decryptToken } = useAuthSession() const { colors } = useTheme(); @@ -184,11 +197,7 @@ export default function ListDivision() { - { - setList(!isList); - }} - > + state.projectUpdate) + + useEffect(() => { + AsyncStorage.getItem('division_view_mode').then((val) => { + if (val !== null) setList(val === 'list') + }) + }, []) + + function toggleView() { + const next = !isList + setList(next) + AsyncStorage.setItem('division_view_mode', next ? 'list' : 'grid') + } const queryClient = useQueryClient() const [refreshing, setRefreshing] = useState(false) @@ -188,11 +201,7 @@ export default function ListProject() { - { - setList(!isList); - }} - > + Date: Thu, 7 May 2026 16:45:52 +0800 Subject: [PATCH 3/3] feat: redesign halaman detail user dan profile dengan tampilan modern - Pindahkan badge APPROVER & AKTIF ke dalam header gradient - Ganti card berlatar menjadi list dengan border bottom saja - Gunakan icon colors.icon agar terlihat pada tema gelap - Tambahkan class baru di Styles.ts: memberAvatarRing, memberBadgeRow, memberBadgeApprover, memberBadgePill, memberInfoRow, memberInfoIcon, memberInfoContent, cWhiteDimmed, pv14, mb08 - Terapkan design yang sama pada halaman profile --- app/(application)/member/[id].tsx | 115 ++++++++++++++++-------------- app/(application)/profile.tsx | 75 ++++++++++++------- constants/Styles.ts | 45 ++++++++++++ 3 files changed, 156 insertions(+), 79 deletions(-) diff --git a/app/(application)/member/[id].tsx b/app/(application)/member/[id].tsx index 4b5cbab..201d351 100644 --- a/app/(application)/member/[id].tsx +++ b/app/(application)/member/[id].tsx @@ -1,6 +1,5 @@ import AppHeader from "@/components/AppHeader"; import ImageUser from "@/components/imageNew"; -import ItemDetailMember from "@/components/itemDetailMember"; import LabelStatus from "@/components/labelStatus"; import HeaderRightMemberDetail from "@/components/member/headerMemberDetail"; import Skeleton from "@/components/skeleton"; @@ -11,6 +10,7 @@ import { valueRoleUser } from "@/constants/RoleUser"; import Styles from "@/constants/Styles"; import { apiGetProfile } from "@/lib/api"; import { useTheme } from "@/providers/ThemeProvider"; +import { AntDesign, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons"; import { LinearGradient } from "expo-linear-gradient"; import { router, Stack, useLocalSearchParams } from "expo-router"; import React, { useEffect, useState } from "react"; @@ -110,60 +110,71 @@ export default function MemberDetail() { colors={[colors.header, colors.homeGradient]} style={[Styles.wrapHeadViewMember]} > - { - loading ? - <> - - - - - : - <> - setPreview(true)}> + {loading ? ( + <> + + + + + ) : ( + <> + setPreview(true)}> + - - {data?.name} - {data?.role} - - - } + + + {data?.name} + {data?.role} + + {data?.isApprover && ( + + APPROVER + + )} + + {data?.isActive ? 'AKTIF' : 'TIDAK AKTIF'} + + + + )} - - - Informasi - - {data?.isApprover && ( - - )} - - - - { - loading ? - arrSkeleton.map((item, index) => { - return ( - - ) - }) - : - <> - - - - - - - - } + + Informasi + + {loading ? ( + arrSkeleton.map((_, index) => ( + + + + + + + )) + ) : ( + [ + { icon: , label: 'NIK', value: data?.nik }, + { icon: , label: 'Lembaga Desa', value: data?.group }, + { icon: , label: 'Jabatan', value: data?.position }, + { icon: , label: 'No Telepon', value: `+62${data?.phone}` }, + { icon: , label: 'Email', value: data?.email }, + { icon: , label: 'Jenis Kelamin', value: data?.gender == "F" ? "Perempuan" : "Laki-Laki" }, + ].map((item, index, arr) => ( + + + {item.icon} + + + {item.label} + {item.value ?? '-'} + + + )) + )} + diff --git a/app/(application)/profile.tsx b/app/(application)/profile.tsx index e9c90af..52c2f72 100644 --- a/app/(application)/profile.tsx +++ b/app/(application)/profile.tsx @@ -1,6 +1,5 @@ import AppHeader from "@/components/AppHeader"; import { ButtonHeader } from "@/components/buttonHeader"; -import ItemDetailMember from "@/components/itemDetailMember"; import Text from "@/components/Text"; import { assetUserImage } from "@/constants/AssetsError"; import { ConstEnv } from "@/constants/ConstEnv"; @@ -9,7 +8,7 @@ import { apiGetProfile } from "@/lib/api"; import { setEntities } from "@/lib/entitiesSlice"; import { useAuthSession } from "@/providers/AuthProvider"; import { useTheme } from "@/providers/ThemeProvider"; -import { Feather } from "@expo/vector-icons"; +import { Feather, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons"; import { LinearGradient } from "expo-linear-gradient"; import { router, Stack } from "expo-router"; import { useState } from "react"; @@ -42,6 +41,15 @@ export default function Profile() { setRefreshing(false) }; + const infoRows = [ + { icon: , label: 'NIK', value: entities.nik }, + { icon: , label: 'Lembaga Desa', value: entities.group }, + { icon: , label: 'Jabatan', value: entities.position }, + { icon: , label: 'No Telepon', value: `0${entities.phone}` }, + { icon: , label: 'Email', value: entities.email }, + { icon: , label: 'Jenis Kelamin', value: entities.gender == "F" ? 'Perempuan' : 'Laki-laki' }, + ] + return ( } - onPress={() => { - router.push('/setting') - }} + onPress={() => router.push('/setting')} /> } /> @@ -75,32 +81,47 @@ export default function Profile() { } style={[Styles.h100, { backgroundColor: colors.background }]} > - - - setPreview(true)}> + + setPreview(true)}> + { setError(true) }} + onError={() => setError(true)} style={[Styles.userProfileBig]} /> - - {entities.name} - {entities.role} - - - - Informasi - {/* Note: ItemDetailMember might need updates to support dynamic colors if it uses default text colors */} - - - - - - + + {entities.name} + {entities.role} + {entities.isApprover && ( + + + APPROVER + + + )} + + + + Informasi + + {infoRows.map((item, index, arr) => ( + + + {item.icon} + + + {item.label} + {item.value ?? '-'} + + + ))} @@ -114,4 +135,4 @@ export default function Profile() { /> ) -} \ No newline at end of file +} diff --git a/constants/Styles.ts b/constants/Styles.ts index 976a1aa..b9fab98 100644 --- a/constants/Styles.ts +++ b/constants/Styles.ts @@ -1068,6 +1068,51 @@ const Styles = StyleSheet.create({ color: 'white', fontWeight: '500', }, + pv14: { + paddingVertical: 14, + }, + mb08: { + marginBottom: 8, + }, + cWhiteDimmed: { + color: 'rgba(255,255,255,0.7)', + }, + memberAvatarRing: { + borderWidth: 3, + borderColor: 'rgba(255,255,255,0.4)', + borderRadius: 100, + }, + memberBadgeRow: { + flexDirection: 'row', + gap: 8, + marginTop: 12, + }, + memberBadgeApprover: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 20, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.6)', + backgroundColor: 'rgba(255,255,255,0.15)', + }, + memberBadgePill: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 20, + }, + memberInfoRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 14, + }, + memberInfoIcon: { + width: 36, + alignItems: 'center', + }, + memberInfoContent: { + flex: 1, + marginLeft: 10, + }, }) export default Styles; \ No newline at end of file -- 2.49.1