feat: tambah fitur kalender umum village dengan indikator per jenis event

- Tambah halaman /village-calendar dengan monthly grid dan agenda view
- Tampilkan acara divisi (DivisionCalendarReminder) dan kegiatan (ProjectTask) se-village
- Indikator dot dua warna pada kalender: ungu untuk acara divisi, biru-abu untuk kegiatan
- Tambah endpoint apiGetVillageCalendarByDate dan apiGetVillageCalendarIndicator
- Tambah menu Kalender di halaman /feature dengan grid layout flexWrap
- Sesuaikan warna EventItem dengan TYPE_COLORS village-calendar
- Pindahkan inline style ke Styles.ts sebagai class baru
This commit is contained in:
2026-05-11 15:19:21 +08:00
parent 74d8b8ef31
commit 84935e8188
7 changed files with 362 additions and 19 deletions

View File

@@ -23,13 +23,12 @@ export default function Feature() {
}}
/>
<View style={[Styles.p15]}>
<View style={[Styles.rowSpaceBetween, Styles.mb15]}>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', rowGap: 20 }}>
<ButtonFiturMenu icon={<Feather name="users" size={30} color={colors.icon} />} text="Divisi" onPress={() => { router.push('/division?active=true') }} />
<ButtonFiturMenu icon={<Feather name="bar-chart" size={30} color={colors.icon} />} text="Kegiatan" onPress={() => { router.push('/project?status=0') }} />
<ButtonFiturMenu icon={<Ionicons name="megaphone-outline" size={30} color={colors.icon} />} text="Pengumuman" onPress={() => { router.push('/announcement') }} />
<ButtonFiturMenu icon={<Ionicons name="chatbubbles-outline" size={30} color={colors.icon} />} text="Diskusi" onPress={() => { router.push('/discussion?active=true') }} />
</View>
<View style={[Styles.rowSpaceBetween, Styles.mb15, (entityUser.role == 'cosupadmin' ? Styles.w70 : entityUser.role == 'supadmin' || entityUser.role == 'developer' ? Styles.w100 : Styles.w40)]}>
<ButtonFiturMenu icon={<Feather name="calendar" size={30} color={colors.icon} />} text="Kalender" onPress={() => { router.push('/village-calendar') }} />
<ButtonFiturMenu icon={<MaterialCommunityIcons name="account-group-outline" size={30} color={colors.icon} />} text="Anggota" onPress={() => { router.push('/member') }} />
<ButtonFiturMenu icon={<MaterialCommunityIcons name="account-tie-outline" size={30} color={colors.icon} />} text="Jabatan" onPress={() => { router.push('/position') }} />
{
@@ -39,7 +38,6 @@ export default function Feature() {
(entityUser.role == "supadmin" || entityUser.role == "developer") &&
<>
<ButtonFiturMenu icon={<Ionicons name="bookmarks-outline" size={30} color={colors.icon} />} text="Lembaga Desa" onPress={() => { router.push('/group') }} />
{/* <ButtonFiturMenu icon={<Ionicons name="color-palette-sharp" size={30} color={colors.icon} />} text="Tema" onPress={() => { }} /> */}
<ButtonFiturMenu icon={<Ionicons name="images-outline" size={30} color={colors.icon} />} text="Banner" onPress={() => { router.push('/banner') }} />
</>
}

View File

@@ -0,0 +1,294 @@
import AppHeader from "@/components/AppHeader";
import ItemDateCalendar from "@/components/calendar/itemDateCalendar";
import Skeleton from "@/components/skeleton";
import Text from "@/components/Text";
import Styles from "@/constants/Styles";
import { apiGetVillageCalendarByDate, apiGetVillageCalendarIndicator } from "@/lib/api";
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 [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()}
/>
),
}}
/>
<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>
);
}