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'
This commit is contained in:
2026-05-07 16:04:02 +08:00
parent d2e1663f9f
commit e48456ea7f
13 changed files with 811 additions and 289 deletions

View File

@@ -154,7 +154,7 @@ export default function DetailTaskDivision() {
} }
<SectionProgress progress={progress} doneCount={taskStats?.done} totalCount={taskStats?.total} /> <SectionProgress progress={progress} doneCount={taskStats?.done} totalCount={taskStats?.total} />
<SectionReportTask refreshing={refreshing} /> <SectionReportTask refreshing={refreshing} />
<SectionTanggalTugasTask refreshing={refreshing} isMemberDivision={isMemberDivision} /> <SectionTanggalTugasTask refreshing={refreshing} isMemberDivision={isMemberDivision} isAdminDivision={isAdminDivision} status={data?.status} />
<SectionFileTask refreshing={refreshing} isMemberDivision={isMemberDivision} /> <SectionFileTask refreshing={refreshing} isMemberDivision={isMemberDivision} />
<SectionLinkTask refreshing={refreshing} isMemberDivision={isMemberDivision} /> <SectionLinkTask refreshing={refreshing} isMemberDivision={isMemberDivision} />
<SectionMemberTask refreshing={refreshing} isAdminDivision={isAdminDivision} /> <SectionMemberTask refreshing={refreshing} isAdminDivision={isAdminDivision} />

View File

@@ -30,6 +30,7 @@ type Props = {
group: string, group: string,
img: string, img: string,
isActive: boolean, isActive: boolean,
isApprover: boolean,
role: string role: string
} }
@@ -89,7 +90,7 @@ export default function MemberDetail() {
showBack={true} showBack={true}
onPressLeft={() => router.back()} onPressLeft={() => router.back()}
right={ right={
(entityUser.role != "user") && isEdit ? <HeaderRightMemberDetail active={data?.isActive} id={id} /> : <></> (entityUser.role != "user") && isEdit ? <HeaderRightMemberDetail active={data?.isActive} id={id} isApprover={data?.isApprover ?? false} /> : <></>
} }
/> />
) )
@@ -130,11 +131,20 @@ export default function MemberDetail() {
<View style={[Styles.p15]}> <View style={[Styles.p15]}>
<View style={[Styles.rowSpaceBetween]}> <View style={[Styles.rowSpaceBetween]}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>Informasi</Text> <Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>Informasi</Text>
<LabelStatus <View style={{ flexDirection: 'row', gap: 6 }}>
size="small" {data?.isApprover && (
category={data?.isActive ? 'success' : 'error'} <LabelStatus
text={data?.isActive ? 'AKTIF' : 'TIDAK AKTIF'} size="small"
/> category="primary"
text="APPROVER"
/>
)}
<LabelStatus
size="small"
category={data?.isActive ? 'success' : 'error'}
text={data?.isActive ? 'AKTIF' : 'TIDAK AKTIF'}
/>
</View>
</View> </View>
{ {
loading ? loading ?

View File

@@ -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 (
<View style={{
backgroundColor: config.color + '20',
borderRadius: 20,
paddingHorizontal: 10,
paddingVertical: 3,
alignSelf: 'flex-start',
}}>
<Text style={[Styles.textSmallSemiBold, { color: config.color }]}>
{config.label}
</Text>
</View>
)
}
export default function ModalRiwayatApproval({ isVisible, setVisible, data, loading }: Props) {
const { colors } = useTheme()
const arrSkeleton = Array.from({ length: 3 })
const scrollRef = useRef<ScrollView>(null)
const [scrollOffset, setScrollOffset] = useState(0)
return (
<DrawerBottom
isVisible={isVisible}
setVisible={setVisible}
title="Riwayat Persetujuan"
animation="slide"
height={60}
scrollOffset={scrollOffset}
scrollTo={(p) => scrollRef.current?.scrollTo(p)}
>
<ScrollView
ref={scrollRef}
showsVerticalScrollIndicator={false}
onScroll={({ nativeEvent }) => setScrollOffset(nativeEvent.contentOffset.y)}
scrollEventThrottle={16}
>
{loading ? (
arrSkeleton.map((_, i) => (
<View key={i} style={[Styles.mb10]}>
<Skeleton width={100} widthType="percent" height={80} borderRadius={10} />
</View>
))
) : data.length > 0 ? (
data.map((item, index) => (
<View
key={item.id}
style={{
borderWidth: 1,
borderColor: colors.icon + '30',
borderRadius: 10,
padding: 12,
marginBottom: 10,
}}
>
{/* Status + tanggal */}
<View style={[Styles.rowItemsCenter, { justifyContent: 'space-between', marginBottom: 8 }]}>
<ApprovalStatusBadge status={item.status} />
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>
{item.createdAt}
</Text>
</View>
{/* Pengaju */}
<View style={[Styles.rowItemsCenter, Styles.mb05]}>
<MaterialCommunityIcons name="account-arrow-up-outline" size={15} color={colors.dimmed} style={{ marginRight: 6 }} />
<Text style={[Styles.textMediumSemiBold, { color: colors.dimmed }]}>Diajukan Oleh: </Text>
<Text style={[Styles.textMediumNormal]}>{item.submitter.name}</Text>
</View>
{/* Approver */}
<View style={[Styles.rowItemsCenter, item.note ? Styles.mb05 : {}]}>
<MaterialCommunityIcons name="account-check-outline" size={15} color={colors.dimmed} style={{ marginRight: 6 }} />
<Text style={[Styles.textMediumSemiBold, { color: colors.dimmed }]}>Disetujui Oleh: </Text>
<Text style={[Styles.textMediumNormal]}>
{item.approver?.name ?? '-'}
</Text>
</View>
{/* Catatan penolakan */}
{item.note && (
<View style={{
backgroundColor: colors.error + '12',
borderRadius: 8,
padding: 8,
marginTop: 4,
}}>
<Text style={[Styles.textSmallSemiBold, { color: colors.error, marginBottom: 2 }]}>
Alasan Penolakan
</Text>
<Text style={[Styles.textMediumNormal, { color: colors.text }]}>
{item.note}
</Text>
</View>
)}
</View>
))
) : (
<Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>
Belum ada riwayat persetujuan
</Text>
)}
</ScrollView>
</DrawerBottom>
)
}

View File

@@ -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 (
<DrawerBottom
isVisible={isVisible}
setVisible={handleClose}
title="Tolak Tugas"
animation="slide"
height={45}
keyboard
>
<View style={{ flex: 1 }}>
<InputForm
label="Alasan Penolakan"
placeholder="Tuliskan alasan penolakan..."
type="default"
multiline
bg="transparent"
value={note}
onChange={setNote}
error={error}
errorText="Alasan penolakan wajib diisi"
required
/>
</View>
<TouchableOpacity
onPress={handleSubmit}
disabled={loading}
style={{
backgroundColor: loading ? colors.error + '60' : colors.error,
borderRadius: 30,
paddingVertical: 10,
alignItems: 'center',
marginTop: 8,
}}
>
<Text style={[Styles.textDefaultSemiBold, Styles.cWhite]}>
{loading ? 'Memproses...' : 'Tolak Tugas'}
</Text>
</TouchableOpacity>
</DrawerBottom>
)
}

View File

@@ -14,9 +14,11 @@ type Props = {
height?: number height?: number
backdropPressable?: boolean backdropPressable?: boolean
keyboard?: 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 tinggiScreen = Dimensions.get("window").height;
const { colors } = useTheme(); const { colors } = useTheme();
const tinggiInput = height != undefined ? height : 25 const tinggiInput = height != undefined ? height : 25
@@ -38,6 +40,9 @@ export default function DrawerBottom({ isVisible, setVisible, title, children, a
backdropTransitionOutTiming={500} backdropTransitionOutTiming={500}
useNativeDriverForBackdrop={true} useNativeDriverForBackdrop={true}
propagateSwipe={true} propagateSwipe={true}
scrollTo={scrollTo}
scrollOffset={scrollOffset}
scrollOffsetMax={200}
> >
{ {
keyboard ? keyboard ?

View File

@@ -36,7 +36,7 @@ export default function CaraouselHome({ refreshing }: { refreshing: boolean }) {
async function handleUser() { async function handleUser() {
const hasil = await decryptToken(String(token?.current)) const hasil = await decryptToken(String(token?.current))
const response = await apiGetProfile({ id: hasil }) 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(() => { useEffect(() => {

View File

@@ -59,7 +59,7 @@ export default function CaraouselHome2({ refreshing }: { refreshing: boolean })
// Sync User Role to Redux // Sync User Role to Redux
useEffect(() => { useEffect(() => {
if (profile) { if (profile) {
dispatch(setEntityUser({ role: profile.idUserRole, admin: false })) dispatch(setEntityUser({ role: profile.idUserRole, admin: false, isApprover: profile.isApprover ?? false }))
} }
}, [profile, dispatch]) }, [profile, dispatch])

View File

@@ -11,7 +11,7 @@ type FileItem = {
} }
type Props = { type Props = {
done?: boolean status?: number // 0=belum selesai, 1=selesai, 2=menunggu persetujuan
title: string title: string
dateStart: string dateStart: string
dateEnd: string dateEnd: string
@@ -64,7 +64,15 @@ function getFileIcon(extension: string): keyof typeof MaterialCommunityIcons.gly
return 'file-outline' 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 { colors, activeTheme } = useTheme()
const [containerWidth, setContainerWidth] = useState(0) 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 dimmed = colors.dimmed.slice(0, 7)
const successColor = activeTheme === 'dark' ? '#51CF66' : colors.success const successColor = activeTheme === 'dark' ? '#51CF66' : colors.success
const accentColor = done === true ? successColor : dimmed + '80' const statusStyle = getStatusStyle(status, successColor, dimmed)
return ( return (
<Pressable <Pressable
@@ -93,8 +101,8 @@ export default function ItemSectionTanggalTugas({ done, title, dateStart, dateEn
}} }}
> >
{/* Accent bar kiri */} {/* Accent bar kiri */}
{done !== undefined && ( {status !== undefined && (
<View style={{ width: 4, backgroundColor: accentColor }} /> <View style={{ width: 4, backgroundColor: statusStyle.accent }} />
)} )}
{/* Konten */} {/* Konten */}
@@ -103,16 +111,16 @@ export default function ItemSectionTanggalTugas({ done, title, dateStart, dateEn
{/* Judul + badge status */} {/* Judul + badge status */}
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}> <View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
<Text style={[Styles.textDefault, { flex: 1, marginRight: 8 }]}>{title}</Text> <Text style={[Styles.textDefault, { flex: 1, marginRight: 8 }]}>{title}</Text>
{done !== undefined && ( {status !== undefined && (
<View style={{ <View style={{
backgroundColor: done ? successColor + '25' : dimmed + '18', backgroundColor: statusStyle.badge,
borderRadius: 20, borderRadius: 20,
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 3, paddingVertical: 3,
alignSelf: 'flex-start', alignSelf: 'flex-start',
}}> }}>
<Text style={[Styles.textSmallSemiBold, { color: done ? successColor : colors.dimmed }]}> <Text style={[Styles.textSmallSemiBold, { color: statusStyle.text }]}>
{done ? 'Selesai' : 'Belum Selesai'} {statusStyle.label}
</Text> </Text>
</View> </View>
)} )}

View File

@@ -1,5 +1,5 @@
import Styles from "@/constants/Styles" import Styles from "@/constants/Styles"
import { apiDeleteUser } from "@/lib/api" import { apiDeleteUser, apiToggleApprover } from "@/lib/api"
import { setUpdateMember } from "@/lib/memberSlice" import { setUpdateMember } from "@/lib/memberSlice"
import { useAuthSession } from "@/providers/AuthProvider" import { useAuthSession } from "@/providers/AuthProvider"
import { useTheme } from "@/providers/ThemeProvider" import { useTheme } from "@/providers/ThemeProvider"
@@ -16,14 +16,17 @@ import MenuItemRow from "../menuItemRow"
type Props = { type Props = {
active: any, 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 { token, decryptToken } = useAuthSession()
const [isVisible, setVisible] = useState(false) const [isVisible, setVisible] = useState(false)
const update = useSelector((state: any) => state.memberUpdate) 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 { colors } = useTheme();
const dispatch = useDispatch() const dispatch = useDispatch()
@@ -37,17 +40,36 @@ export default function HeaderRightMemberDetail({ active, id }: Props) {
} else { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message, })
} }
} catch (error : any ) { } catch (error: any) {
console.error(error); console.error(error);
const message = error?.response?.data?.message || "Gagal mengupdate data" const message = error?.response?.data?.message || "Gagal mengupdate data"
Toast.show({ type: 'small', text1: message }) Toast.show({ type: 'small', text1: message })
} finally { } finally {
setVisible(false) 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 ( return (
<> <>
<ButtonMenuHeader onPress={() => { setVisible(true) }} /> <ButtonMenuHeader onPress={() => { setVisible(true) }} />
@@ -55,12 +77,10 @@ export default function HeaderRightMemberDetail({ active, id }: Props) {
<View style={Styles.rowItemsCenter}> <View style={Styles.rowItemsCenter}>
<MenuItemRow <MenuItemRow
icon={<MaterialCommunityIcons name="toggle-switch-off-outline" color={colors.text} size={25} />} icon={<MaterialCommunityIcons name="toggle-switch-off-outline" color={colors.text} size={25} />}
title={active ? "Non Aktifkan" : "Aktifkan"} title={active ? "Nonaktifkan" : "Aktifkan"}
onPress={() => { onPress={() => {
setVisible(false) setVisible(false)
setTimeout(() => { setTimeout(() => setShowModalActive(true), 600)
setShowModal(true)
}, 600)
}} }}
/> />
<MenuItemRow <MenuItemRow
@@ -71,18 +91,39 @@ export default function HeaderRightMemberDetail({ active, id }: Props) {
router.push(`/member/edit/${id}`) router.push(`/member/edit/${id}`)
}} }}
/> />
{canManageApprover && (
<MenuItemRow
icon={<MaterialCommunityIcons name="shield-check-outline" color={colors.text} size={25} />}
title={isApprover ? "Revoke Approver" : "Jadikan Approver"}
color={colors.text}
onPress={() => {
setVisible(false)
setTimeout(() => setShowModalApprover(true), 600)
}}
/>
)}
</View> </View>
</DrawerBottom> </DrawerBottom>
<ModalConfirmation <ModalConfirmation
visible={showModal} visible={showModalActive}
title="Konfirmasi" title="Konfirmasi"
message={active ? 'Apakah anda yakin ingin menonaktifkan user?' : 'Apakah anda yakin ingin mengaktifkan user?'} message={active ? 'Apakah anda yakin ingin menonaktifkan anggota ini?' : 'Apakah anda yakin ingin mengaktifkan anggota ini?'}
onConfirm={() => { onConfirm={() => {
setShowModal(false) setShowModalActive(false)
handleActive() handleActive()
}} }}
onCancel={() => setShowModal(false)} onCancel={() => setShowModalActive(false)}
confirmText="Konfirmasi"
cancelText="Batal"
/>
<ModalConfirmation
visible={showModalApprover}
title="Konfirmasi"
message={isApprover ? 'Apakah anda yakin ingin mencabut status approver user ini?' : 'Apakah anda yakin ingin menjadikan user ini sebagai approver?'}
onConfirm={handleToggleApprover}
onCancel={() => setShowModalApprover(false)}
confirmText="Konfirmasi" confirmText="Konfirmasi"
cancelText="Batal" cancelText="Batal"
/> />

View File

@@ -1,5 +1,5 @@
import Styles from "@/constants/Styles"; 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 { setUpdateProject } from "@/lib/projectUpdate";
import { useAuthSession } from "@/providers/AuthProvider"; import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider"; import { useTheme } from "@/providers/ThemeProvider";
@@ -13,7 +13,8 @@ import DrawerBottom from "../drawerBottom";
import ItemSectionTanggalTugas from "../itemSectionTanggalTugas"; import ItemSectionTanggalTugas from "../itemSectionTanggalTugas";
import MenuItemRow from "../menuItemRow"; import MenuItemRow from "../menuItemRow";
import ModalConfirmation from "../ModalConfirmation"; import ModalConfirmation from "../ModalConfirmation";
import ModalSelect from "../modalSelect"; import ModalRiwayatApproval from "../ModalRiwayatApproval";
import ModalTolakApproval from "../ModalTolakApproval";
import SkeletonTask from "../skeletonTask"; import SkeletonTask from "../skeletonTask";
import Text from "../Text"; import Text from "../Text";
import ModalListDetailTugasProject from "./modalListDetailTugasProject"; import ModalListDetailTugasProject from "./modalListDetailTugasProject";
@@ -22,41 +23,52 @@ type Props = {
id: string; id: string;
title: string; title: string;
desc: string; desc: string;
status: 1; status: number;
dateStart: string; dateStart: string;
dateEnd: string; dateEnd: string;
createdAt: string; createdAt: string;
files?: { name: string; extension: 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 }) { export default function SectionTanggalTugasProject({ status, member, refreshing }: { status: number | undefined, member: boolean, refreshing?: boolean }) {
const { colors } = useTheme(); const { colors } = useTheme();
const entityUser = useSelector((state: any) => state.user) const entityUser = useSelector((state: any) => state.user)
const dispatch = useDispatch() const dispatch = useDispatch()
const update = useSelector((state: any) => state.projectUpdate) const update = useSelector((state: any) => state.projectUpdate)
const [isModal, setModal] = useState(false); const [isModal, setModal] = useState(false);
const [isSelect, setSelect] = useState(false);
const { token, decryptToken } = useAuthSession(); const { token, decryptToken } = useAuthSession();
const [modalDetail, setModalDetail] = useState(false) 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 { id } = useLocalSearchParams<{ id: string }>();
const [data, setData] = useState<Props[]>([]); const [data, setData] = useState<Props[]>([]);
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [loadingAction, setLoadingAction] = useState(false)
const [loadingRiwayat, setLoadingRiwayat] = useState(false)
const [riwayatData, setRiwayatData] = useState<ApprovalRecord[]>([])
const arrSkeleton = Array.from({ length: 5 }); const arrSkeleton = Array.from({ length: 5 });
const [tugas, setTugas] = useState({ const [tugas, setTugas] = useState({ id: '', status: 0 })
id: '',
status: 0,
})
const [showDeleteModal, setShowDeleteModal] = useState(false) 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) { async function handleLoad(loading: boolean) {
try { try {
setLoading(loading) setLoading(loading)
const hasil = await decryptToken(String(token?.current)); const hasil = await decryptToken(String(token?.current));
const response = await apiGetProjectOne({ const response = await apiGetProjectOne({ user: hasil, cat: "task", id: id });
user: hasil,
cat: "task",
id: id,
});
setData(response.data); setData(response.data);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -65,178 +77,268 @@ export default function SectionTanggalTugasProject({ status, member, refreshing
} }
} }
useEffect(() => { useEffect(() => { handleLoad(false) }, [update.task]);
handleLoad(false); useEffect(() => { if (refreshing) handleLoad(false) }, [refreshing]);
}, [update.task]); useEffect(() => { handleLoad(true) }, []);
useEffect(() => { async function handleLoadRiwayat() {
if (refreshing)
handleLoad(false);
}, [refreshing]);
useEffect(() => {
handleLoad(true);
}, []);
async function handleUpdate(status: number) {
try { try {
const hasil = await decryptToken(String(token?.current)); setLoadingRiwayat(true)
const response = await apiUpdateStatusProjectTask({ const hasil = await decryptToken(String(token?.current))
user: hasil, const response = await apiGetProjectTaskApprovals({ user: hasil, id: tugas.id })
idProject: id, setRiwayatData(response.data ?? [])
status: status, } catch (error) {
}, tugas.id); 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) { if (response.success) {
dispatch(setUpdateProject({ ...update, progress: !update.progress, task: !update.task })) dispatch(setUpdateProject({ ...update, progress: !update.progress, task: !update.task }))
setSelect(false); Toast.show({ type: 'small', text1: 'Berhasil mengajukan task untuk persetujuan' })
Toast.show({ type: 'small', text1: 'Berhasil mengubah data', }) } else {
Toast.show({ type: 'small', text1: response.message })
} }
} catch (error: any) { } catch (error: any) {
console.error(error); const message = error?.response?.data?.message || "Gagal mengajukan persetujuan"
const message = error?.response?.data?.message || "Gagal mengubah data"
Toast.show({ type: 'small', text1: message }) 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() { async function handleDelete() {
try { try {
const hasil = await decryptToken(String(token?.current)); const hasil = await decryptToken(String(token?.current));
const response = await apiDeleteProjectTask({ const response = await apiDeleteProjectTask({ user: hasil, idProject: id }, tugas.id);
user: hasil,
idProject: id,
}, tugas.id);
if (response.success) { if (response.success) {
dispatch(setUpdateProject({ ...update, progress: !update.progress, task: !update.task })) 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) { } catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal menghapus data" const message = error?.response?.data?.message || "Gagal menghapus data"
Toast.show({ type: 'small', text1: message }) 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 ( return (
<> <>
<View style={[Styles.mb15, Styles.mt10]}> <View style={[Styles.mb15, Styles.mt10]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mv05]}> <Text style={[Styles.textDefaultSemiBold, Styles.mv05]}>Tanggal & Tugas</Text>
Tanggal & Tugas
</Text>
<View> <View>
{ {loading
loading ? ? arrSkeleton.map((_, index) => <SkeletonTask key={index} />)
arrSkeleton.map((item, index) => { : data.length > 0
return ( ? data.map((item, index) => (
<SkeletonTask key={index} /> <ItemSectionTanggalTugas
) key={index}
}) status={item.status}
: title={item.title}
data.length > 0 dateStart={item.dateStart}
? dateEnd={item.dateEnd}
data.map((item, index) => { files={item.files ?? []}
return ( onPress={() => {
<ItemSectionTanggalTugas setTugas({ id: item.id, status: item.status })
key={index} setModal(true)
done={item.status === 1} }}
title={item.title} />
dateStart={item.dateStart} ))
dateEnd={item.dateEnd} : <Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>Tidak ada tugas</Text>
files={item.files ?? []}
onPress={() => {
setTugas({ id: item.id, status: item.status })
setModal(true)
}}
/>
);
})
:
<Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>Tidak ada tugas</Text>
} }
</View> </View>
</View> </View>
<DrawerBottom {/* Drawer menu */}
animation="slide" <DrawerBottom animation="slide" isVisible={isModal} setVisible={setModal} title="Menu" height={40}>
isVisible={isModal} <View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
setVisible={setModal}
title="Menu" {/* Baris 1 — selalu tampil */}
>
<View style={Styles.rowItemsCenter}>
{(member || (entityUser.role != "user" && entityUser.role != "coadmin")) && status != 3 && (
<MenuItemRow
icon={<MaterialCommunityIcons name="list-status" color={colors.text} size={25} />}
title="Update Status"
onPress={() => {
setModal(false);
setTimeout(() => setSelect(true), 600)
}}
/>
)}
<MenuItemRow <MenuItemRow
icon={<MaterialCommunityIcons name="file-multiple-outline" color={colors.text} size={25} />} icon={<MaterialCommunityIcons name="file-multiple-outline" color={colors.text} size={25} />}
title="File Tugas" title="File Tugas"
onPress={() => { onPress={() => {
setModal(false); setModal(false)
router.push(`/project/${id}/tugas-file/${tugas.id}?member=${member}`); router.push(`/project/${id}/tugas-file/${tugas.id}?member=${member}`)
}} }}
/> />
<MenuItemRow <MenuItemRow
icon={<MaterialCommunityIcons name="clock-time-three-outline" color={colors.text} size={25} />} icon={<MaterialCommunityIcons name="clock-time-three-outline" color={colors.text} size={25} />}
title="Detail Waktu" title="Detail Waktu"
onPress={() => { onPress={() => {
setModal(false); setModal(false)
setTimeout(() => setModalDetail(true), 600) setTimeout(() => setModalDetail(true), 600)
}} }}
/> />
</View> <MenuItemRow
{(member || (entityUser.role != "user" && entityUser.role != "coadmin")) && status != 3 && ( icon={<MaterialCommunityIcons name="history" color={colors.text} size={25} />}
<View style={[Styles.rowItemsCenter, Styles.mt15]}> title="Riwayat"
onPress={() => {
setModal(false)
handleLoadRiwayat()
setTimeout(() => setModalRiwayat(true), 600)
}}
/>
{/* Separator antar baris */}
{(showAjukan || showApproverActions || (canTakeAction && isAdmin && status !== 3)) && (
<View style={{ width: '100%', height: 15 }} />
)}
{/* Baris 2 — semua aksi kondisional dalam satu baris */}
{showAjukan && (
<MenuItemRow <MenuItemRow
icon={<MaterialCommunityIcons name="pencil-outline" color={colors.text} size={25} />} icon={<MaterialCommunityIcons name="check-circle-outline" color={colors.text} size={25} />}
title="Edit Tugas" title="Ajukan Selesai"
onPress={() => { disabled={loadingAction}
setModal(false);
router.push(`/project/update/${tugas.id}`);
}}
/>
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus Tugas"
onPress={() => { onPress={() => {
setModal(false) setModal(false)
setTimeout(() => setShowDeleteModal(true), 600) setTimeout(() => handleSubmitAjukan(), 600)
}} }}
/> />
</View> )}
)} {showApproverActions && (
<MenuItemRow
icon={<MaterialCommunityIcons name="shield-check-outline" color={colors.text} size={25} />}
title="Persetujuan"
disabled={loadingAction}
onPress={() => {
setModal(false)
setTimeout(() => setModalPersetujuan(true), 600)
}}
/>
)}
{canTakeAction && isAdmin && status !== 3 && (
<>
<MenuItemRow
icon={<MaterialCommunityIcons name="pencil-outline" color={colors.text} size={25} />}
title="Edit Tugas"
onPress={() => {
setModal(false)
router.push(`/project/update/${tugas.id}`)
}}
/>
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus Tugas"
onPress={() => {
setModal(false)
setTimeout(() => setShowDeleteModal(true), 600)
}}
/>
</>
)}
</View>
</DrawerBottom>
{/* Drawer persetujuan */}
<DrawerBottom animation="slide" isVisible={modalPersetujuan} setVisible={setModalPersetujuan} title="Persetujuan Tugas" height={25}>
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
<MenuItemRow
icon={<MaterialCommunityIcons name="check-circle" color={colors.success} size={25} />}
title="Setujui"
color={colors.success}
disabled={loadingAction}
onPress={() => {
setModalPersetujuan(false)
setTimeout(() => setModalKonfirmasiSetujui(true), 600)
}}
/>
<MenuItemRow
icon={<MaterialCommunityIcons name="close-circle-outline" color={colors.error} size={25} />}
title="Tolak"
color={colors.error}
disabled={loadingAction}
onPress={() => {
setModalPersetujuan(false)
setTimeout(() => setModalTolak(true), 600)
}}
/>
</View>
</DrawerBottom> </DrawerBottom>
<ModalConfirmation <ModalConfirmation
visible={showDeleteModal} visible={showDeleteModal}
title="Konfirmasi" title="Konfirmasi"
message="Apakah anda yakin ingin menghapus data ini?" message="Apakah anda yakin ingin menghapus data ini?"
onConfirm={() => { onConfirm={() => { setShowDeleteModal(false); handleDelete() }}
setShowDeleteModal(false)
handleDelete()
}}
onCancel={() => setShowDeleteModal(false)} onCancel={() => setShowDeleteModal(false)}
confirmText="Hapus" confirmText="Hapus"
cancelText="Batal" cancelText="Batal"
/> />
<ModalSelect <ModalConfirmation
category="status-task" visible={modalKonfirmasiSetujui}
close={() => { setSelect(false) }} title="Konfirmasi"
onSelect={(value) => { message="Apakah anda yakin ingin menyetujui tugas ini?"
handleUpdate(Number(value.val)) onConfirm={handleSetujui}
}} onCancel={() => setModalKonfirmasiSetujui(false)}
title="Status" confirmText="Setujui"
open={isSelect} cancelText="Batal"
valChoose={String(tugas.status)} />
<ModalRiwayatApproval
isVisible={modalRiwayat}
setVisible={setModalRiwayat}
data={riwayatData}
loading={loadingRiwayat}
/>
<ModalTolakApproval
isVisible={modalTolak}
setVisible={setModalTolak}
onTolak={handleTolak}
loading={loadingAction}
/> />
<ModalListDetailTugasProject <ModalListDetailTugasProject

View File

@@ -21,7 +21,7 @@ export default function SectionProgress({ progress, doneCount, totalCount }: Pro
? 'Selesai' ? 'Selesai'
: progress > 0 : progress > 0
? 'Sedang berlangsung' ? 'Sedang berlangsung'
: 'Belum dimulai'; : 'Belum ada tugas yang diselesaikan';
useEffect(() => { useEffect(() => {
animatedWidth.setValue(0); animatedWidth.setValue(0);

View File

@@ -1,5 +1,5 @@
import Styles from "@/constants/Styles"; 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 { setUpdateTask } from "@/lib/taskUpdate";
import { useAuthSession } from "@/providers/AuthProvider"; import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider"; import { useTheme } from "@/providers/ThemeProvider";
@@ -13,7 +13,8 @@ import DrawerBottom from "../drawerBottom";
import ItemSectionTanggalTugas from "../itemSectionTanggalTugas"; import ItemSectionTanggalTugas from "../itemSectionTanggalTugas";
import MenuItemRow from "../menuItemRow"; import MenuItemRow from "../menuItemRow";
import ModalConfirmation from "../ModalConfirmation"; import ModalConfirmation from "../ModalConfirmation";
import ModalSelect from "../modalSelect"; import ModalRiwayatApproval from "../ModalRiwayatApproval";
import ModalTolakApproval from "../ModalTolakApproval";
import SkeletonTask from "../skeletonTask"; import SkeletonTask from "../skeletonTask";
import Text from "../Text"; import Text from "../Text";
import ModalListDetailTugasTask from "./modalListDetailTugasTask"; import ModalListDetailTugasTask from "./modalListDetailTugasTask";
@@ -28,25 +29,41 @@ type Props = {
files?: { name: string; extension: string }[]; 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 { colors } = useTheme()
const dispatch = useDispatch() const dispatch = useDispatch()
const entityUser = useSelector((state: any) => state.user); const entityUser = useSelector((state: any) => state.user);
const update = useSelector((state: any) => state.taskUpdate) const update = useSelector((state: any) => state.taskUpdate)
const [isModal, setModal] = useState(false) const [isModal, setModal] = useState(false)
const [isSelect, setSelect] = useState(false)
const { token, decryptToken } = useAuthSession() const { token, decryptToken } = useAuthSession()
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [loadingAction, setLoadingAction] = useState(false)
const [loadingRiwayat, setLoadingRiwayat] = useState(false)
const arrSkeleton = Array.from({ length: 5 }) const arrSkeleton = Array.from({ length: 5 })
const [modalDetail, setModalDetail] = useState(false) 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<ApprovalRecord[]>([])
const { id, detail } = useLocalSearchParams<{ id: string, detail: string }>(); const { id, detail } = useLocalSearchParams<{ id: string, detail: string }>();
const [data, setData] = useState<Props[]>([]) const [data, setData] = useState<Props[]>([])
const [tugas, setTugas] = useState({ const [tugas, setTugas] = useState({ id: '', status: 0 })
id: '',
status: 0,
})
const [showDeleteModal, setShowDeleteModal] = useState(false) 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) { async function handleLoad(loading: boolean) {
try { try {
setLoading(loading) setLoading(loading)
@@ -60,126 +77,136 @@ export default function SectionTanggalTugasTask({ refreshing, isMemberDivision }
} }
} }
async function handleUpdate(status: number) { useEffect(() => { handleLoad(false) }, [update.task])
try { useEffect(() => { if (refreshing) handleLoad(false) }, [refreshing])
const hasil = await decryptToken(String(token?.current)); useEffect(() => { handleLoad(true) }, [])
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"
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 { } finally {
setSelect(false) setLoadingRiwayat(false)
} }
} }
useEffect(() => { async function handleSubmitAjukan() {
handleLoad(false) try {
}, [update.task]) 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(() => { async function handleSetujui() {
if (refreshing) try {
handleLoad(false); setLoadingAction(true)
}, [refreshing]); const hasil = await decryptToken(String(token?.current))
const response = await apiApproveRejectTaskTugas({ user: hasil, id: tugas.id, action: 'approve' })
if (response.success) {
useEffect(() => { dispatch(setUpdateTask({ ...update, progress: !update.progress, task: !update.task }))
handleLoad(true) 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() { async function handleDelete() {
try { try {
const hasil = await decryptToken(String(token?.current)); const hasil = await decryptToken(String(token?.current));
const response = await apiDeleteTaskTugas({ const response = await apiDeleteTaskTugas({ user: hasil, idProject: detail }, tugas.id);
user: hasil,
idProject: detail,
}, tugas.id);
if (response.success) { if (response.success) {
dispatch(setUpdateTask({ ...update, progress: !update.progress, task: !update.task })) 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 { } else {
Toast.show({ type: 'small', text1: response.message, }) Toast.show({ type: 'small', text1: response.message })
} }
} catch (error: any) { } catch (error: any) {
console.error(error); Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menghapus data" })
const message = error?.response?.data?.message || "Gagal menghapus data"
Toast.show({ type: 'small', text1: message })
} finally {
setModal(false);
} }
} }
const canApprove = isApprover || isAdminDivision
const showAjukan = (isMemberDivision || canApprove) && tugas.status === 0 && status !== 3
const showApproverActions = canApprove && tugas.status === 2
return ( return (
<> <>
<View style={[Styles.mb15, Styles.mt10]}> <View style={[Styles.mb15, Styles.mt10]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mv05]}>Tanggal & Tugas</Text> <Text style={[Styles.textDefaultSemiBold, Styles.mv05]}>Tanggal & Tugas</Text>
<View> <View>
{ {loading
loading ? ? arrSkeleton.map((_, index) => <SkeletonTask key={index} />)
arrSkeleton.map((item, index) => { : data.length > 0
return ( ? data.map((item, index) => (
<SkeletonTask key={index} /> <ItemSectionTanggalTugas
) key={index}
}) status={item.status}
: title={item.title}
data.length > 0 dateStart={item.dateStart}
? dateEnd={item.dateEnd}
data.map((item, index) => { files={item.files ?? []}
return ( onPress={() => {
<ItemSectionTanggalTugas setTugas({ id: item.id, status: item.status })
key={index} setModal(true)
done={item.status === 1} }}
title={item.title} />
dateStart={item.dateStart} ))
dateEnd={item.dateEnd} : <Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>Tidak ada tugas</Text>
files={item.files ?? []}
onPress={() => {
setTugas({
id: item.id,
status: item.status
})
setModal(true)
}}
/>
);
})
:
<Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>Tidak ada tugas</Text>
} }
</View> </View>
</View> </View>
<DrawerBottom animation="slide" isVisible={isModal} setVisible={setModal} title="Menu"> {/* Drawer menu */}
<View style={Styles.rowItemsCenter}> <DrawerBottom animation="slide" isVisible={isModal} setVisible={setModal} title="Menu" height={40}>
{((entityUser.role != "user" && entityUser.role != "coadmin") || isMemberDivision) && ( <View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
<MenuItemRow
icon={<MaterialCommunityIcons name="list-status" color={colors.text} size={25} />} {/* Baris 1 — selalu tampil */}
title="Update Status"
onPress={() => {
setModal(false)
setTimeout(() => setSelect(true), 600)
}}
/>
)}
<MenuItemRow <MenuItemRow
icon={<MaterialCommunityIcons name="file-multiple-outline" color={colors.text} size={25} />} icon={<MaterialCommunityIcons name="file-multiple-outline" color={colors.text} size={25} />}
title="File Tugas" title="File Tugas"
onPress={() => { onPress={() => {
setModal(false); setModal(false)
router.push(`/division/${id}/task/${detail}/tugas-file/${tugas.id}?member=${isMemberDivision}`) router.push(`/division/${id}/task/${detail}/tugas-file/${tugas.id}?member=${isMemberDivision}`)
}} }}
/> />
@@ -187,53 +214,129 @@ export default function SectionTanggalTugasTask({ refreshing, isMemberDivision }
icon={<MaterialCommunityIcons name="clock-time-three-outline" color={colors.text} size={25} />} icon={<MaterialCommunityIcons name="clock-time-three-outline" color={colors.text} size={25} />}
title="Detail Waktu" title="Detail Waktu"
onPress={() => { onPress={() => {
setModal(false); setModal(false)
setTimeout(() => setModalDetail(true), 600) setTimeout(() => setModalDetail(true), 600)
}} }}
/> />
<MenuItemRow
icon={<MaterialCommunityIcons name="history" color={colors.text} size={25} />}
title="Riwayat"
onPress={() => {
setModal(false)
handleLoadRiwayat()
setTimeout(() => setModalRiwayat(true), 600)
}}
/>
{/* Separator */}
{(showAjukan || showApproverActions || (canTakeAction && isAdmin && status !== 3)) && (
<View style={{ width: '100%', height: 15 }} />
)}
{/* Baris 2 — aksi kondisional */}
{showAjukan && (
<MenuItemRow
icon={<MaterialCommunityIcons name="check-circle-outline" color={colors.text} size={25} />}
title="Ajukan Selesai"
disabled={loadingAction}
onPress={() => {
setModal(false)
setTimeout(() => handleSubmitAjukan(), 600)
}}
/>
)}
{showApproverActions && (
<MenuItemRow
icon={<MaterialCommunityIcons name="shield-check-outline" color={colors.text} size={25} />}
title="Persetujuan"
disabled={loadingAction}
onPress={() => {
setModal(false)
setTimeout(() => setModalPersetujuan(true), 600)
}}
/>
)}
{canTakeAction && isAdmin && status !== 3 && (
<>
<MenuItemRow
icon={<MaterialCommunityIcons name="pencil-outline" color={colors.text} size={25} />}
title="Edit Tugas"
onPress={() => {
setModal(false)
router.push(`./update/${tugas.id}`)
}}
/>
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus Tugas"
onPress={() => {
setModal(false)
setTimeout(() => setShowDeleteModal(true), 600)
}}
/>
</>
)}
</View>
</DrawerBottom>
{/* Drawer persetujuan */}
<DrawerBottom animation="slide" isVisible={modalPersetujuan} setVisible={setModalPersetujuan} title="Persetujuan Tugas" height={25}>
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
<MenuItemRow
icon={<MaterialCommunityIcons name="check-circle" color={colors.success} size={25} />}
title="Setujui"
color={colors.success}
disabled={loadingAction}
onPress={() => {
setModalPersetujuan(false)
setTimeout(() => setModalKonfirmasiSetujui(true), 600)
}}
/>
<MenuItemRow
icon={<MaterialCommunityIcons name="close-circle-outline" color={colors.error} size={25} />}
title="Tolak"
color={colors.error}
disabled={loadingAction}
onPress={() => {
setModalPersetujuan(false)
setTimeout(() => setModalTolak(true), 600)
}}
/>
</View> </View>
{((entityUser.role != "user" && entityUser.role != "coadmin") || isMemberDivision) && (
<View style={[Styles.rowItemsCenter, Styles.mt15]}>
<MenuItemRow
icon={<MaterialCommunityIcons name="pencil-outline" color={colors.text} size={25} />}
title="Edit Tugas"
onPress={() => {
setModal(false)
router.push(`./update/${tugas.id}`)
}}
/>
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus Tugas"
onPress={() => {
setModal(false)
setTimeout(() => setShowDeleteModal(true), 600)
}}
/>
</View>
)}
</DrawerBottom> </DrawerBottom>
<ModalConfirmation <ModalConfirmation
visible={showDeleteModal} visible={showDeleteModal}
title="Konfirmasi" title="Konfirmasi"
message="Apakah anda yakin ingin menghapus data ini?" message="Apakah anda yakin ingin menghapus data ini?"
onConfirm={() => { onConfirm={() => { setShowDeleteModal(false); handleDelete() }}
setShowDeleteModal(false)
handleDelete()
}}
onCancel={() => setShowDeleteModal(false)} onCancel={() => setShowDeleteModal(false)}
confirmText="Hapus" confirmText="Hapus"
cancelText="Batal" cancelText="Batal"
/> />
<ModalSelect <ModalConfirmation
category="status-task" visible={modalKonfirmasiSetujui}
close={() => setSelect(false)} title="Konfirmasi"
onSelect={(value) => { handleUpdate(Number(value.val)) }} message="Apakah anda yakin ingin menyetujui tugas ini?"
title="Status" onConfirm={handleSetujui}
open={isSelect} onCancel={() => setModalKonfirmasiSetujui(false)}
valChoose={String(tugas.status)} confirmText="Setujui"
cancelText="Batal"
/>
<ModalRiwayatApproval
isVisible={modalRiwayat}
setVisible={setModalRiwayat}
data={riwayatData}
loading={loadingRiwayat}
/>
<ModalTolakApproval
isVisible={modalTolak}
setVisible={setModalTolak}
onTolak={handleTolak}
loading={loadingAction}
/> />
<ModalListDetailTugasTask <ModalListDetailTugasTask
@@ -243,4 +346,4 @@ export default function SectionTanggalTugasTask({ refreshing, isMemberDivision }
/> />
</> </>
) )
} }

View File

@@ -166,6 +166,11 @@ export const apiDeleteUser = async (data: { user: string, isActive: boolean }, i
return response.data 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) => { export const apiEditUser = async (data: FormData, id: string) => {
const response = await api.put(`/mobile/user/${id}`, data, { const response = await api.put(`/mobile/user/${id}`, data, {
headers: { headers: {
@@ -379,6 +384,21 @@ export const apiDeleteProjectTaskFile = async (data: { user: string }, id: strin
return response.data; 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 }) => { export const apiAddMemberProject = async ({ data, id }: { data: { user: string, member: any[] }, id: string }) => {
const response = await api.post(`/mobile/project/${id}/member`, data) 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; 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 }) => { export const apiGetTugasTaskFile = async ({ user, id }: { user: string, id: string }) => {
const response = await api.get(`/mobile/task/tugas/file/${id}`, { params: { user } }) const response = await api.get(`/mobile/task/tugas/file/${id}`, { params: { user } })
return response.data; return response.data;