amalia/18-mei-26 #49

Merged
amaliadwiy merged 5 commits from amalia/18-mei-26 into join 2026-05-18 17:28:19 +08:00
13 changed files with 115 additions and 66 deletions

View File

@@ -26,6 +26,7 @@ type Props = {
reason: string reason: string
status: number status: number
isActive: boolean isActive: boolean
idGroup: string
} }
export default function DetailTaskDivision() { export default function DetailTaskDivision() {
@@ -159,7 +160,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} isAdminDivision={isAdminDivision} status={data?.status} /> <SectionTanggalTugasTask refreshing={refreshing} isMemberDivision={isMemberDivision} isAdminDivision={isAdminDivision} status={data?.status} idGroup={data?.idGroup ?? ''} />
<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

@@ -251,7 +251,7 @@ export default function TugasFileScreen() {
disabled={loadingUpload} disabled={loadingUpload}
/> />
<ButtonSelect <ButtonSelect
value="Pilih dari File Proyek" value="Pilih dari File Kegiatan ini"
onPress={() => { onPress={() => {
setSelectedProjectFiles([]); setSelectedProjectFiles([]);
setPickerModal(true); setPickerModal(true);

View File

@@ -9,12 +9,11 @@ import { pushToPage } from "@/lib/pushToPage";
import { useAuthSession } from "@/providers/AuthProvider"; import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider"; import { useTheme } from "@/providers/ThemeProvider";
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { router, Stack } from "expo-router";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo } from "react"; import { router, Stack } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import { FlatList, Pressable, RefreshControl, SafeAreaView, View } from "react-native"; import { FlatList, Pressable, RefreshControl, SafeAreaView, View } from "react-native";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { useState } from "react";
type Props = { type Props = {
id: string id: string
@@ -79,6 +78,15 @@ export default function Notification() {
}, [data]) }, [data])
const listData = useMemo<ListRow[]>(() => { const listData = useMemo<ListRow[]>(() => {
const BULAN: Record<string, number> = {
'JAN': 0, 'FEB': 1, 'MAR': 2, 'APR': 3, 'MEI': 4, 'JUN': 5,
'JUL': 6, 'AGU': 7, 'SEP': 8, 'OKT': 9, 'NOV': 10, 'DES': 11,
}
const parseDate = (str: string) => {
const [d, m, y] = str.split(' ')
return new Date(Number(y), BULAN[m] ?? 0, Number(d)).getTime()
}
const groups: Record<string, Props[]> = {} const groups: Record<string, Props[]> = {}
const dateOrder: string[] = [] const dateOrder: string[] = []
@@ -90,6 +98,8 @@ export default function Notification() {
groups[item.createdAt].push(item) groups[item.createdAt].push(item)
}) })
dateOrder.sort((a, b) => parseDate(b) - parseDate(a))
const result: ListRow[] = [] const result: ListRow[] = []
dateOrder.forEach((date) => { dateOrder.forEach((date) => {
result.push({ _type: 'header', date }) result.push({ _type: 'header', date })
@@ -137,6 +147,17 @@ export default function Notification() {
} }
} }
async function handleMarkOneRead(id: string) {
try {
const hasil = await decryptToken(String(token?.current))
await apiReadOneNotification({ user: hasil, id: id })
await queryClient.invalidateQueries({ queryKey: ['notifications'] })
dispatch(setUpdateNotification(!updateNotification))
} catch (error) {
console.error(error)
}
}
return ( return (
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}> <SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen <Stack.Screen
@@ -153,7 +174,7 @@ export default function Notification() {
disabled={markingAll} disabled={markingAll}
style={{ opacity: markingAll ? 0.5 : 1, padding: 4 }} style={{ opacity: markingAll ? 0.5 : 1, padding: 4 }}
> >
<Feather name="check-square" size={22} color="white" /> <Feather name="check-square" size={20} color="white" />
</Pressable> </Pressable>
) : undefined ) : undefined
} }
@@ -175,7 +196,8 @@ export default function Notification() {
onCancel={() => setShowConfirm(false)} onCancel={() => setShowConfirm(false)}
/> />
<View style={[Styles.flex1, Styles.ph15, { paddingTop: 10 }]}>
<View style={[Styles.flex1, Styles.ph15, Styles.notifContainer]}>
{isLoading ? ( {isLoading ? (
[0, 1, 2, 3, 4].map((_, i) => <SkeletonTwoItem key={i} />) [0, 1, 2, 3, 4].map((_, i) => <SkeletonTwoItem key={i} />)
) : flatData.length === 0 ? ( ) : flatData.length === 0 ? (
@@ -204,11 +226,11 @@ export default function Notification() {
renderItem={({ item }) => { renderItem={({ item }) => {
if (item._type === 'header') { if (item._type === 'header') {
return ( return (
<View style={[Styles.rowItemsCenter, { marginTop: 16, marginBottom: 8 }]}> <View style={[Styles.rowItemsCenter, Styles.notifHeaderRow]}>
<Text style={{ fontSize: 11, fontWeight: '600', color: colors.dimmed, letterSpacing: 0.6, textTransform: 'uppercase' }}> <Text style={[Styles.notifDateText, { color: colors.dimmed }]}>
{item.date} {item.date}
</Text> </Text>
<View style={{ flex: 1, height: 1, backgroundColor: colors.icon + '20', marginLeft: 8 }} /> <View style={[Styles.notifDateSeparator, { backgroundColor: colors.icon + '20' }]} />
</View> </View>
) )
} }
@@ -218,37 +240,20 @@ export default function Notification() {
return ( return (
<Pressable <Pressable
onPress={() => handleReadNotification(item.id, item.category, item.idContent)} onPress={() => handleReadNotification(item.id, item.category, item.idContent)}
style={({ pressed }) => [{ style={({ pressed }) => [Styles.notifItemRow, {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 10,
borderWidth: 1,
borderColor: colors.icon + '20', borderColor: colors.icon + '20',
backgroundColor: pressed backgroundColor: pressed
? colors.icon + '10' ? colors.icon + '10'
: item.isRead : item.isRead
? colors.icon + '10' ? colors.icon + '10'
: colors.card, : colors.card,
paddingHorizontal: 12,
paddingVertical: 10,
marginBottom: 6,
}]} }]}
> >
{/* Colored icon */} <View style={[Styles.notifIconContainer, { backgroundColor: color + '20' }]}>
<View style={{
width: 42,
height: 42,
borderRadius: 21,
backgroundColor: color + '20',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}>
<Feather name={icon} size={20} color={color} /> <Feather name={icon} size={20} color={color} />
</View> </View>
{/* Content */} <View style={[Styles.flex1, Styles.notifContent]}>
<View style={[Styles.flex1, { marginLeft: 10 }]}>
<View style={[Styles.rowSpaceBetween, Styles.itemsCenter]}> <View style={[Styles.rowSpaceBetween, Styles.itemsCenter]}>
<View style={[Styles.flex1, Styles.mr10]}> <View style={[Styles.flex1, Styles.mr10]}>
<Text <Text
@@ -258,6 +263,20 @@ export default function Notification() {
{item.title} {item.title}
</Text> </Text>
</View> </View>
{!item.isRead && (
<Pressable
onPress={(e) => {
e.stopPropagation()
handleMarkOneRead(item.id)
}}
hitSlop={8}
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, flexShrink: 0 })}
>
<Text style={Styles.notifMarkReadText}>
Tandai dibaca
</Text>
</Pressable>
)}
</View> </View>
<Text <Text
style={[Styles.textMediumNormal, { color: item.isRead ? colors.dimmed : colors.text, opacity: item.isRead ? 0.7 : 1 }]} style={[Styles.textMediumNormal, { color: item.isRead ? colors.dimmed : colors.text, opacity: item.isRead ? 0.7 : 1 }]}

View File

@@ -150,7 +150,7 @@ export default function DetailProject() {
} }
<SectionProgress progress={progress} doneCount={taskStats?.done} totalCount={taskStats?.total} /> <SectionProgress progress={progress} doneCount={taskStats?.done} totalCount={taskStats?.total} />
<SectionReportProject refreshing={refreshing} /> <SectionReportProject refreshing={refreshing} />
<SectionTanggalTugasProject status={data?.status} member={isMember} refreshing={refreshing} /> <SectionTanggalTugasProject status={data?.status} member={isMember} refreshing={refreshing} idGroup={data?.idGroup ?? ''} />
<SectionFile status={data?.status} member={isMember} refreshing={refreshing} /> <SectionFile status={data?.status} member={isMember} refreshing={refreshing} />
<SectionLink status={data?.status} member={isMember} refreshing={refreshing} /> <SectionLink status={data?.status} member={isMember} refreshing={refreshing} />
<SectionMember status={data?.status} refreshing={refreshing} /> <SectionMember status={data?.status} refreshing={refreshing} />

View File

@@ -246,7 +246,7 @@ export default function ProjectTugasFileScreen() {
disabled={loadingUpload} disabled={loadingUpload}
/> />
<ButtonSelect <ButtonSelect
value="Pilih dari File Proyek" value="Pilih dari File Kegiatan ini"
onPress={() => { onPress={() => {
setSelectedProjectFiles([]); setSelectedProjectFiles([]);
setPickerModal(true); setPickerModal(true);

View File

@@ -33,13 +33,7 @@ function ApprovalStatusBadge({ status }: { status: number }) {
: { label: 'Menunggu', color: '#FFA94D' } : { label: 'Menunggu', color: '#FFA94D' }
return ( return (
<View style={{ <View style={[Styles.approvalBadge, { backgroundColor: config.color + '20' }]}>
backgroundColor: config.color + '20',
borderRadius: 20,
paddingHorizontal: 10,
paddingVertical: 3,
alignSelf: 'flex-start',
}}>
<Text style={[Styles.textSmallSemiBold, { color: config.color }]}> <Text style={[Styles.textSmallSemiBold, { color: config.color }]}>
{config.label} {config.label}
</Text> </Text>
@@ -79,16 +73,10 @@ export default function ModalRiwayatApproval({ isVisible, setVisible, data, load
data.map((item, index) => ( data.map((item, index) => (
<View <View
key={item.id} key={item.id}
style={{ style={[Styles.approvalItem, { borderColor: colors.icon + '30' }]}
borderWidth: 1,
borderColor: colors.icon + '30',
borderRadius: 10,
padding: 12,
marginBottom: 10,
}}
> >
{/* Status + tanggal */} {/* Status + tanggal */}
<View style={[Styles.rowItemsCenter, { justifyContent: 'space-between', marginBottom: 8 }]}> <View style={[Styles.rowItemsCenter, Styles.approvalItemHeader]}>
<ApprovalStatusBadge status={item.status} /> <ApprovalStatusBadge status={item.status} />
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}> <Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>
{item.createdAt} {item.createdAt}
@@ -97,15 +85,15 @@ export default function ModalRiwayatApproval({ isVisible, setVisible, data, load
{/* Pengaju */} {/* Pengaju */}
<View style={[Styles.rowItemsCenter, Styles.mb05]}> <View style={[Styles.rowItemsCenter, Styles.mb05]}>
<MaterialCommunityIcons name="account-arrow-up-outline" size={15} color={colors.dimmed} style={{ marginRight: 6 }} /> <MaterialCommunityIcons name="account-arrow-up-outline" size={15} color={colors.text} style={Styles.approvalIconMr} />
<Text style={[Styles.textMediumSemiBold, { color: colors.dimmed }]}>Diajukan Oleh: </Text> <Text style={[Styles.textMediumSemiBold]}>Diajukan Oleh: </Text>
<Text style={[Styles.textMediumNormal]}>{item.submitter.name}</Text> <Text style={[Styles.textMediumNormal]}>{item.submitter.name}</Text>
</View> </View>
{/* Approver */} {/* Approver */}
<View style={[Styles.rowItemsCenter, item.note ? Styles.mb05 : {}]}> <View style={[Styles.rowItemsCenter, item.note ? Styles.mb05 : {}]}>
<MaterialCommunityIcons name="account-check-outline" size={15} color={colors.dimmed} style={{ marginRight: 6 }} /> <MaterialCommunityIcons name="account-check-outline" size={15} color={colors.text} style={Styles.approvalIconMr} />
<Text style={[Styles.textMediumSemiBold, { color: colors.dimmed }]}>Disetujui Oleh: </Text> <Text style={[Styles.textMediumSemiBold]}>Disetujui Oleh: </Text>
<Text style={[Styles.textMediumNormal]}> <Text style={[Styles.textMediumNormal]}>
{item.approver?.name ?? '-'} {item.approver?.name ?? '-'}
</Text> </Text>
@@ -113,16 +101,11 @@ export default function ModalRiwayatApproval({ isVisible, setVisible, data, load
{/* Catatan penolakan */} {/* Catatan penolakan */}
{item.note && ( {item.note && (
<View style={{ <View style={[Styles.approvalNoteBox, { backgroundColor: colors.icon + '12' }]}>
backgroundColor: colors.error + '12', <Text style={[Styles.textSmallSemiBold, Styles.approvalNoteLabel, { color: colors.error }]}>
borderRadius: 8,
padding: 8,
marginTop: 4,
}}>
<Text style={[Styles.textSmallSemiBold, { color: colors.error, marginBottom: 2 }]}>
Alasan Penolakan Alasan Penolakan
</Text> </Text>
<Text style={[Styles.textMediumNormal, { color: colors.text }]}> <Text style={[Styles.textMediumNormal]}>
{item.note} {item.note}
</Text> </Text>
</View> </View>
@@ -130,7 +113,7 @@ export default function ModalRiwayatApproval({ isVisible, setVisible, data, load
</View> </View>
)) ))
) : ( ) : (
<Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}> <Text style={[Styles.textDefault, Styles.approvalEmptyText, { color: colors.dimmed }]}>
Belum ada riwayat persetujuan Belum ada riwayat persetujuan
</Text> </Text>
)} )}

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, isApprover: response.data.isApprover ?? false })) dispatch(setEntityUser({ role: response.data.idUserRole, admin: false, isApprover: response.data.isApprover ?? false, idGroup: response.data.idGroup ?? '' }))
} }
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, isApprover: profile.isApprover ?? false })) dispatch(setEntityUser({ role: profile.idUserRole, admin: false, isApprover: profile.isApprover ?? false, idGroup: profile.idGroup ?? '' }))
} }
}, [profile, dispatch]) }, [profile, dispatch])

View File

@@ -39,7 +39,7 @@ type ApprovalRecord = {
createdAt: string createdAt: string
} }
export default function SectionTanggalTugasProject({ status, member, refreshing }: { status: number | undefined, member: boolean, refreshing?: boolean }) { export default function SectionTanggalTugasProject({ status, member, refreshing, idGroup }: { status: number | undefined, member: boolean, refreshing?: boolean, idGroup: string }) {
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()
@@ -61,7 +61,7 @@ export default function SectionTanggalTugasProject({ status, member, refreshing
const [tugas, setTugas] = useState({ id: '', status: 0 }) const [tugas, setTugas] = useState({ id: '', status: 0 })
const [showDeleteModal, setShowDeleteModal] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false)
const isApprover = entityUser.isApprover || ['supadmin', 'developer'].includes(entityUser.role) const isApprover = (entityUser.isApprover && entityUser.idGroup === idGroup) || ['supadmin', 'developer'].includes(entityUser.role)
const isAdmin = entityUser.role !== 'user' && entityUser.role !== 'coadmin' const isAdmin = entityUser.role !== 'user' && entityUser.role !== 'coadmin'
async function handleLoad(loading: boolean) { async function handleLoad(loading: boolean) {

View File

@@ -38,7 +38,7 @@ type ApprovalRecord = {
createdAt: string createdAt: string
} }
export default function SectionTanggalTugasTask({ refreshing, isMemberDivision, isAdminDivision, status }: { refreshing: boolean, isMemberDivision: boolean, isAdminDivision: boolean, status?: number }) { export default function SectionTanggalTugasTask({ refreshing, isMemberDivision, isAdminDivision, status, idGroup }: { refreshing: boolean, isMemberDivision: boolean, isAdminDivision: boolean, status?: number, idGroup: string }) {
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);
@@ -60,7 +60,7 @@ export default function SectionTanggalTugasTask({ refreshing, isMemberDivision,
const [tugas, setTugas] = useState({ id: '', status: 0 }) const [tugas, setTugas] = useState({ id: '', status: 0 })
const [showDeleteModal, setShowDeleteModal] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false)
const isApprover = entityUser.isApprover || ['supadmin', 'developer'].includes(entityUser.role) const isApprover = (entityUser.isApprover && entityUser.idGroup === idGroup) || ['supadmin', 'developer'].includes(entityUser.role)
const isAdmin = entityUser.role !== 'user' && entityUser.role !== 'coadmin' const isAdmin = entityUser.role !== 'user' && entityUser.role !== 'coadmin'
const canTakeAction = isMemberDivision || isAdmin const canTakeAction = isMemberDivision || isAdmin

View File

@@ -0,0 +1,13 @@
import { StyleSheet } from "react-native";
const ApprovalStyles = StyleSheet.create({
approvalBadge: { borderRadius: 20, paddingHorizontal: 10, paddingVertical: 3, alignSelf: 'flex-start' },
approvalItem: { borderWidth: 1, borderRadius: 10, padding: 12, marginBottom: 10 },
approvalItemHeader: { justifyContent: 'space-between', marginBottom: 8 },
approvalIconMr: { marginRight: 6 },
approvalNoteBox: { borderRadius: 8, padding: 8, marginTop: 4 },
approvalNoteLabel: { marginBottom: 2 },
approvalEmptyText: { textAlign: 'center' },
});
export default ApprovalStyles;

View File

@@ -9,6 +9,8 @@ import CardStyles from './card.styles';
import ModalStyles from './modal.styles'; import ModalStyles from './modal.styles';
import HeaderStyles from './header.styles'; import HeaderStyles from './header.styles';
import ComponentStyles from './component.styles'; import ComponentStyles from './component.styles';
import NotificationStyles from './notification.styles';
import ApprovalStyles from './approval.styles';
const Styles = StyleSheet.create({ const Styles = StyleSheet.create({
...SpacingStyles, ...SpacingStyles,
@@ -21,6 +23,8 @@ const Styles = StyleSheet.create({
...ModalStyles, ...ModalStyles,
...HeaderStyles, ...HeaderStyles,
...ComponentStyles, ...ComponentStyles,
...NotificationStyles,
...ApprovalStyles,
}); });
export default Styles; export default Styles;

View File

@@ -0,0 +1,29 @@
import { StyleSheet } from "react-native";
const NotificationStyles = StyleSheet.create({
notifContainer: { paddingTop: 10 },
notifHeaderRow: { marginTop: 16, marginBottom: 8 },
notifDateSeparator: { flex: 1, height: 1, marginLeft: 8 },
notifDateText: { fontSize: 11, fontWeight: '600', letterSpacing: 0.6, textTransform: 'uppercase' },
notifItemRow: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 10,
borderWidth: 1,
paddingHorizontal: 12,
paddingVertical: 10,
marginBottom: 6,
},
notifIconContainer: {
width: 42,
height: 42,
borderRadius: 21,
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
},
notifContent: { marginLeft: 10 },
notifMarkReadText: { fontSize: 11, color: '#3B82F6', fontWeight: '600' },
});
export default NotificationStyles;