Kantongku: aplikasi pencatat keuangan pribadi offline-first
This commit is contained in:
@@ -0,0 +1,521 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
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 Svg, { Circle, G } from 'react-native-svg';
|
||||
import { colors } from '../theme/colors';
|
||||
import { formatRupiah, formatRupiahShort } from '../utils/format';
|
||||
import { getCategoryIcon3d } from '../utils/categoryIcons3d';
|
||||
import { deleteTransaction, getExpenseByCategory, getMonthSummary, getTransactions } from '../db/repositories';
|
||||
import type { TransactionType, TransactionWithCategory } from '../types';
|
||||
import type { RootStackParamList } from '../navigation';
|
||||
import { SwipeableRow } from '../components/SwipeableRow';
|
||||
|
||||
type Filter = 'all' | TransactionType;
|
||||
|
||||
const FILTERS: Array<{ key: Filter; label: string }> = [
|
||||
{ key: 'all', label: 'Semua' },
|
||||
{ key: 'income', label: 'Pemasukan' },
|
||||
{ key: 'expense', label: 'Pengeluaran' },
|
||||
];
|
||||
|
||||
export function HistoryScreen() {
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const [transactions, setTransactions] = useState<TransactionWithCategory[]>([]);
|
||||
const [month, setMonth] = useState({ income: 0, expense: 0 });
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
const [categoryData, setCategoryData] = useState<
|
||||
Array<{ categoryName: string; categoryIcon: string; total: number }>
|
||||
>([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [txs, m, cats] = await Promise.all([
|
||||
getTransactions(500),
|
||||
getMonthSummary(),
|
||||
getExpenseByCategory(),
|
||||
]);
|
||||
setTransactions(txs);
|
||||
setMonth(m);
|
||||
setCategoryData(cats);
|
||||
}, []);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
let active = true;
|
||||
load().then(() => {
|
||||
if (!active) return;
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [load])
|
||||
);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const filtered =
|
||||
filter === 'all' ? transactions : transactions.filter((t) => t.type === filter);
|
||||
const map = new Map<string, TransactionWithCategory[]>();
|
||||
for (const t of filtered) {
|
||||
const key = new Date(t.occurredAt).toLocaleDateString('id-ID', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
const arr = map.get(key) ?? [];
|
||||
arr.push(t);
|
||||
map.set(key, arr);
|
||||
}
|
||||
return [...map.entries()].map(([title, data]) => ({ title, data }));
|
||||
}, [transactions, filter]);
|
||||
|
||||
const monthLabel = new Date().toLocaleDateString('id-ID', {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
const net = month.income - month.expense;
|
||||
|
||||
const handleDelete = (item: TransactionWithCategory) => {
|
||||
const name = item.note || item.categoryName || 'Transaksi';
|
||||
Alert.alert(
|
||||
'Hapus transaksi?',
|
||||
`"${name}" (${item.type === 'income' ? '+' : '-'}${formatRupiah(item.amount)}) akan dihapus permanen.${
|
||||
item.type === 'income' ? '\nSisihan otomatisnya juga ikut terhapus.' : ''
|
||||
}`,
|
||||
[
|
||||
{ text: 'Batal', style: 'cancel' },
|
||||
{
|
||||
text: 'Hapus',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await deleteTransaction(item.id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
Alert.alert('Gagal menghapus', String(e));
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SectionList
|
||||
sections={sections}
|
||||
keyExtractor={(item) => String(item.id)}
|
||||
contentContainerStyle={styles.listContent}
|
||||
stickySectionHeadersEnabled={false}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
{/* Ringkasan bulan */}
|
||||
<LinearGradient
|
||||
colors={[colors.primary, colors.primaryDark]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.summaryCard}
|
||||
>
|
||||
<View style={styles.ornCircleLarge} />
|
||||
<View style={styles.ornCircleSmall} />
|
||||
<Image
|
||||
source={require('../../assets/cash-3d.png')}
|
||||
style={styles.ornCoin}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<View style={styles.summaryTop}>
|
||||
<Text style={styles.summaryLabel}>RINGKASAN BULAN INI</Text>
|
||||
<View style={styles.monthBadge}>
|
||||
<Ionicons name="calendar" size={12} color={colors.primaryDark} />
|
||||
<Text style={styles.monthBadgeText}>{monthLabel}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.summaryRow}>
|
||||
<View style={styles.summaryItem}>
|
||||
<View style={styles.summaryItemLabelRow}>
|
||||
<Ionicons name="arrow-down-circle" size={13} color="#BBF7D0" />
|
||||
<Text style={styles.summaryItemLabel}>Pemasukan</Text>
|
||||
</View>
|
||||
<Text style={[styles.summaryItemValue, { color: '#BBF7D0' }]}>
|
||||
+{formatRupiah(month.income)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.summaryItem}>
|
||||
<View style={styles.summaryItemLabelRow}>
|
||||
<Ionicons name="arrow-up-circle" size={13} color="#FECACA" />
|
||||
<Text style={styles.summaryItemLabel}>Pengeluaran</Text>
|
||||
</View>
|
||||
<Text style={[styles.summaryItemValue, { color: '#FECACA' }]}>
|
||||
-{formatRupiah(month.expense)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.summaryItem}>
|
||||
<View style={styles.summaryItemLabelRow}>
|
||||
<Ionicons
|
||||
name={net >= 0 ? 'trending-up' : 'trending-down'}
|
||||
size={13}
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
<Text style={styles.summaryItemLabel}>Selisih</Text>
|
||||
</View>
|
||||
<Text style={[styles.summaryItemValue, { color: colors.white }]}>
|
||||
{net >= 0 ? '+' : '-'}
|
||||
{formatRupiah(Math.abs(net))}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
|
||||
{/* Donut pengeluaran per kategori */}
|
||||
<View style={[styles.card, styles.donutCard]}>
|
||||
<Text style={styles.donutTitle}>PENGELUARAN PER KATEGORI</Text>
|
||||
{categoryData.length === 0 ? (
|
||||
<Text style={styles.donutEmpty}>
|
||||
Belum ada pengeluaran bulan ini.
|
||||
</Text>
|
||||
) : (
|
||||
<View style={styles.donutRow}>
|
||||
<DonutChart data={categoryData} />
|
||||
<View style={styles.legend}>
|
||||
{categoryData.slice(0, 5).map((c, i) => (
|
||||
<View key={c.categoryName} style={styles.legendRow}>
|
||||
<View
|
||||
style={[
|
||||
styles.legendDot,
|
||||
{ backgroundColor: PALET[i % PALET.length] },
|
||||
]}
|
||||
/>
|
||||
<Text style={styles.legendIcon}>{c.categoryIcon}</Text>
|
||||
<Text style={styles.legendName} numberOfLines={1}>
|
||||
{c.categoryName}
|
||||
</Text>
|
||||
<Text style={styles.legendPct}>
|
||||
{Math.round((c.total / month.expense) * 100)}%
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Filter */}
|
||||
<View style={styles.filterRow}>
|
||||
{FILTERS.map((f) => {
|
||||
const active = f.key === filter;
|
||||
return (
|
||||
<Pressable
|
||||
key={f.key}
|
||||
style={[styles.filterChip, active && styles.filterChipActive]}
|
||||
onPress={() => setFilter(f.key)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.filterText,
|
||||
active && { color: colors.white, fontWeight: '700' },
|
||||
]}
|
||||
>
|
||||
{f.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</>
|
||||
}
|
||||
renderSectionHeader={({ section }) => (
|
||||
<Text style={styles.sectionHeader}>{section.title}</Text>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View style={[styles.card, styles.emptyCard]}>
|
||||
<Text style={styles.emptyEmoji}>🔍</Text>
|
||||
<Text style={styles.emptyTitle}>Belum ada transaksi</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
Transaksi yang kamu catat akan muncul di sini.
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item }) => {
|
||||
const icon3d = getCategoryIcon3d(item.categoryName);
|
||||
return (
|
||||
<SwipeableRow
|
||||
onEdit={() =>
|
||||
navigation.navigate('EditTransaksi', { transactionId: item.id })
|
||||
}
|
||||
onDelete={() => handleDelete(item)}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.txRow,
|
||||
{
|
||||
backgroundColor:
|
||||
item.type === 'income' ? colors.incomeSoft : colors.expenseSoft,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.txIcon}>
|
||||
{icon3d ? (
|
||||
<Image source={icon3d} style={styles.txIcon3d} resizeMode="contain" />
|
||||
) : (
|
||||
<Text style={styles.txIconText}>{item.categoryIcon ?? '•'}</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.txInfo}>
|
||||
<Text style={styles.txName} numberOfLines={1}>
|
||||
{item.note || item.categoryName || 'Tanpa kategori'}
|
||||
</Text>
|
||||
<Text style={styles.txDate}>
|
||||
{new Date(item.occurredAt).toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.txAmount,
|
||||
{ color: item.type === 'income' ? colors.income : colors.expense },
|
||||
]}
|
||||
>
|
||||
{item.type === 'income' ? '+' : '-'}
|
||||
{formatRupiah(item.amount)}
|
||||
</Text>
|
||||
</View>
|
||||
</SwipeableRow>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const PALET = ['#0D9488', '#EF4444', '#F59E0B', '#3B82F6', '#8B5CF6', '#22C55E', '#EC4899', '#64748B'];
|
||||
|
||||
function DonutChart({
|
||||
data,
|
||||
}: {
|
||||
data: Array<{ categoryName: string; total: number }>;
|
||||
}) {
|
||||
const size = 132;
|
||||
const stroke = 20;
|
||||
const r = (size - stroke) / 2;
|
||||
const c = 2 * Math.PI * r;
|
||||
const total = data.reduce((s, d) => s + d.total, 0);
|
||||
let offset = 0;
|
||||
|
||||
return (
|
||||
<View style={styles.donutWrap}>
|
||||
<Svg width={size} height={size}>
|
||||
<G rotation={-90} origin={`${size / 2}, ${size / 2}`}>
|
||||
<Circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
stroke="#E2E8F0"
|
||||
strokeWidth={stroke}
|
||||
fill="none"
|
||||
/>
|
||||
{data.map((d, i) => {
|
||||
const frac = d.total / total;
|
||||
const dash = frac * c;
|
||||
const el = (
|
||||
<Circle
|
||||
key={d.categoryName}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
stroke={PALET[i % PALET.length]}
|
||||
strokeWidth={stroke}
|
||||
fill="none"
|
||||
strokeDasharray={`${dash} ${c - dash}`}
|
||||
strokeDashoffset={-offset}
|
||||
/>
|
||||
);
|
||||
offset += dash;
|
||||
return el;
|
||||
})}
|
||||
</G>
|
||||
</Svg>
|
||||
<View style={styles.donutCenter}>
|
||||
<Text style={styles.donutCenterLabel}>TOTAL</Text>
|
||||
<Text style={styles.donutCenterValue}>
|
||||
{formatRupiahShort(total)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
listContent: { paddingBottom: 32 },
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
// Summary card
|
||||
summaryCard: {
|
||||
marginHorizontal: 16,
|
||||
marginTop: 12,
|
||||
borderRadius: 20,
|
||||
padding: 18,
|
||||
overflow: 'hidden',
|
||||
shadowColor: colors.primaryDark,
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
elevation: 6,
|
||||
},
|
||||
ornCircleLarge: {
|
||||
position: 'absolute',
|
||||
right: -40,
|
||||
top: -50,
|
||||
width: 140,
|
||||
height: 140,
|
||||
borderRadius: 70,
|
||||
backgroundColor: 'rgba(255,255,255,0.06)',
|
||||
},
|
||||
ornCircleSmall: {
|
||||
position: 'absolute',
|
||||
left: -20,
|
||||
bottom: -30,
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
},
|
||||
ornCoin: {
|
||||
position: 'absolute',
|
||||
left: -20,
|
||||
top: 8,
|
||||
width: 180,
|
||||
height: 124,
|
||||
opacity: 0.35,
|
||||
transform: [{ perspective: 600 }, { rotateY: '20deg' }],
|
||||
},
|
||||
summaryTop: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
summaryLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 1,
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
},
|
||||
monthBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
backgroundColor: 'rgba(255,255,255,0.92)',
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
monthBadgeText: { fontSize: 11, fontWeight: '700', color: colors.primaryDark },
|
||||
summaryRow: { flexDirection: 'row', gap: 12, marginTop: 14 },
|
||||
summaryItem: { flex: 1 },
|
||||
summaryItemLabelRow: { flexDirection: 'row', alignItems: 'center', gap: 3 },
|
||||
summaryItemLabel: { fontSize: 10, color: 'rgba(255,255,255,0.75)' },
|
||||
summaryItemValue: { fontSize: 14, fontWeight: '800', marginTop: 4 },
|
||||
// Filter
|
||||
filterRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
marginHorizontal: 16,
|
||||
marginTop: 14,
|
||||
marginBottom: 4,
|
||||
},
|
||||
filterChip: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingVertical: 9,
|
||||
borderRadius: 999,
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
filterChipActive: {
|
||||
backgroundColor: colors.primary,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
filterText: { fontSize: 13, color: colors.textMuted },
|
||||
// Donut
|
||||
donutCard: { marginHorizontal: 16, marginTop: 14 },
|
||||
donutTitle: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 1,
|
||||
color: colors.textMuted,
|
||||
marginBottom: 12,
|
||||
},
|
||||
donutEmpty: { fontSize: 13, color: colors.textMuted, textAlign: 'center', paddingVertical: 12 },
|
||||
donutRow: { flexDirection: 'row', alignItems: 'center', gap: 16 },
|
||||
donutWrap: { position: 'relative' },
|
||||
donutCenter: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
donutCenterLabel: { fontSize: 9, fontWeight: '700', color: colors.textMuted },
|
||||
donutCenterValue: { fontSize: 15, fontWeight: '800', color: colors.text },
|
||||
legend: { flex: 1, gap: 7 },
|
||||
legendRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
legendDot: { width: 10, height: 10, borderRadius: 5 },
|
||||
legendIcon: { fontSize: 13 },
|
||||
legendName: { flex: 1, fontSize: 12, color: colors.text, fontWeight: '600' },
|
||||
legendPct: { fontSize: 12, fontWeight: '700', color: colors.textMuted },
|
||||
// Section & items
|
||||
sectionHeader: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
color: colors.textMuted,
|
||||
marginHorizontal: 20,
|
||||
marginTop: 18,
|
||||
marginBottom: 8,
|
||||
},
|
||||
txRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 14,
|
||||
shadowColor: '#0F172A',
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 6,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
elevation: 2,
|
||||
},
|
||||
txIcon: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
txIconText: { fontSize: 18 },
|
||||
txIcon3d: { width: 34, height: 34 },
|
||||
txInfo: { flex: 1 },
|
||||
txName: { fontSize: 14, fontWeight: '600', color: colors.text },
|
||||
txDate: { fontSize: 11, color: colors.textMuted, marginTop: 2 },
|
||||
txAmount: { fontSize: 14, fontWeight: '700' },
|
||||
pressed: { opacity: 0.7 },
|
||||
emptyCard: { alignItems: 'center', marginHorizontal: 16, marginTop: 24, paddingVertical: 28 },
|
||||
emptyEmoji: { fontSize: 38, marginBottom: 8 },
|
||||
emptyTitle: { fontSize: 15, fontWeight: '700', color: colors.text },
|
||||
emptyText: { color: colors.textMuted, fontSize: 13, textAlign: 'center', marginTop: 4 },
|
||||
});
|
||||
Reference in New Issue
Block a user