Kantongku: dropdown pilih bulan di Riwayat (summary + donut + daftar per bulan)
This commit is contained in:
+29
-8
@@ -184,17 +184,25 @@ export async function updateTransaction(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTransactions(limit = 50): Promise<TransactionWithCategory[]> {
|
export async function getTransactions(
|
||||||
|
limit = 50,
|
||||||
|
month: string | null = null
|
||||||
|
): Promise<TransactionWithCategory[]> {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
const monthClause = month
|
||||||
|
? `WHERE strftime('%Y-%m', t.occurred_at, 'localtime') = ?`
|
||||||
|
: '';
|
||||||
|
const params = month ? [month, limit] : [limit];
|
||||||
return db.getAllAsync<TransactionWithCategory>(
|
return db.getAllAsync<TransactionWithCategory>(
|
||||||
`SELECT t.id, t.type, t.amount, t.category_id AS categoryId, t.note,
|
`SELECT t.id, t.type, t.amount, t.category_id AS categoryId, t.note,
|
||||||
t.occurred_at AS occurredAt, t.created_at AS createdAt,
|
t.occurred_at AS occurredAt, t.created_at AS createdAt,
|
||||||
c.name AS categoryName, c.icon AS categoryIcon
|
c.name AS categoryName, c.icon AS categoryIcon
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
LEFT JOIN categories c ON c.id = t.category_id
|
LEFT JOIN categories c ON c.id = t.category_id
|
||||||
|
${monthClause}
|
||||||
ORDER BY t.occurred_at DESC, t.id DESC
|
ORDER BY t.occurred_at DESC, t.id DESC
|
||||||
LIMIT ?`,
|
LIMIT ?`,
|
||||||
[limit]
|
params
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,14 +264,21 @@ export async function getTopExpenseCategory(
|
|||||||
return row ?? null;
|
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 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 }>(
|
const row = await db.getFirstAsync<{ income: number; expense: number }>(
|
||||||
`SELECT
|
`SELECT
|
||||||
COALESCE(SUM(CASE WHEN type = 'income' THEN amount END), 0) AS income,
|
COALESCE(SUM(CASE WHEN type = 'income' THEN amount END), 0) AS income,
|
||||||
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount END), 0) AS expense
|
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount END), 0) AS expense
|
||||||
FROM transactions
|
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 };
|
return { income: row?.income ?? 0, expense: row?.expense ?? 0 };
|
||||||
}
|
}
|
||||||
@@ -371,18 +386,24 @@ export async function deleteSavingsTarget(id: number): Promise<void> {
|
|||||||
await db.runAsync('DELETE FROM savings_targets WHERE id = ?', [id]);
|
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 }>
|
Array<{ categoryName: string; categoryIcon: string; total: number }>
|
||||||
> {
|
> {
|
||||||
const db = getDb();
|
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(
|
return db.getAllAsync(
|
||||||
`SELECT c.name AS categoryName, c.icon AS categoryIcon, SUM(t.amount) AS total
|
`SELECT c.name AS categoryName, c.icon AS categoryIcon, SUM(t.amount) AS total
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
JOIN categories c ON c.id = t.category_id
|
JOIN categories c ON c.id = t.category_id
|
||||||
WHERE t.type = 'expense'
|
WHERE t.type = 'expense' AND ${monthClause}
|
||||||
AND strftime('%Y-%m', t.occurred_at, 'localtime') = strftime('%Y-%m', 'now', 'localtime')
|
|
||||||
GROUP BY c.id
|
GROUP BY c.id
|
||||||
ORDER BY total DESC`
|
ORDER BY total DESC`,
|
||||||
|
params
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { LinearGradient } from 'expo-linear-gradient';
|
|||||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
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 Svg, { Circle, G } from 'react-native-svg';
|
||||||
import { colors } from '../theme/colors';
|
import { colors } from '../theme/colors';
|
||||||
import { formatRupiah, formatRupiahShort } from '../utils/format';
|
import { formatRupiah, formatRupiahShort } from '../utils/format';
|
||||||
@@ -30,17 +30,32 @@ export function HistoryScreen() {
|
|||||||
const [categoryData, setCategoryData] = useState<
|
const [categoryData, setCategoryData] = useState<
|
||||||
Array<{ categoryName: string; categoryIcon: string; total: number }>
|
Array<{ categoryName: string; categoryIcon: string; total: number }>
|
||||||
>([]);
|
>([]);
|
||||||
|
const [monthKey, setMonthKey] = useState<string | null>(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 load = useCallback(async () => {
|
||||||
const [txs, m, cats] = await Promise.all([
|
const [txs, m, cats] = await Promise.all([
|
||||||
getTransactions(500),
|
getTransactions(500, monthKey),
|
||||||
getMonthSummary(),
|
getMonthSummary(monthKey),
|
||||||
getExpenseByCategory(),
|
getExpenseByCategory(monthKey),
|
||||||
]);
|
]);
|
||||||
setTransactions(txs);
|
setTransactions(txs);
|
||||||
setMonth(m);
|
setMonth(m);
|
||||||
setCategoryData(cats);
|
setCategoryData(cats);
|
||||||
}, []);
|
}, [monthKey]);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
@@ -72,10 +87,8 @@ export function HistoryScreen() {
|
|||||||
return [...map.entries()].map(([title, data]) => ({ title, data }));
|
return [...map.entries()].map(([title, data]) => ({ title, data }));
|
||||||
}, [transactions, filter]);
|
}, [transactions, filter]);
|
||||||
|
|
||||||
const monthLabel = new Date().toLocaleDateString('id-ID', {
|
const monthLabel =
|
||||||
month: 'long',
|
monthOptions.find((o) => o.key === monthKey)?.label ?? monthOptions[0]?.label ?? '';
|
||||||
year: 'numeric',
|
|
||||||
});
|
|
||||||
const net = month.income - month.expense;
|
const net = month.income - month.expense;
|
||||||
|
|
||||||
const handleDelete = (item: TransactionWithCategory) => {
|
const handleDelete = (item: TransactionWithCategory) => {
|
||||||
@@ -128,10 +141,11 @@ export function HistoryScreen() {
|
|||||||
/>
|
/>
|
||||||
<View style={styles.summaryTop}>
|
<View style={styles.summaryTop}>
|
||||||
<Text style={styles.summaryLabel}>RINGKASAN BULAN INI</Text>
|
<Text style={styles.summaryLabel}>RINGKASAN BULAN INI</Text>
|
||||||
<View style={styles.monthBadge}>
|
<Pressable style={styles.monthBadge} onPress={() => setMonthModal(true)}>
|
||||||
<Ionicons name="calendar" size={12} color={colors.primaryDark} />
|
<Ionicons name="calendar" size={12} color={colors.primaryDark} />
|
||||||
<Text style={styles.monthBadgeText}>{monthLabel}</Text>
|
<Text style={styles.monthBadgeText}>{monthLabel}</Text>
|
||||||
</View>
|
<Ionicons name="chevron-down" size={12} color={colors.primaryDark} />
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.summaryRow}>
|
<View style={styles.summaryRow}>
|
||||||
<View style={styles.summaryItem}>
|
<View style={styles.summaryItem}>
|
||||||
@@ -288,6 +302,47 @@ export function HistoryScreen() {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Modal pilih bulan */}
|
||||||
|
<Modal
|
||||||
|
visible={monthModal}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setMonthModal(false)}
|
||||||
|
>
|
||||||
|
<Pressable style={styles.modalBackdrop} onPress={() => setMonthModal(false)}>
|
||||||
|
<Pressable style={styles.modalSheet} onPress={() => {}}>
|
||||||
|
<Text style={styles.modalTitle}>Pilih bulan</Text>
|
||||||
|
<ScrollView style={styles.modalScroll}>
|
||||||
|
{monthOptions.map((o) => {
|
||||||
|
const active = o.key === monthKey;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={o.key}
|
||||||
|
style={[styles.monthRow, active && styles.monthRowActive]}
|
||||||
|
onPress={() => {
|
||||||
|
setMonthKey(o.key);
|
||||||
|
setMonthModal(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.monthRowText,
|
||||||
|
active && { color: colors.primaryDark, fontWeight: '700' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</Text>
|
||||||
|
{active && (
|
||||||
|
<Ionicons name="checkmark" size={18} color={colors.primary} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -420,6 +475,36 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
},
|
},
|
||||||
monthBadgeText: { fontSize: 11, fontWeight: '700', color: colors.primaryDark },
|
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 },
|
summaryRow: { flexDirection: 'row', gap: 12, marginTop: 14 },
|
||||||
summaryItem: { flex: 1 },
|
summaryItem: { flex: 1 },
|
||||||
summaryItemLabelRow: { flexDirection: 'row', alignItems: 'center', gap: 3 },
|
summaryItemLabelRow: { flexDirection: 'row', alignItems: 'center', gap: 3 },
|
||||||
|
|||||||
Reference in New Issue
Block a user