diff options
Diffstat (limited to 'src/screens/dashboard-screen.tsx')
| -rw-r--r-- | src/screens/dashboard-screen.tsx | 440 |
1 files changed, 440 insertions, 0 deletions
diff --git a/src/screens/dashboard-screen.tsx b/src/screens/dashboard-screen.tsx new file mode 100644 index 0000000..c4a533c --- /dev/null +++ b/src/screens/dashboard-screen.tsx @@ -0,0 +1,440 @@ +import React, { useCallback, useState } from 'react'; +import { + ActivityIndicator, + RefreshControl, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { Feather } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import Animated, { + FadeInDown, + FadeInUp, + LinearTransition, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Card, CurrencyDisplay, Divider, SectionLabel } from '@/components/ui/primitives'; +import { ExpensesChart } from '@/components/ui/expenses-chart'; +import { Colors, Spacing, Typography } from '@/constants/theme'; +import { getDashboard } from '@/services/api'; +import type { DashboardData } from '@/types'; +import { useAuth } from '@/contexts/AuthContext'; +import { useRouter, useFocusEffect } from 'expo-router'; +import { useIsFocused } from "expo-router/react-navigation"; + +const MONTH_NAMES = [ + '', 'Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', + 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro', +]; + +export default function DashboardScreen() { + const insets = useSafeAreaInsets(); + const [refreshing, setRefreshing] = useState(false); + const [data, setData] = useState<DashboardData | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + const { user, logout } = useAuth(); + const router = useRouter(); + const isFocused = useIsFocused(); + + const [targetMonth, setTargetMonth] = useState(() => new Date().getMonth() + 1); + const [targetYear, setTargetYear] = useState(() => new Date().getFullYear()); + + const fetchDashboard = useCallback(async () => { + if (!user) return; + try { + setError(null); + const res = await getDashboard(user.id, targetMonth, targetYear); + setData(res); + } catch (err) { + setError('Não foi possível carregar os dados.'); + } finally { + setLoading(false); + } + }, [targetMonth, targetYear, user]); + + useFocusEffect( + useCallback(() => { + fetchDashboard(); + }, [fetchDashboard]) + ); + + async function handleRefresh() { + setRefreshing(true); + await fetchDashboard(); + setRefreshing(false); + } + + if (loading) { + return ( + <View style={[styles.container, styles.centered]}> + <ActivityIndicator size="large" color={Colors.primary} /> + </View> + ); + } + + if (error || !data) { + return ( + <View style={[styles.container, styles.centered]}> + <Text style={styles.errorText}>{error}</Text> + </View> + ); + } + + const isPositive = data.balance.available >= 0; + const monthName = MONTH_NAMES[data.period.month]; + + return ( + <View style={[styles.container, { paddingTop: insets.top }]}> + <LinearGradient + colors={['#0D2818', Colors.background]} + style={styles.headerGradient} + start={{ x: 0, y: 0 }} + end={{ x: 0, y: 1 }} + /> + + {isFocused && ( + <ScrollView + contentContainerStyle={[styles.scroll, { paddingBottom: insets.bottom + 80 }]} + showsVerticalScrollIndicator={false} + refreshControl={ + <RefreshControl + refreshing={refreshing} + onRefresh={handleRefresh} + tintColor={Colors.primaryLight} + /> + } + > + <Animated.View entering={FadeInUp.duration(500)} style={styles.headerTop}> + <View style={styles.greeting}> + <Text style={styles.greetingText}>Olá, {data.user.name} 👋</Text> + + <View style={styles.monthSelector}> + <TouchableOpacity + onPress={() => { + let m = targetMonth - 1; + let y = targetYear; + if (m < 1) { m = 12; y -= 1; } + setTargetMonth(m); + setTargetYear(y); + setLoading(true); + }} + style={styles.monthButton} + > + <Feather name="chevron-left" size={20} color={Colors.textSecondary} /> + </TouchableOpacity> + + <Text style={styles.periodText}> + {monthName} {data.period.year} + </Text> + + <TouchableOpacity + onPress={() => { + let m = targetMonth + 1; + let y = targetYear; + if (m > 12) { m = 1; y += 1; } + setTargetMonth(m); + setTargetYear(y); + setLoading(true); + }} + style={styles.monthButton} + > + <Feather name="chevron-right" size={20} color={Colors.textSecondary} /> + </TouchableOpacity> + </View> + </View> + + <TouchableOpacity + style={styles.logoutBtn} + onPress={async () => { + await logout(); + router.replace('/login'); + }} + > + <Feather name="log-out" size={20} color={Colors.textSecondary} /> + </TouchableOpacity> + </Animated.View> + + <Animated.View entering={FadeInUp.delay(100).duration(500)} layout={LinearTransition.springify()}> + <Card style={[styles.heroCard, isPositive ? styles.heroPositive : styles.heroNegative]}> + <SectionLabel + text="Saldo Disponível" + color={isPositive ? Colors.positive : Colors.negative} + /> + <View style={styles.heroBalance}> + <CurrencyDisplay amount={data.balance.available} size="display" /> + </View> + + <View style={styles.ratioSection}> + <View style={styles.ratioBar}> + <View + style={[ + styles.ratioFill, + { + width: `${Math.min(data.balance.expense_ratio, 100)}%` as `${number}%`, + backgroundColor: + data.balance.expense_ratio > 80 ? Colors.negative : Colors.primary, + }, + ]} + /> + </View> + <Text style={styles.ratioText}> + {data.balance.expense_ratio.toFixed(1)}% do salário comprometido + </Text> + </View> + </Card> + </Animated.View> + + <Animated.View entering={FadeInDown.delay(200).duration(500)} layout={LinearTransition.springify()}> + <Card style={styles.card}> + <SectionLabel text="Salário" /> + <View style={styles.salaryRow}> + <View style={styles.salaryItem}> + <Text style={styles.salaryLabel}>Bruto</Text> + <Text style={styles.salaryValue}> + {data.salary.gross.toLocaleString('pt-BR', { + style: 'currency', + currency: 'BRL', + minimumFractionDigits: 0, + })} + </Text> + </View> + <View style={styles.salaryArrow}> + <Text style={styles.salaryArrowText}>→</Text> + </View> + <View style={styles.salaryItem}> + <Text style={styles.salaryLabel}>Líquido</Text> + <Text style={[styles.salaryValue, { color: Colors.positive }]}> + {data.salary.effective_net.toLocaleString('pt-BR', { + style: 'currency', + currency: 'BRL', + minimumFractionDigits: 0, + })} + </Text> + </View> + </View> + <Divider /> + <View style={styles.taxRow}> + <Text style={styles.taxLabel}>INSS</Text> + <Text style={styles.taxValue}> + -{data.salary.inss_discount.toLocaleString('pt-BR', { + style: 'currency', + currency: 'BRL', + })} + </Text> + </View> + <View style={styles.taxRow}> + <Text style={styles.taxLabel}>IRRF</Text> + <Text style={styles.taxValue}> + -{data.salary.irrf_discount.toLocaleString('pt-BR', { + style: 'currency', + currency: 'BRL', + })} + </Text> + </View> + </Card> + </Animated.View> + + <Animated.View entering={FadeInDown.delay(250).duration(500)} layout={LinearTransition.springify()}> + <ExpensesChart + recurring={data.expenses.recurring_list} + oneTime={data.expenses.one_time_list} + /> + </Animated.View> + + <Animated.View entering={FadeInDown.delay(300).duration(500)} layout={LinearTransition.springify()}> + <Card style={styles.card}> + <View style={[styles.sectionHeader, { marginBottom: 0 }]}> + <SectionLabel text="Lançamentos Fixos" /> + <Text + style={[ + styles.totalBadge, + data.expenses.recurring_total < 0 ? { color: Colors.positive } : { color: Colors.negative } + ]} + > + {data.expenses.recurring_total < 0 ? '+ ' : (data.expenses.recurring_total > 0 ? '- ' : '')} + {Math.abs(data.expenses.recurring_total).toLocaleString('pt-BR', { + style: 'currency', + currency: 'BRL', + minimumFractionDigits: 0, + })} + </Text> + </View> + </Card> + </Animated.View> + + <Animated.View entering={FadeInDown.delay(400).duration(500)} layout={LinearTransition.springify()}> + <Card style={styles.card}> + <View style={[styles.sectionHeader, { marginBottom: 0 }]}> + <SectionLabel text="Lançamentos do Mês" /> + <Text + style={[ + styles.totalBadge, + data.expenses.one_time_total < 0 ? { color: Colors.positive } : { color: Colors.negative } + ]} + > + {data.expenses.one_time_total < 0 ? '+ ' : (data.expenses.one_time_total > 0 ? '- ' : '')} + {Math.abs(data.expenses.one_time_total).toLocaleString('pt-BR', { + style: 'currency', + currency: 'BRL', + minimumFractionDigits: 0, + })} + </Text> + </View> + </Card> + </Animated.View> + </ScrollView> + )} + </View> + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: Colors.background, + }, + centered: { + justifyContent: 'center', + alignItems: 'center', + }, + errorText: { + ...Typography.body, + color: Colors.negative, + }, + headerGradient: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: 200, + }, + scroll: { + padding: Spacing.md, + gap: Spacing.md, + }, + headerTop: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + paddingVertical: Spacing.sm, + }, + greeting: { + flex: 1, + }, + logoutBtn: { + padding: 8, + backgroundColor: 'rgba(255,255,255,0.05)', + borderRadius: 12, + }, + greetingText: { + ...Typography.h2, + color: Colors.textPrimary, + }, + periodText: { + ...Typography.caption, + color: Colors.textSecondary, + textTransform: 'uppercase', + letterSpacing: 1.5, + }, + monthSelector: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 6, + gap: Spacing.xs, + }, + monthButton: { + padding: 4, + }, + card: { + gap: Spacing.sm, + }, + heroCard: { + gap: Spacing.md, + borderWidth: 1, + }, + heroPositive: { + borderColor: `${Colors.positive}40`, + }, + heroNegative: { + borderColor: `${Colors.negative}40`, + }, + heroBalance: { + marginTop: Spacing.xs, + }, + ratioSection: { + gap: Spacing.xs, + }, + ratioBar: { + height: 4, + backgroundColor: Colors.border, + borderRadius: 2, + overflow: 'hidden', + }, + ratioFill: { + height: '100%', + borderRadius: 2, + }, + ratioText: { + ...Typography.caption, + color: Colors.textMuted, + }, + salaryRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginTop: Spacing.sm, + }, + salaryItem: { + flex: 1, + gap: 4, + }, + salaryLabel: { + ...Typography.caption, + color: Colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 1, + }, + salaryValue: { + ...Typography.h2, + color: Colors.textPrimary, + }, + salaryArrow: { + paddingHorizontal: Spacing.md, + }, + salaryArrowText: { + ...Typography.h2, + color: Colors.textMuted, + }, + taxRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + taxLabel: { + ...Typography.body, + color: Colors.textSecondary, + }, + taxValue: { + ...Typography.body, + color: Colors.negative, + fontFamily: 'DMSans_600SemiBold', + }, + sectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: Spacing.xs, + }, + totalBadge: { + ...Typography.body, + color: Colors.negative, + fontFamily: 'DMSans_600SemiBold', + }, + rowDivider: { + marginVertical: 0, + }, +}); |