diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 835e5c9..0f8ab7a 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -184,17 +184,25 @@ export async function updateTransaction( }); } -export async function getTransactions(limit = 50): Promise { +export async function getTransactions( + limit = 50, + month: string | null = null +): Promise { const db = getDb(); + const monthClause = month + ? `WHERE strftime('%Y-%m', t.occurred_at, 'localtime') = ?` + : ''; + const params = month ? [month, limit] : [limit]; return db.getAllAsync( `SELECT t.id, t.type, t.amount, t.category_id AS categoryId, t.note, t.occurred_at AS occurredAt, t.created_at AS createdAt, c.name AS categoryName, c.icon AS categoryIcon FROM transactions t LEFT JOIN categories c ON c.id = t.category_id + ${monthClause} ORDER BY t.occurred_at DESC, t.id DESC LIMIT ?`, - [limit] + params ); } @@ -256,14 +264,21 @@ export async function getTopExpenseCategory( return row ?? null; } -export async function getMonthSummary(): Promise<{ income: number; expense: number }> { +export async function getMonthSummary( + month: string | null = null +): Promise<{ income: number; expense: number }> { const db = getDb(); + const monthClause = month + ? `strftime('%Y-%m', occurred_at, 'localtime') = ?` + : `strftime('%Y-%m', occurred_at, 'localtime') = strftime('%Y-%m', 'now', 'localtime')`; + const params = month ? [month] : []; const row = await db.getFirstAsync<{ income: number; expense: number }>( `SELECT COALESCE(SUM(CASE WHEN type = 'income' THEN amount END), 0) AS income, COALESCE(SUM(CASE WHEN type = 'expense' THEN amount END), 0) AS expense FROM transactions - WHERE strftime('%Y-%m', occurred_at, 'localtime') = strftime('%Y-%m', 'now', 'localtime')` + WHERE ${monthClause}`, + params ); return { income: row?.income ?? 0, expense: row?.expense ?? 0 }; } @@ -371,18 +386,24 @@ export async function deleteSavingsTarget(id: number): Promise { await db.runAsync('DELETE FROM savings_targets WHERE id = ?', [id]); } -export async function getExpenseByCategory(): Promise< +export async function getExpenseByCategory( + month: string | null = null +): Promise< Array<{ categoryName: string; categoryIcon: string; total: number }> > { const db = getDb(); + const monthClause = month + ? `strftime('%Y-%m', t.occurred_at, 'localtime') = ?` + : `strftime('%Y-%m', t.occurred_at, 'localtime') = strftime('%Y-%m', 'now', 'localtime')`; + const params = month ? [month] : []; return db.getAllAsync( `SELECT c.name AS categoryName, c.icon AS categoryIcon, SUM(t.amount) AS total FROM transactions t JOIN categories c ON c.id = t.category_id - WHERE t.type = 'expense' - AND strftime('%Y-%m', t.occurred_at, 'localtime') = strftime('%Y-%m', 'now', 'localtime') + WHERE t.type = 'expense' AND ${monthClause} GROUP BY c.id - ORDER BY total DESC` + ORDER BY total DESC`, + params ); } diff --git a/src/screens/HistoryScreen.tsx b/src/screens/HistoryScreen.tsx index 052e4a5..56db2bd 100644 --- a/src/screens/HistoryScreen.tsx +++ b/src/screens/HistoryScreen.tsx @@ -3,7 +3,7 @@ import { LinearGradient } from 'expo-linear-gradient'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useCallback, useMemo, useState } from 'react'; -import { Alert, Image, Pressable, SectionList, StyleSheet, Text, View } from 'react-native'; +import { Alert, Image, Modal, Pressable, ScrollView, SectionList, StyleSheet, Text, View } from 'react-native'; import Svg, { Circle, G } from 'react-native-svg'; import { colors } from '../theme/colors'; import { formatRupiah, formatRupiahShort } from '../utils/format'; @@ -30,17 +30,32 @@ export function HistoryScreen() { const [categoryData, setCategoryData] = useState< Array<{ categoryName: string; categoryIcon: string; total: number }> >([]); + const [monthKey, setMonthKey] = useState(null); + const [monthModal, setMonthModal] = useState(false); + + const monthOptions = useMemo(() => { + const now = new Date(); + const out: Array<{ key: string; label: string }> = []; + for (let i = 0; i < 12; i++) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + out.push({ + key: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`, + label: d.toLocaleDateString('id-ID', { month: 'long', year: 'numeric' }), + }); + } + return out; + }, []); const load = useCallback(async () => { const [txs, m, cats] = await Promise.all([ - getTransactions(500), - getMonthSummary(), - getExpenseByCategory(), + getTransactions(500, monthKey), + getMonthSummary(monthKey), + getExpenseByCategory(monthKey), ]); setTransactions(txs); setMonth(m); setCategoryData(cats); - }, []); + }, [monthKey]); useFocusEffect( useCallback(() => { @@ -72,10 +87,8 @@ export function HistoryScreen() { return [...map.entries()].map(([title, data]) => ({ title, data })); }, [transactions, filter]); - const monthLabel = new Date().toLocaleDateString('id-ID', { - month: 'long', - year: 'numeric', - }); + const monthLabel = + monthOptions.find((o) => o.key === monthKey)?.label ?? monthOptions[0]?.label ?? ''; const net = month.income - month.expense; const handleDelete = (item: TransactionWithCategory) => { @@ -128,10 +141,11 @@ export function HistoryScreen() { /> RINGKASAN BULAN INI - + setMonthModal(true)}> {monthLabel} - + + @@ -288,6 +302,47 @@ export function HistoryScreen() { ); }} /> + + {/* Modal pilih bulan */} + setMonthModal(false)} + > + setMonthModal(false)}> + {}}> + Pilih bulan + + {monthOptions.map((o) => { + const active = o.key === monthKey; + return ( + { + setMonthKey(o.key); + setMonthModal(false); + }} + > + + {o.label} + + {active && ( + + )} + + ); + })} + + + + ); } @@ -420,6 +475,36 @@ const styles = StyleSheet.create({ paddingVertical: 4, }, monthBadgeText: { fontSize: 11, fontWeight: '700', color: colors.primaryDark }, + modalBackdrop: { + flex: 1, + backgroundColor: 'rgba(15,23,42,0.45)', + justifyContent: 'flex-end', + }, + modalSheet: { + backgroundColor: colors.surface, + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + padding: 20, + paddingBottom: 36, + }, + modalTitle: { + fontSize: 16, + fontWeight: '800', + color: colors.text, + marginBottom: 12, + textAlign: 'center', + }, + modalScroll: { maxHeight: 420 }, + monthRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 13, + paddingHorizontal: 12, + borderRadius: 12, + }, + monthRowActive: { backgroundColor: colors.primarySoft }, + monthRowText: { fontSize: 15, color: colors.text }, summaryRow: { flexDirection: 'row', gap: 12, marginTop: 14 }, summaryItem: { flex: 1 }, summaryItemLabelRow: { flexDirection: 'row', alignItems: 'center', gap: 3 },