522 lines
17 KiB
TypeScript
522 lines
17 KiB
TypeScript
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, item.type);
|
|
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: 110 },
|
|
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 },
|
|
});
|