Run SQL to create financial goals table.

This commit is contained in:
gpt-engineer-app[bot] 2025-05-19 12:47:41 +00:00
parent 85b4d3e795
commit e66b0342b7
9 changed files with 735 additions and 168 deletions

View File

@ -1,154 +1,31 @@
import React, { useEffect, useState } from "react";
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import Index from "./pages/Index";
import Transacoes from "./pages/Transacoes";
import Categorias from "./pages/Categorias";
import Calendario from "./pages/Calendario";
import Auth from "./pages/Auth";
import NotFound from "./pages/NotFound";
import ProtectedRoute from "./components/auth/ProtectedRoute";
import { supabase } from "@/integrations/supabase/client";
// Create a client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
},
});
const App = () => {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const checkAuthentication = async () => {
try {
// Get stored authentication status
const storedAuth = localStorage.getItem('autenticado') === 'true';
if (storedAuth) {
console.log('Using stored authentication: authenticated');
setIsAuthenticated(true);
setIsLoading(false);
return;
}
// If no stored status, check session
const { data: sessionData } = await supabase.auth.getSession();
if (sessionData?.session) {
console.log('Session found in App.tsx');
setIsAuthenticated(true);
localStorage.setItem('autenticado', 'true');
localStorage.setItem('userId', sessionData.session.user.id);
} else {
// For RLS disabled mode, we default to authenticated
console.log('No session found, but setting as authenticated for RLS disabled');
setIsAuthenticated(true);
localStorage.setItem('autenticado', 'true');
localStorage.setItem('userId', 'default');
}
} catch (error) {
console.error('Error checking authentication in App.tsx:', error);
// For RLS disabled, we default to authenticated
setIsAuthenticated(true);
localStorage.setItem('autenticado', 'true');
localStorage.setItem('userId', 'default');
} finally {
setIsLoading(false);
}
};
checkAuthentication();
const { data: authListener } = supabase.auth.onAuthStateChange((event, session) => {
console.log('Auth event in App.tsx:', event);
// For RLS disabled mode, we keep authenticated state regardless
setIsAuthenticated(true);
localStorage.setItem('autenticado', 'true');
localStorage.setItem('userId', session?.user?.id || 'default');
});
return () => {
authListener.subscription.unsubscribe();
};
}, []);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<p>Carregando...</p>
</div>
);
}
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import Index from '@/pages/Index';
import Transacoes from '@/pages/Transacoes';
import Categorias from '@/pages/Categorias';
import Calendario from '@/pages/Calendario';
import NotFound from '@/pages/NotFound';
import Auth from '@/pages/Auth';
import Metas from '@/pages/Metas';
import { Toaster } from '@/components/ui/toaster';
import './App.css';
function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<TooltipProvider>
<Toaster />
<Sonner />
<Routes>
{/* Root path redirects to dashboard */}
<Route
path="/"
element={<Navigate to="/dashboard" replace />}
/>
{/* Dashboard route using Index component */}
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Index />
</ProtectedRoute>
}
/>
{/* Auth page is still available, but we should never need to redirect here */}
<Route path="/auth" element={<Auth />} />
{/* Protected routes - with RLS disabled, they're always accessible */}
<Route
path="/transacoes"
element={
<ProtectedRoute>
<Transacoes />
</ProtectedRoute>
}
/>
<Route
path="/categorias"
element={
<ProtectedRoute>
<Categorias />
</ProtectedRoute>
}
/>
<Route
path="/calendario"
element={
<ProtectedRoute>
<Calendario />
</ProtectedRoute>
}
/>
{/* 404 route */}
<Route path="*" element={<NotFound />} />
</Routes>
</TooltipProvider>
</BrowserRouter>
</QueryClientProvider>
<Router>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<Index />} />
<Route path="/transacoes" element={<Transacoes />} />
<Route path="/categorias" element={<Categorias />} />
<Route path="/calendario" element={<Calendario />} />
<Route path="/metas" element={<Metas />} />
<Route path="/login" element={<Auth />} />
<Route path="*" element={<NotFound />} />
</Routes>
<Toaster />
</Router>
);
};
}
export default App;

View File

@ -0,0 +1,90 @@
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { Trophy, Flag, FlagOff } from 'lucide-react';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from '@/components/ui/progress';
import { ResultadoMeta } from '@/types/financialTypes';
interface MetaProgressCardProps {
meta: ResultadoMeta;
onEditClick?: () => void;
}
export function MetaProgressCard({ meta, onEditClick }: MetaProgressCardProps) {
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL',
}).format(value);
};
const getMesNome = (mes: number) => {
const date = new Date(2000, mes - 1, 1);
return format(date, 'MMMM', { locale: ptBR });
};
const getStatusIcon = () => {
if (meta.meta_batida) {
return <Trophy className="h-8 w-8 text-yellow-500" />;
} else {
return <FlagOff className="h-8 w-8 text-gray-400" />;
}
};
const getProgressColor = () => {
if (meta.meta_batida) {
return 'bg-green-500';
} else if (meta.percentual_atingido >= 75) {
return 'bg-yellow-500';
} else {
return 'bg-red-500';
}
};
return (
<Card className="overflow-hidden">
<CardHeader className={`pb-2 ${meta.meta_batida ? 'bg-green-50' : 'bg-gray-50'}`}>
<CardTitle className="flex items-center justify-between">
<span className="capitalize">{getMesNome(meta.mes)} {meta.ano}</span>
<div className="flex items-center">
{getStatusIcon()}
</div>
</CardTitle>
</CardHeader>
<CardContent className="pt-4 space-y-4">
<div className="flex justify-between text-sm text-muted-foreground mb-1">
<span>Meta: {formatCurrency(meta.valor_meta)}</span>
<span>Economia: {formatCurrency(meta.economia_real)}</span>
</div>
<div className="space-y-1">
<div className="h-2 w-full">
<Progress
value={meta.percentual_atingido}
max={100}
className={getProgressColor()}
/>
</div>
<div className="flex justify-between text-xs">
<span>0%</span>
<span className="font-medium">{meta.percentual_atingido.toFixed(0)}% atingido</span>
<span>100%</span>
</div>
</div>
</CardContent>
{onEditClick && (
<CardFooter className="bg-muted/50 pt-1 px-4 pb-2">
<button
onClick={onEditClick}
className="text-sm text-muted-foreground hover:text-primary w-full text-center"
>
Editar meta
</button>
</CardFooter>
)}
</Card>
);
}

View File

@ -0,0 +1,214 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useToast } from '@/components/ui/use-toast';
import { salvarMeta } from '@/services/metasService';
const formSchema = z.object({
mes: z.string(),
ano: z.string(),
valor_meta: z.string().refine(value => !isNaN(Number(value)) && Number(value) > 0, {
message: 'Valor da meta deve ser um número positivo',
}),
});
interface MetaFormProps {
onSuccess: () => void;
onCancel: () => void;
userId: string;
valorInicial?: number;
mesInicial?: number;
anoInicial?: number;
}
export function MetasForm({ onSuccess, onCancel, userId, valorInicial = 0, mesInicial, anoInicial }: MetaFormProps) {
const { toast } = useToast();
const [isSubmitting, setIsSubmitting] = useState(false);
const meses = Array.from({ length: 12 }, (_, i) => {
const date = new Date(2000, i, 1);
return {
value: (i + 1).toString(),
label: format(date, 'MMMM', { locale: ptBR }),
};
});
const anos = Array.from({ length: 6 }, (_, i) => {
const ano = new Date().getFullYear() - 2 + i;
return {
value: ano.toString(),
label: ano.toString(),
};
});
// Valor atual do mês e ano
const currentDate = new Date();
const mesAtual = (mesInicial || currentDate.getMonth() + 1).toString();
const anoAtual = (anoInicial || currentDate.getFullYear()).toString();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
mes: mesAtual,
ano: anoAtual,
valor_meta: valorInicial > 0 ? valorInicial.toString() : '',
},
});
async function onSubmit(values: z.infer<typeof formSchema>) {
try {
setIsSubmitting(true);
const novaMeta = {
user_id: userId,
mes: parseInt(values.mes),
ano: parseInt(values.ano),
valor_meta: parseFloat(values.valor_meta)
};
await salvarMeta(novaMeta);
toast({
title: "Meta registrada com sucesso",
description: `Meta de economia para ${meses.find(m => m.value === values.mes)?.label} de ${values.ano} salva.`,
});
onSuccess();
} catch (error) {
console.error("Erro ao salvar meta:", error);
toast({
title: "Erro ao salvar",
description: "Não foi possível salvar a meta. Tente novamente.",
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="mes"
render={({ field }) => (
<FormItem>
<FormLabel>Mês</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
disabled={isSubmitting}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Selecione o mês" />
</SelectTrigger>
</FormControl>
<SelectContent>
{meses.map((mes) => (
<SelectItem key={mes.value} value={mes.value}>
{mes.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ano"
render={({ field }) => (
<FormItem>
<FormLabel>Ano</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
disabled={isSubmitting}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Selecione o ano" />
</SelectTrigger>
</FormControl>
<SelectContent>
{anos.map((ano) => (
<SelectItem key={ano.value} value={ano.value}>
{ano.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="valor_meta"
render={({ field }) => (
<FormItem>
<FormLabel>Valor da Meta (R$)</FormLabel>
<FormControl>
<Input
placeholder="0.00"
{...field}
disabled={isSubmitting}
type="number"
step="0.01"
min="0"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end space-x-2">
<Button
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
type="button"
>
Cancelar
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="bg-finance-green hover:bg-finance-green/90"
>
{isSubmitting ? "Salvando..." : "Salvar Meta"}
</Button>
</div>
</form>
</Form>
);
}

View File

@ -3,7 +3,7 @@ import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, Home, CalendarDays, DollarSign, PieChart } from 'lucide-react';
import { ChevronLeft, ChevronRight, Home, CalendarDays, DollarSign, PieChart, Flag } from 'lucide-react';
// Updated navItems with correct paths
const navItems = [
@ -22,6 +22,11 @@ const navItems = [
path: '/categorias',
icon: <PieChart className="mr-2 h-5 w-5" />
},
{
name: 'Metas',
path: '/metas',
icon: <Flag className="mr-2 h-5 w-5" />
},
{
name: 'Calendário',
path: '/calendario',

View File

@ -120,6 +120,33 @@ export type Database = {
}
Relationships: []
}
metas: {
Row: {
ano: number
created_at: string | null
id: string
mes: number
user_id: string
valor_meta: number
}
Insert: {
ano: number
created_at?: string | null
id?: string
mes: number
user_id: string
valor_meta: number
}
Update: {
ano?: number
created_at?: string | null
id?: string
mes?: number
user_id?: string
valor_meta?: number
}
Relationships: []
}
transacoes: {
Row: {
categoria: string | null

188
src/pages/Metas.tsx Normal file
View File

@ -0,0 +1,188 @@
import { useState, useEffect } from 'react';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import Layout from '@/components/layout/Layout';
import { MetasForm } from '@/components/dashboard/MetasForm';
import { MetaProgressCard } from '@/components/dashboard/MetaProgressCard';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { ResultadoMeta } from '@/types/financialTypes';
import { useToast } from '@/components/ui/use-toast';
import { calcularResultadosMetas, getMeta } from '@/services/metasService';
import { PlusCircle, Check } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
const MetasPage = () => {
const { toast } = useToast();
const [userId, setUserId] = useState<string>('');
const [resultados, setResultados] = useState<ResultadoMeta[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [metaParaEditar, setMetaParaEditar] = useState<{ mes: number, ano: number } | null>(null);
const [valorMetaAtual, setValorMetaAtual] = useState(0);
useEffect(() => {
// Obter userId do localStorage (temporário até termos autenticação completa)
const user = localStorage.getItem('finDashUser');
if (user) {
setUserId(user);
loadData(user);
} else {
setIsLoading(false);
}
}, []);
const loadData = async (user: string) => {
try {
setIsLoading(true);
const resultado = await calcularResultadosMetas(user);
setResultados(resultado);
} catch (error) {
console.error('Erro ao carregar metas:', error);
toast({
title: 'Erro ao carregar metas',
description: 'Não foi possível obter os dados de metas',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
const handleEditarMeta = async (mes: number, ano: number) => {
try {
const meta = await getMeta(userId, mes, ano);
if (meta) {
setValorMetaAtual(meta.valor_meta);
setMetaParaEditar({ mes, ano });
setIsDialogOpen(true);
}
} catch (error) {
console.error('Erro ao buscar meta para edição:', error);
}
};
const handleNovaMeta = () => {
setMetaParaEditar(null);
setValorMetaAtual(0);
setIsDialogOpen(true);
};
const handleSuccess = () => {
setIsDialogOpen(false);
loadData(userId);
};
const getMesAtual = () => {
const now = new Date();
return {
mes: now.getMonth() + 1,
ano: now.getFullYear(),
nome: format(now, 'MMMM', { locale: ptBR }),
};
};
const mesAtual = getMesAtual();
// Agrupar por ano (objeto com chave = ano e valor = array de metas daquele ano)
const resultadosPorAno = resultados.reduce((acc, meta) => {
if (!acc[meta.ano]) {
acc[meta.ano] = [];
}
acc[meta.ano].push(meta);
return acc;
}, {} as Record<number, ResultadoMeta[]>);
// Ordenar os anos de forma decrescente
const anosOrdenados = Object.keys(resultadosPorAno)
.map(Number)
.sort((a, b) => b - a);
return (
<Layout>
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold tracking-tight">Metas de Economia</h1>
<Button
className="flex items-center gap-2 bg-finance-blue hover:bg-finance-blue/90"
onClick={handleNovaMeta}
>
<PlusCircle className="h-4 w-4" />
Nova Meta
</Button>
</div>
{isLoading ? (
<div className="flex justify-center py-10">
<div className="text-center">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full mx-auto"></div>
<p className="mt-4 text-muted-foreground">Carregando suas metas...</p>
</div>
</div>
) : resultados.length === 0 ? (
<Card className="flex flex-col items-center justify-center py-10 px-4 text-center">
<h3 className="text-xl font-semibold mb-2">Nenhuma meta definida</h3>
<p className="text-muted-foreground mb-6">
Defina sua primeira meta de economia para {mesAtual.nome} {mesAtual.ano}
</p>
<Button onClick={handleNovaMeta}>
<PlusCircle className="h-4 w-4 mr-2" />
Definir Meta
</Button>
</Card>
) : (
<>
{anosOrdenados.map(ano => (
<div key={ano} className="space-y-3">
<h2 className="text-xl font-semibold">{ano}</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{resultadosPorAno[ano]
.sort((a, b) => b.mes - a.mes)
.map(meta => (
<MetaProgressCard
key={`${meta.ano}-${meta.mes}`}
meta={meta}
onEditClick={() => handleEditarMeta(meta.mes, meta.ano)}
/>
))}
</div>
</div>
))}
</>
)}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{metaParaEditar ? 'Editar Meta' : 'Nova Meta'}
</DialogTitle>
<DialogDescription>
{metaParaEditar
? 'Atualize os valores da sua meta de economia.'
: 'Defina uma nova meta de economia mensal.'}
</DialogDescription>
</DialogHeader>
<MetasForm
onSuccess={handleSuccess}
onCancel={() => setIsDialogOpen(false)}
userId={userId}
valorInicial={metaParaEditar ? valorMetaAtual : 0}
mesInicial={metaParaEditar?.mes}
anoInicial={metaParaEditar?.ano}
/>
</DialogContent>
</Dialog>
</div>
</Layout>
);
};
export default MetasPage;

View File

@ -128,25 +128,14 @@ const TransacoesPage = () => {
</Card>
</div>
{/* Split da tabela em duas colunas */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-4">
<h2 className="text-xl font-semibold text-finance-green">Receitas</h2>
<TransactionsTable
transactions={receitas}
isLoading={isLoading}
showPagination={false}
/>
</div>
<div className="space-y-4">
<h2 className="text-xl font-semibold text-finance-red">Despesas</h2>
<TransactionsTable
transactions={despesas}
isLoading={isLoading}
showPagination={false}
/>
</div>
{/* Tabela completa de transações */}
<div className="space-y-4">
<h2 className="text-xl font-semibold">Todas as Transações</h2>
<TransactionsTable
transactions={transactions}
isLoading={isLoading}
showPagination={true}
/>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>

View File

@ -0,0 +1,159 @@
import { supabase } from '@/integrations/supabase/client';
export interface Meta {
id: string;
user_id: string;
mes: number;
ano: number;
valor_meta: number;
created_at?: string;
}
export interface ResultadoMeta {
mes: number;
ano: number;
valor_meta: number;
economia_real: number;
percentual_atingido: number;
meta_batida: boolean;
}
// Obter todas as metas do usuário
export const getMetas = async (userId: string): Promise<Meta[]> => {
const { data, error } = await supabase
.from('metas')
.select('*')
.eq('user_id', userId)
.order('ano', { ascending: false })
.order('mes', { ascending: false });
if (error) {
console.error('Erro ao buscar metas:', error);
throw new Error(error.message);
}
return data || [];
};
// Obter meta específica
export const getMeta = async (userId: string, mes: number, ano: number): Promise<Meta | null> => {
const { data, error } = await supabase
.from('metas')
.select('*')
.eq('user_id', userId)
.eq('mes', mes)
.eq('ano', ano)
.maybeSingle();
if (error) {
console.error('Erro ao buscar meta:', error);
throw new Error(error.message);
}
return data;
};
// Criar ou atualizar meta
export const salvarMeta = async (meta: Partial<Meta>): Promise<Meta> => {
// Verificar se já existe uma meta para este mês/ano
const existingMeta = await getMeta(meta.user_id as string, meta.mes as number, meta.ano as number);
if (existingMeta) {
// Atualizar meta existente
const { data, error } = await supabase
.from('metas')
.update({ valor_meta: meta.valor_meta })
.eq('id', existingMeta.id)
.select()
.single();
if (error) {
console.error('Erro ao atualizar meta:', error);
throw new Error(error.message);
}
return data;
} else {
// Criar nova meta
const { data, error } = await supabase
.from('metas')
.insert(meta)
.select()
.single();
if (error) {
console.error('Erro ao criar meta:', error);
throw new Error(error.message);
}
return data;
}
};
// Deletar meta
export const deletarMeta = async (metaId: string): Promise<void> => {
const { error } = await supabase
.from('metas')
.delete()
.eq('id', metaId);
if (error) {
console.error('Erro ao deletar meta:', error);
throw new Error(error.message);
}
};
// Calcular resultados das metas
export const calcularResultadosMetas = async (userId: string): Promise<ResultadoMeta[]> => {
const metas = await getMetas(userId);
const resultados: ResultadoMeta[] = [];
for (const meta of metas) {
// Buscar transações do mês da meta
const startDate = new Date(meta.ano, meta.mes - 1, 1);
const endDate = new Date(meta.ano, meta.mes, 0);
const { data: transacoes, error } = await supabase
.from('transacoes')
.select('*')
.eq('user', userId)
.gte('quando', startDate.toISOString().split('T')[0])
.lte('quando', endDate.toISOString().split('T')[0]);
if (error) {
console.error('Erro ao buscar transações para cálculo de metas:', error);
continue;
}
// Calcular receitas e despesas do mês
const receitas = transacoes
.filter(t => t.tipo === 'receita')
.reduce((sum, t) => sum + Number(t.valor || 0), 0);
const despesas = transacoes
.filter(t => t.tipo === 'despesa')
.reduce((sum, t) => sum + Number(t.valor || 0), 0);
// Calcular economia real
const economiaReal = receitas - despesas;
// Calcular percentual atingido e se a meta foi batida
const percentualAtingido = meta.valor_meta > 0
? Math.min(100, (economiaReal / meta.valor_meta) * 100)
: 0;
const metaBatida = economiaReal >= meta.valor_meta;
resultados.push({
mes: meta.mes,
ano: meta.ano,
valor_meta: meta.valor_meta,
economia_real: economiaReal,
percentual_atingido: percentualAtingido,
meta_batida: metaBatida
});
}
return resultados;
};

View File

@ -37,3 +37,21 @@ export interface TransactionFilters {
tipo: string;
categoria: string;
}
export interface Meta {
id: string;
user_id: string;
mes: number;
ano: number;
valor_meta: number;
created_at?: string;
}
export interface ResultadoMeta {
mes: number;
ano: number;
valor_meta: number;
economia_real: number;
percentual_atingido: number;
meta_batida: boolean;
}