- Buat komponen GuideOverlay dengan animasi fade+slide, arrow tooltip, dan dot indicator - Buat hook useGuide untuk menyimpan state guide per fitur via AsyncStorage - Sentralisasi semua step guide di lib/guideSteps.ts - Pasang guide pada 12 halaman: village-calendar, project detail, banner, group, position, member, announcement, discussion, division calendar/document/discussion, dan division task detail - Posisi card menggunakan cardTopRatio (rasio layar) untuk kompatibilitas berbagai ukuran device - Tambah styles guide dan village calendar di constants/Styles.ts
305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
import AppHeader from "@/components/AppHeader";
|
||
import ItemDateCalendar from "@/components/calendar/itemDateCalendar";
|
||
import GuideOverlay from "@/components/GuideOverlay";
|
||
import Skeleton from "@/components/skeleton";
|
||
import Text from "@/components/Text";
|
||
import Styles from "@/constants/Styles";
|
||
import { apiGetVillageCalendarByDate, apiGetVillageCalendarIndicator } from "@/lib/api";
|
||
import { GUIDE_VILLAGE_CALENDAR } from "@/lib/guideSteps";
|
||
import { useGuide } from "@/lib/useGuide";
|
||
import { useAuthSession } from "@/providers/AuthProvider";
|
||
import { useTheme } from "@/providers/ThemeProvider";
|
||
import { Feather } from "@expo/vector-icons";
|
||
import { router, Stack } from "expo-router";
|
||
import 'intl';
|
||
import 'intl/locale-data/jsonp/id';
|
||
import moment from "moment";
|
||
import "moment/locale/id";
|
||
import { useEffect, useState } from "react";
|
||
import { Pressable, RefreshControl, SafeAreaView, ScrollView, View } from "react-native";
|
||
import Datepicker, {
|
||
CalendarComponents,
|
||
CalendarDay
|
||
} from "react-native-ui-datepicker";
|
||
moment.locale('id');
|
||
|
||
type EventItem = {
|
||
id: string;
|
||
type: 'calendar' | 'task';
|
||
title: string;
|
||
desc: string;
|
||
dateStart: string;
|
||
dateEnd: string;
|
||
timeStart: string | null;
|
||
timeEnd: string | null;
|
||
divisionName: string | null;
|
||
projectName: string | null;
|
||
idDivision: string | null;
|
||
idRef: string;
|
||
};
|
||
|
||
const TYPE_COLORS = {
|
||
calendar: {
|
||
light: { bg: '#A9B5DF', stick: '#7886C7' },
|
||
dark: { bg: '#2D2B5E', stick: '#7886C7' },
|
||
},
|
||
task: {
|
||
light: { bg: '#D6E6F2', stick: '#94B4C1' },
|
||
dark: { bg: '#1C3347', stick: '#94B4C1' },
|
||
},
|
||
};
|
||
|
||
function VillageEventItem({ item, onPress }: { item: EventItem; onPress: () => void }) {
|
||
const { activeTheme, colors } = useTheme();
|
||
const palette = TYPE_COLORS[item.type][activeTheme === 'dark' ? 'dark' : 'light'];
|
||
|
||
return (
|
||
<Pressable
|
||
style={[Styles.itemEvent, { backgroundColor: palette.bg }]}
|
||
onPress={onPress}
|
||
>
|
||
<View style={[Styles.dividerEvent, { backgroundColor: palette.stick }]} />
|
||
<View style={[Styles.flex1]}>
|
||
{item.timeStart ? (
|
||
<Text style={[Styles.textMediumNormal, { color: activeTheme === 'dark' ? '#ffffff' : '#000000' }]}>
|
||
{item.timeStart} - {item.timeEnd}
|
||
</Text>
|
||
) : (
|
||
<Text style={[Styles.textMediumNormal, { color: activeTheme === 'dark' ? '#ffffff' : '#000000' }]}>
|
||
{moment(item.dateStart).format('D MMM YYYY')} – {moment(item.dateEnd).format('D MMM YYYY')}
|
||
</Text>
|
||
)}
|
||
<Text numberOfLines={1} ellipsizeMode="tail" style={[Styles.textDefaultSemiBold, { color: colors.text }]}>
|
||
{item.title}
|
||
</Text>
|
||
<View style={[Styles.rowItemsCenter]}>
|
||
<View style={[Styles.villageEventBadge, { backgroundColor: palette.stick }]}>
|
||
<Text style={[Styles.textSmallSemiBold, { color: '#ffffff' }]}>
|
||
{item.type === 'calendar' ? 'Divisi' : 'Kegiatan'}
|
||
</Text>
|
||
</View>
|
||
<Text numberOfLines={1} ellipsizeMode="tail" style={[Styles.textMediumNormal, { color: activeTheme === 'dark' ? '#ffffff' : '#000000', flex: 1 }]}>
|
||
{item.type === 'calendar' ? item.divisionName : item.projectName}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
</Pressable>
|
||
);
|
||
}
|
||
|
||
|
||
export default function VillageCalendar() {
|
||
const { colors, activeTheme } = useTheme();
|
||
const { token, decryptToken } = useAuthSession();
|
||
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('village-calendar');
|
||
const [selected, setSelected] = useState<any>(new Date());
|
||
const [data, setData] = useState<EventItem[]>([]);
|
||
const [dataIndicator, setDataIndicator] = useState<{ calendar: string[], task: string[] }>({ calendar: [], task: [] });
|
||
const [month, setMonth] = useState<number>(new Date().getMonth());
|
||
const [loading, setLoading] = useState(true);
|
||
const [loadingBtn, setLoadingBtn] = useState(false);
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
|
||
async function handleLoad(showLoading: boolean) {
|
||
try {
|
||
setLoading(showLoading);
|
||
const hasil = await decryptToken(String(token?.current));
|
||
const response = await apiGetVillageCalendarByDate({
|
||
user: hasil,
|
||
date: moment(selected).format("YYYY-MM-DD"),
|
||
});
|
||
setData(response.data);
|
||
} catch (error) {
|
||
console.error(error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function handleLoadIndicator() {
|
||
try {
|
||
setLoadingBtn(true);
|
||
const newDate = new Date(selected?.getFullYear(), month, 1);
|
||
const hasil = await decryptToken(String(token?.current));
|
||
const response = await apiGetVillageCalendarIndicator({
|
||
user: hasil,
|
||
date: moment(newDate).format("YYYY-MM-DD"),
|
||
});
|
||
setDataIndicator(response.data ?? { calendar: [], task: [] });
|
||
} catch (error) {
|
||
console.error(error);
|
||
} finally {
|
||
setTimeout(() => setLoadingBtn(false), 500);
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
handleLoad(true);
|
||
}, [selected]);
|
||
|
||
useEffect(() => {
|
||
handleLoadIndicator();
|
||
}, [month]);
|
||
|
||
const handleRefresh = async () => {
|
||
setRefreshing(true);
|
||
handleLoad(false);
|
||
handleLoadIndicator();
|
||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||
setRefreshing(false);
|
||
};
|
||
|
||
function handlePressEvent(item: EventItem) {
|
||
if (item.type === 'calendar') {
|
||
router.push(`/division/${item.idDivision}/calendar/${item.id}` as any);
|
||
} else {
|
||
router.push(`/project/${item.idRef}` as any);
|
||
}
|
||
}
|
||
|
||
const calendarEvents = data.filter(e => e.type === 'calendar');
|
||
const taskEvents = data.filter(e => e.type === 'task');
|
||
|
||
const components: CalendarComponents = {
|
||
Day: (day: CalendarDay) => {
|
||
const today = moment(String(day.date)).format("YYYY-MM-DD");
|
||
return (
|
||
<ItemDateCalendar
|
||
text={day.text}
|
||
isSelected={day.isSelected}
|
||
isSignCalendar={dataIndicator.calendar.includes(today)}
|
||
isSignTask={dataIndicator.task.includes(today)}
|
||
onPress={() => setSelected(new Date(today))}
|
||
/>
|
||
);
|
||
},
|
||
IconNext: (
|
||
<Pressable onPress={() => !loadingBtn && setMonth(month + 1)}>
|
||
<Feather name="chevron-right" size={20} color={loadingBtn ? 'gray' : colors.text} />
|
||
</Pressable>
|
||
),
|
||
IconPrev: (
|
||
<Pressable onPress={() => !loadingBtn && setMonth(month - 1)}>
|
||
<Feather name="chevron-left" size={20} color={loadingBtn ? 'gray' : colors.text} />
|
||
</Pressable>
|
||
),
|
||
};
|
||
|
||
return (
|
||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
|
||
<Stack.Screen
|
||
options={{
|
||
headerShown: true,
|
||
header: () => (
|
||
<AppHeader
|
||
title="Kalender"
|
||
showBack={true}
|
||
onPressLeft={() => router.back()}
|
||
/>
|
||
),
|
||
}}
|
||
/>
|
||
<GuideOverlay
|
||
visible={guideVisible}
|
||
steps={GUIDE_VILLAGE_CALENDAR}
|
||
onDismiss={dismissGuide}
|
||
/>
|
||
<ScrollView
|
||
refreshControl={
|
||
<RefreshControl
|
||
refreshing={refreshing}
|
||
onRefresh={handleRefresh}
|
||
tintColor={colors.icon}
|
||
/>
|
||
}
|
||
style={[Styles.h100]}
|
||
>
|
||
<View style={[Styles.p15]}>
|
||
{/* Calendar grid */}
|
||
<View style={[Styles.wrapPaper, Styles.p10, { backgroundColor: colors.card, borderColor: colors.background }]}>
|
||
<Datepicker
|
||
components={components}
|
||
mode="single"
|
||
date={selected}
|
||
month={month}
|
||
onMonthChange={(m) => setMonth(m)}
|
||
styles={{
|
||
selected: Styles.selectedDate,
|
||
month_label: { color: colors.text },
|
||
month_selector_label: { color: colors.text },
|
||
year_label: { color: colors.text },
|
||
year_selector_label: { color: colors.text },
|
||
day_label: { color: colors.text },
|
||
time_label: { color: colors.text },
|
||
weekday_label: { color: colors.text },
|
||
}}
|
||
/>
|
||
</View>
|
||
|
||
{/* Legend */}
|
||
<View style={[Styles.rowItemsCenter, Styles.villageEventLegendRow]}>
|
||
<View style={[Styles.rowItemsCenter, Styles.villageEventLegendItem]}>
|
||
<View style={[Styles.villageEventLegendDot, { backgroundColor: '#7886C7' }]} />
|
||
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Acara Divisi</Text>
|
||
</View>
|
||
<View style={[Styles.rowItemsCenter, Styles.villageEventLegendItem]}>
|
||
<View style={[Styles.villageEventLegendDot, { backgroundColor: '#94B4C1' }]} />
|
||
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Kegiatan</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* Calendar events */}
|
||
<View style={[Styles.mb15, Styles.mt10]}>
|
||
<Text style={[Styles.textDefaultSemiBold, Styles.mb05]}>Acara Divisi</Text>
|
||
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.background }]}>
|
||
{loading ? (
|
||
<>
|
||
<Skeleton width={100} height={70} borderRadius={10} widthType="percent" />
|
||
<Skeleton width={100} height={70} borderRadius={10} widthType="percent" />
|
||
</>
|
||
) : calendarEvents.length > 0 ? (
|
||
calendarEvents.map((item, index) => (
|
||
<VillageEventItem
|
||
key={index}
|
||
item={item}
|
||
onPress={() => handlePressEvent(item)}
|
||
/>
|
||
))
|
||
) : (
|
||
<Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>
|
||
Tidak ada acara
|
||
</Text>
|
||
)}
|
||
</View>
|
||
</View>
|
||
|
||
{/* Task events */}
|
||
<View style={[Styles.mb15]}>
|
||
<Text style={[Styles.textDefaultSemiBold, Styles.mb05]}>Kegiatan</Text>
|
||
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.background }]}>
|
||
{loading ? (
|
||
<>
|
||
<Skeleton width={100} height={70} borderRadius={10} widthType="percent" />
|
||
<Skeleton width={100} height={70} borderRadius={10} widthType="percent" />
|
||
</>
|
||
) : taskEvents.length > 0 ? (
|
||
taskEvents.map((item, index) => (
|
||
<VillageEventItem
|
||
key={index}
|
||
item={item}
|
||
onPress={() => handlePressEvent(item)}
|
||
/>
|
||
))
|
||
) : (
|
||
<Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>
|
||
Tidak ada kegiatan
|
||
</Text>
|
||
)}
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</ScrollView>
|
||
</SafeAreaView>
|
||
);
|
||
}
|