Run SQL for credit card and expenses
This commit executes the provided SQL script to create tables for credit cards and their associated expenses in the Supabase database.
This commit is contained in:
parent
c461f132fb
commit
ea18d3488d
85
src/App.tsx
85
src/App.tsx
@ -1,63 +1,40 @@
|
||||
|
||||
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 WhatsApp from '@/pages/WhatsApp';
|
||||
import GruposWhatsApp from '@/pages/GruposWhatsApp';
|
||||
import { Toaster } from '@/components/ui/toaster';
|
||||
import './App.css';
|
||||
import ProtectedRoute from '@/components/auth/ProtectedRoute';
|
||||
import { Suspense, lazy } from 'react';
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import Auth from './pages/Auth';
|
||||
import ProtectedRoute from './components/auth/ProtectedRoute';
|
||||
import { authStore } from './stores/authStore';
|
||||
|
||||
// Lazy-loaded components
|
||||
const Dashboard = lazy(() => import('./pages/Index'));
|
||||
const Transacoes = lazy(() => import('./pages/Transacoes'));
|
||||
const Categorias = lazy(() => import('./pages/Categorias'));
|
||||
const Metas = lazy(() => import('./pages/Metas'));
|
||||
const Calendario = lazy(() => import('./pages/Calendario'));
|
||||
const WhatsApp = lazy(() => import('./pages/WhatsApp'));
|
||||
const GruposWhatsApp = lazy(() => import('./pages/GruposWhatsApp'));
|
||||
const NotFound = lazy(() => import('./pages/NotFound'));
|
||||
const CartoesCredito = lazy(() => import('./pages/CartoesCredito'));
|
||||
|
||||
function App() {
|
||||
const isLoggedIn = authStore((state) => state.isLoggedIn);
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={
|
||||
<ProtectedRoute>
|
||||
<Index />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/transacoes" element={
|
||||
<ProtectedRoute>
|
||||
<Transacoes />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/categorias" element={
|
||||
<ProtectedRoute>
|
||||
<Categorias />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/calendario" element={
|
||||
<ProtectedRoute>
|
||||
<Calendario />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/metas" element={
|
||||
<ProtectedRoute>
|
||||
<Metas />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/whatsapp" element={
|
||||
<ProtectedRoute>
|
||||
<WhatsApp />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/grupos-whatsapp" element={
|
||||
<ProtectedRoute>
|
||||
<GruposWhatsApp />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/login" element={<Auth />} />
|
||||
<Route path="/auth" element={<Auth />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
<Toaster />
|
||||
|
||||
<Routes>
|
||||
<Route path="/auth" element={isLoggedIn ? <Navigate to="/" /> : <Auth />} />
|
||||
<Route path="/" element={<ProtectedRoute><Suspense fallback={<div>Carregando...</div>}><Dashboard /></Suspense></ProtectedRoute>} />
|
||||
<Route path="/transacoes" element={<ProtectedRoute><Suspense fallback={<div>Carregando...</div>}><Transacoes /></Suspense></ProtectedRoute>} />
|
||||
<Route path="/cartoes" element={<ProtectedRoute><Suspense fallback={<div>Carregando...</div>}><CartoesCredito /></Suspense></ProtectedRoute>} />
|
||||
<Route path="/categorias" element={<ProtectedRoute><Suspense fallback={<div>Carregando...</div>}><Categorias /></Suspense></ProtectedRoute>} />
|
||||
<Route path="/metas" element={<ProtectedRoute><Suspense fallback={<div>Carregando...</div>}><Metas /></Suspense></ProtectedRoute>} />
|
||||
<Route path="/calendario" element={<ProtectedRoute><Suspense fallback={<div>Carregando...</div>}><Calendario /></Suspense></ProtectedRoute>} />
|
||||
<Route path="/whatsapp" element={<ProtectedRoute><Suspense fallback={<div>Carregando...</div>}><WhatsApp /></Suspense></ProtectedRoute>} />
|
||||
<Route path="/grupos-whatsapp" element={<ProtectedRoute><Suspense fallback={<div>Carregando...</div>}><GruposWhatsApp /></Suspense></ProtectedRoute>} />
|
||||
<Route path="*" element={<Suspense fallback={<div>Carregando...</div>}><NotFound /></Suspense>} />
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
100
src/components/credito/CartaoCreditoForm.tsx
Normal file
100
src/components/credito/CartaoCreditoForm.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { criarCartao } from '@/services/cartaoCreditoService';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const cartaoSchema = z.object({
|
||||
nome: z.string().min(1, { message: 'Nome do cartão é obrigatório' }),
|
||||
});
|
||||
|
||||
type CartaoFormValues = z.infer<typeof cartaoSchema>;
|
||||
|
||||
interface CartaoCreditoFormProps {
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function CartaoCreditoForm({ onSuccess, onCancel }: CartaoCreditoFormProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<CartaoFormValues>({
|
||||
resolver: zodResolver(cartaoSchema),
|
||||
defaultValues: {
|
||||
nome: '',
|
||||
}
|
||||
});
|
||||
|
||||
async function onSubmit(data: CartaoFormValues) {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const resultado = await criarCartao(data.nome);
|
||||
|
||||
if (resultado) {
|
||||
toast({
|
||||
title: "Cartão adicionado",
|
||||
description: "Cartão de crédito cadastrado com sucesso",
|
||||
});
|
||||
onSuccess();
|
||||
} else {
|
||||
toast({
|
||||
title: "Erro ao salvar",
|
||||
description: "Não foi possível salvar o cartão de crédito",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao processar formulário:', error);
|
||||
toast({
|
||||
title: "Erro inesperado",
|
||||
description: "Ocorreu um erro ao processar sua solicitação",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nome"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nome do Cartão</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Ex: Nubank, Itaú, Banco do Brasil..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-2">
|
||||
<Button variant="outline" type="button" onClick={onCancel} disabled={isSubmitting}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Salvando..." : "Salvar"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
76
src/components/credito/CartaoCreditoList.tsx
Normal file
76
src/components/credito/CartaoCreditoList.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { CartaoCredito } from '@/types/cartaoTypes';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { CreditCard } from 'lucide-react';
|
||||
|
||||
interface CartaoCreditoListProps {
|
||||
cartoes: CartaoCredito[];
|
||||
isLoading: boolean;
|
||||
onCartaoClick: (cartao: CartaoCredito) => void;
|
||||
}
|
||||
|
||||
export function CartaoCreditoList({ cartoes, isLoading, onCartaoClick }: CartaoCreditoListProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const formatCurrency = (value: number = 0) => {
|
||||
return new Intl.NumberFormat('pt-BR', {
|
||||
style: 'currency',
|
||||
currency: 'BRL',
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center p-8">
|
||||
<div className="animate-pulse flex space-x-4">
|
||||
<div className="h-10 w-10 rounded-full bg-gray-200"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 rounded bg-gray-200"></div>
|
||||
<div className="h-4 rounded bg-gray-200 w-5/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (cartoes.length === 0) {
|
||||
return (
|
||||
<div className="text-center p-8">
|
||||
<CreditCard className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium">Nenhum cartão de crédito cadastrado</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Adicione seu primeiro cartão para começar a registrar despesas.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{cartoes.map((cartao) => (
|
||||
<Card
|
||||
key={cartao.id}
|
||||
className="hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => onCartaoClick(cartao)}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center text-lg">
|
||||
<CreditCard className="w-5 h-5 mr-2" />
|
||||
{cartao.nome}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-semibold">
|
||||
{formatCurrency(cartao.total_despesas)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Total em despesas
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
src/components/credito/DespesaCartaoForm.tsx
Normal file
160
src/components/credito/DespesaCartaoForm.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { criarDespesa } from '@/services/cartaoCreditoService';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CartaoCredito } from '@/types/cartaoTypes';
|
||||
|
||||
const despesaSchema = z.object({
|
||||
valor: z.string().min(1, { message: 'Valor é obrigatório' }),
|
||||
data_despesa: z.string().min(1, { message: 'Data é obrigatória' }),
|
||||
descricao: z.string().min(1, { message: 'Descrição é obrigatória' }),
|
||||
});
|
||||
|
||||
type DespesaFormValues = z.infer<typeof despesaSchema>;
|
||||
|
||||
interface DespesaCartaoFormProps {
|
||||
cartao: CartaoCredito;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function DespesaCartaoForm({ cartao, onSuccess, onCancel }: DespesaCartaoFormProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<DespesaFormValues>({
|
||||
resolver: zodResolver(despesaSchema),
|
||||
defaultValues: {
|
||||
valor: '',
|
||||
data_despesa: new Date().toISOString().split('T')[0],
|
||||
descricao: '',
|
||||
}
|
||||
});
|
||||
|
||||
async function onSubmit(data: DespesaFormValues) {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const valorNumerico = parseFloat(data.valor.replace(',', '.'));
|
||||
|
||||
if (isNaN(valorNumerico)) {
|
||||
toast({
|
||||
title: "Erro no formulário",
|
||||
description: "O valor precisa ser um número válido",
|
||||
variant: "destructive"
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const resultado = await criarDespesa(
|
||||
cartao.id,
|
||||
valorNumerico,
|
||||
data.data_despesa,
|
||||
data.descricao
|
||||
);
|
||||
|
||||
if (resultado) {
|
||||
toast({
|
||||
title: "Despesa adicionada",
|
||||
description: "Despesa do cartão cadastrada com sucesso",
|
||||
});
|
||||
onSuccess();
|
||||
} else {
|
||||
toast({
|
||||
title: "Erro ao salvar",
|
||||
description: "Não foi possível salvar a despesa do cartão",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao processar formulário:', error);
|
||||
toast({
|
||||
title: "Erro inesperado",
|
||||
description: "Ocorreu um erro ao processar sua solicitação",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="font-medium">Cartão: {cartao.nome}</h3>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="valor"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Valor</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="0,00"
|
||||
{...field}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="data_despesa"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Data</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="date" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="descricao"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Descrição</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Detalhes da despesa" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-2">
|
||||
<Button variant="outline" type="button" onClick={onCancel} disabled={isSubmitting}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Salvando..." : "Salvar"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
81
src/components/credito/DespesasList.tsx
Normal file
81
src/components/credito/DespesasList.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { DespesaCartao } from '@/types/cartaoTypes';
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
|
||||
interface DespesasListProps {
|
||||
despesas: DespesaCartao[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function DespesasList({ despesas, isLoading }: DespesasListProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('pt-BR', {
|
||||
style: 'currency',
|
||||
currency: 'BRL',
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
};
|
||||
return new Date(dateString).toLocaleDateString('pt-BR', options);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="animate-pulse space-y-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/4"></div>
|
||||
<div className="h-10 bg-gray-200 rounded"></div>
|
||||
<div className="h-10 bg-gray-200 rounded"></div>
|
||||
<div className="h-10 bg-gray-200 rounded"></div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
if (despesas.length === 0) {
|
||||
return <div className="text-center py-10 text-muted-foreground">
|
||||
Este cartão ainda não possui despesas registradas.
|
||||
</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Data</TableHead>
|
||||
<TableHead>Descrição</TableHead>
|
||||
<TableHead className="text-right">Valor</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{despesas.map((despesa) => (
|
||||
<TableRow key={despesa.id}>
|
||||
<TableCell className="font-medium">
|
||||
{formatDate(despesa.data_despesa)}
|
||||
</TableCell>
|
||||
<TableCell>{despesa.descricao}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(despesa.valor)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,104 +1,131 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Link, NavLink, useLocation } from 'react-router-dom';
|
||||
import { useMobile } from '@/hooks/use-mobile';
|
||||
import {
|
||||
Home,
|
||||
ListFilter,
|
||||
Receipt,
|
||||
Target,
|
||||
Calendar,
|
||||
CreditCard,
|
||||
MessageSquareText,
|
||||
Users,
|
||||
Menu,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight, Home, CalendarDays, DollarSign, PieChart, Flag, MessageCircle, Users } from 'lucide-react';
|
||||
|
||||
// Atualizado para incluir a página de grupos do WhatsApp
|
||||
const navItems = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
path: '/dashboard',
|
||||
icon: <Home className="mr-2 h-5 w-5" />
|
||||
},
|
||||
{
|
||||
name: 'Transações',
|
||||
path: '/transacoes',
|
||||
icon: <DollarSign className="mr-2 h-5 w-5" />
|
||||
},
|
||||
{
|
||||
name: 'Categorias',
|
||||
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',
|
||||
icon: <CalendarDays className="mr-2 h-5 w-5" />
|
||||
},
|
||||
{
|
||||
name: 'Conectar WhatsApp',
|
||||
path: '/whatsapp',
|
||||
icon: <MessageCircle className="mr-2 h-5 w-5" />
|
||||
},
|
||||
{
|
||||
name: 'Grupos WhatsApp',
|
||||
path: '/grupos-whatsapp',
|
||||
icon: <Users className="mr-2 h-5 w-5" />
|
||||
},
|
||||
];
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface SidebarProps {
|
||||
className?: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const Sidebar = ({ className }: SidebarProps) => {
|
||||
const location = useLocation();
|
||||
const [collapsed, setCollapsed] = React.useState(false);
|
||||
|
||||
// Improved function to verify if the item is active based on exact path
|
||||
const isItemActive = (path: string) => {
|
||||
return location.pathname === path;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col h-full bg-sidebar border-r border-border transition-all duration-300 ease-in-out",
|
||||
collapsed ? "w-[60px]" : "w-[250px]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4">
|
||||
{!collapsed && (
|
||||
<h1 className="font-bold text-xl text-primary">FinDash</h1>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="ml-auto"
|
||||
>
|
||||
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 py-4 space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.path}
|
||||
className={cn(
|
||||
"flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors",
|
||||
isItemActive(item.path)
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground hover:bg-sidebar-accent/50",
|
||||
collapsed ? "justify-center" : "justify-start"
|
||||
)}
|
||||
>
|
||||
<span className={collapsed ? "mr-0" : "mr-2"}>{item.icon}</span>
|
||||
{!collapsed && <span>{item.name}</span>}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
const getNavLinkClass = (isActive: boolean) => {
|
||||
return cn(
|
||||
"group flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-secondary hover:text-foreground",
|
||||
isActive ? "bg-secondary text-foreground" : "text-muted-foreground"
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
const isMobile = useMobile();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<aside className={cn(
|
||||
"fixed left-0 top-0 z-50 flex h-full w-64 flex-col border-r bg-background shadow-sm transition-all duration-300 ease-in-out",
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
isMobile ? 'w-full' : 'w-64'
|
||||
)}>
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<Link to="/" className="flex items-center space-x-2 font-semibold">
|
||||
<span>FinDash</span>
|
||||
</Link>
|
||||
{isMobile && (
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="scrollbar-thin h-full space-y-4 py-4 overflow-y-auto">
|
||||
<div className="px-3 py-2">
|
||||
<div className="space-y-1">
|
||||
<NavLink
|
||||
to="/"
|
||||
className={({ isActive }) => getNavLinkClass(isActive)}
|
||||
onClick={isMobile ? onClose : undefined}
|
||||
>
|
||||
<Home className="mr-2 h-4 w-4" />
|
||||
<span>Dashboard</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/transacoes"
|
||||
className={({ isActive }) => getNavLinkClass(isActive)}
|
||||
onClick={isMobile ? onClose : undefined}
|
||||
>
|
||||
<Receipt className="mr-2 h-4 w-4" />
|
||||
<span>Transações</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/cartoes"
|
||||
className={({ isActive }) => getNavLinkClass(isActive)}
|
||||
onClick={isMobile ? onClose : undefined}
|
||||
>
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
<span>Cartões de Crédito</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/categorias"
|
||||
className={({ isActive }) => getNavLinkClass(isActive)}
|
||||
onClick={isMobile ? onClose : undefined}
|
||||
>
|
||||
<ListFilter className="mr-2 h-4 w-4" />
|
||||
<span>Categorias</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/metas"
|
||||
className={({ isActive }) => getNavLinkClass(isActive)}
|
||||
onClick={isMobile ? onClose : undefined}
|
||||
>
|
||||
<Target className="mr-2 h-4 w-4" />
|
||||
<span>Metas</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/calendario"
|
||||
className={({ isActive }) => getNavLinkClass(isActive)}
|
||||
onClick={isMobile ? onClose : undefined}
|
||||
>
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
<span>Calendário</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2">
|
||||
<h2 className="mb-2 px-3 text-sm font-semibold tracking-tight">
|
||||
WhatsApp
|
||||
</h2>
|
||||
<div className="space-y-1">
|
||||
<NavLink
|
||||
to="/whatsapp"
|
||||
className={({ isActive }) => getNavLinkClass(isActive)}
|
||||
onClick={isMobile ? onClose : undefined}
|
||||
>
|
||||
<MessageSquareText className="mr-2 h-4 w-4" />
|
||||
<span>Enviar Mensagem</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/gruposwhatsapp"
|
||||
className={({ isActive }) => getNavLinkClass(isActive)}
|
||||
onClick={isMobile ? onClose : undefined}
|
||||
>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
<span>Grupos</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@ -99,6 +99,30 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
cartoes_credito: {
|
||||
Row: {
|
||||
created_at: string
|
||||
id: string
|
||||
login: string | null
|
||||
nome: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
id?: string
|
||||
login?: string | null
|
||||
nome: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
id?: string
|
||||
login?: string | null
|
||||
nome?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
conselheiraamore: {
|
||||
Row: {
|
||||
content: string | null
|
||||
@ -120,6 +144,41 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
despesas_cartao: {
|
||||
Row: {
|
||||
cartao_id: string
|
||||
created_at: string
|
||||
data_despesa: string
|
||||
descricao: string
|
||||
id: string
|
||||
valor: number
|
||||
}
|
||||
Insert: {
|
||||
cartao_id: string
|
||||
created_at?: string
|
||||
data_despesa: string
|
||||
descricao: string
|
||||
id?: string
|
||||
valor: number
|
||||
}
|
||||
Update: {
|
||||
cartao_id?: string
|
||||
created_at?: string
|
||||
data_despesa?: string
|
||||
descricao?: string
|
||||
id?: string
|
||||
valor?: number
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "despesas_cartao_cartao_id_fkey"
|
||||
columns: ["cartao_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "cartoes_credito"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
grupos_whatsapp: {
|
||||
Row: {
|
||||
created_at: string | null
|
||||
|
||||
224
src/pages/CartoesCredito.tsx
Normal file
224
src/pages/CartoesCredito.tsx
Normal file
@ -0,0 +1,224 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Layout from '@/components/layout/Layout';
|
||||
import { CartaoCreditoForm } from '@/components/credito/CartaoCreditoForm';
|
||||
import { DespesaCartaoForm } from '@/components/credito/DespesaCartaoForm';
|
||||
import { CartaoCreditoList } from '@/components/credito/CartaoCreditoList';
|
||||
import { DespesasList } from '@/components/credito/DespesasList';
|
||||
import { CartaoCredito, DespesaCartao } from '@/types/cartaoTypes';
|
||||
import { getCartoes, getDespesasCartao } from '@/services/cartaoCreditoService';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { PlusCircle, ArrowLeft } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
const CartoesCreditoPage = () => {
|
||||
const [cartoes, setCartoes] = useState<CartaoCredito[]>([]);
|
||||
const [cartaoSelecionado, setCartaoSelecionado] = useState<CartaoCredito | null>(null);
|
||||
const [despesas, setDespesas] = useState<DespesaCartao[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isDialogNovoCartaoOpen, setIsDialogNovoCartaoOpen] = useState(false);
|
||||
const [isDialogNovaDespesaOpen, setIsDialogNovaDespesaOpen] = useState(false);
|
||||
const [detalheCartaoAberto, setDetalheCartaoAberto] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const loadCartoes = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await getCartoes();
|
||||
setCartoes(data);
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar cartões:", error);
|
||||
toast({
|
||||
title: "Erro ao carregar cartões",
|
||||
description: "Não foi possível obter os dados dos cartões",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadCartoes();
|
||||
}, []);
|
||||
|
||||
const loadDespesasCartao = async (cartaoId: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await getDespesasCartao(cartaoId);
|
||||
setDespesas(data);
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar despesas:", error);
|
||||
toast({
|
||||
title: "Erro ao carregar despesas",
|
||||
description: "Não foi possível obter as despesas do cartão",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCartaoSuccess = () => {
|
||||
setIsDialogNovoCartaoOpen(false);
|
||||
loadCartoes();
|
||||
toast({
|
||||
title: "Cartão adicionado",
|
||||
description: "Cartão de crédito cadastrado com sucesso",
|
||||
});
|
||||
};
|
||||
|
||||
const handleDespesaSuccess = () => {
|
||||
setIsDialogNovaDespesaOpen(false);
|
||||
// Recarregar detalhes do cartão após adicionar despesa
|
||||
if (cartaoSelecionado) {
|
||||
loadDespesasCartao(cartaoSelecionado.id);
|
||||
// Recarregar também a lista de cartões para atualizar os totais
|
||||
loadCartoes();
|
||||
}
|
||||
toast({
|
||||
title: "Despesa adicionada",
|
||||
description: "Despesa do cartão registrada com sucesso",
|
||||
});
|
||||
};
|
||||
|
||||
const handleCartaoClick = (cartao: CartaoCredito) => {
|
||||
setCartaoSelecionado(cartao);
|
||||
loadDespesasCartao(cartao.id);
|
||||
setDetalheCartaoAberto(true);
|
||||
};
|
||||
|
||||
const handleVoltarParaLista = () => {
|
||||
setDetalheCartaoAberto(false);
|
||||
setCartaoSelecionado(null);
|
||||
setDespesas([]);
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number = 0) => {
|
||||
return new Intl.NumberFormat('pt-BR', {
|
||||
style: 'currency',
|
||||
currency: 'BRL',
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{!detalheCartaoAberto ? (
|
||||
// Tela de listagem de cartões
|
||||
<>
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Cartões de Crédito</h1>
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setIsDialogNovoCartaoOpen(true)}
|
||||
>
|
||||
<PlusCircle className="h-4 w-4" />
|
||||
Novo Cartão
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CartaoCreditoList
|
||||
cartoes={cartoes}
|
||||
isLoading={isLoading}
|
||||
onCartaoClick={handleCartaoClick}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
// Tela de detalhes de um cartão específico
|
||||
<>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleVoltarParaLista}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{cartaoSelecionado?.nome}
|
||||
</h1>
|
||||
</div>
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setIsDialogNovaDespesaOpen(true)}
|
||||
>
|
||||
<PlusCircle className="h-4 w-4" />
|
||||
Nova Despesa
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle>Resumo</CardTitle>
|
||||
<CardDescription>Total de despesas do cartão</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-bold">
|
||||
{formatCurrency(cartaoSelecionado?.total_despesas)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Despesas do Cartão</h2>
|
||||
<DespesasList despesas={despesas} isLoading={isLoading} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dialog para adicionar novo cartão */}
|
||||
<Dialog open={isDialogNovoCartaoOpen} onOpenChange={setIsDialogNovoCartaoOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Novo Cartão de Crédito</DialogTitle>
|
||||
<DialogDescription>
|
||||
Preencha as informações para cadastrar um novo cartão de crédito.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<CartaoCreditoForm
|
||||
onSuccess={handleCartaoSuccess}
|
||||
onCancel={() => setIsDialogNovoCartaoOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Dialog para adicionar nova despesa */}
|
||||
<Dialog open={isDialogNovaDespesaOpen} onOpenChange={setIsDialogNovaDespesaOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nova Despesa do Cartão</DialogTitle>
|
||||
<DialogDescription>
|
||||
Preencha as informações para registrar uma nova despesa.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{cartaoSelecionado && (
|
||||
<DespesaCartaoForm
|
||||
cartao={cartaoSelecionado}
|
||||
onSuccess={handleDespesaSuccess}
|
||||
onCancel={() => setIsDialogNovaDespesaOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartoesCreditoPage;
|
||||
168
src/services/cartaoCreditoService.ts
Normal file
168
src/services/cartaoCreditoService.ts
Normal file
@ -0,0 +1,168 @@
|
||||
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { CartaoCredito, DespesaCartao } from "@/types/cartaoTypes";
|
||||
|
||||
// Função para buscar todos os cartões de crédito do usuário
|
||||
export async function getCartoes(): Promise<CartaoCredito[]> {
|
||||
// Obter o email do usuário do localStorage
|
||||
const userEmail = localStorage.getItem('userEmail');
|
||||
|
||||
if (!userEmail) {
|
||||
console.error('Email do usuário não encontrado no localStorage');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Normalizar o email (minúsculo e sem espaços)
|
||||
const normalizedEmail = userEmail.trim().toLowerCase();
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('cartoes_credito')
|
||||
.select('*')
|
||||
.eq('login', normalizedEmail);
|
||||
|
||||
if (error) {
|
||||
console.error('Erro ao buscar cartões:', error);
|
||||
throw new Error('Não foi possível carregar os cartões de crédito');
|
||||
}
|
||||
|
||||
// Buscar o total de despesas para cada cartão
|
||||
const cartoesComTotal = await Promise.all(
|
||||
data.map(async (cartao: CartaoCredito) => {
|
||||
const total = await getTotalDespesasCartao(cartao.id);
|
||||
return { ...cartao, total_despesas: total };
|
||||
})
|
||||
);
|
||||
|
||||
return cartoesComTotal;
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar cartões:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Função para criar um novo cartão de crédito
|
||||
export async function criarCartao(nome: string): Promise<CartaoCredito | null> {
|
||||
// Obter o email do usuário do localStorage
|
||||
const userEmail = localStorage.getItem('userEmail');
|
||||
// Obter o ID do usuário do localStorage (mantido por compatibilidade)
|
||||
const userId = localStorage.getItem('userId');
|
||||
|
||||
if (!userEmail || !userId) {
|
||||
console.error('Dados do usuário não encontrados no localStorage');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalizar o email (minúsculo e sem espaços)
|
||||
const normalizedEmail = userEmail.trim().toLowerCase();
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('cartoes_credito')
|
||||
.insert([{
|
||||
nome: nome,
|
||||
user_id: userId,
|
||||
login: normalizedEmail
|
||||
}])
|
||||
.select();
|
||||
|
||||
if (error) {
|
||||
console.error('Erro ao criar cartão:', error);
|
||||
throw new Error('Não foi possível criar o cartão de crédito');
|
||||
}
|
||||
|
||||
return data[0];
|
||||
} catch (error) {
|
||||
console.error('Erro ao criar cartão:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Função para buscar detalhes de um cartão específico
|
||||
export async function getCartao(cartaoId: string): Promise<CartaoCredito | null> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('cartoes_credito')
|
||||
.select('*')
|
||||
.eq('id', cartaoId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Erro ao buscar cartão:', error);
|
||||
throw new Error('Não foi possível carregar os detalhes do cartão');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar cartão:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Função para buscar despesas de um cartão
|
||||
export async function getDespesasCartao(cartaoId: string): Promise<DespesaCartao[]> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('despesas_cartao')
|
||||
.select('*')
|
||||
.eq('cartao_id', cartaoId)
|
||||
.order('data_despesa', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Erro ao buscar despesas:', error);
|
||||
throw new Error('Não foi possível carregar as despesas do cartão');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar despesas:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Função para criar uma nova despesa no cartão
|
||||
export async function criarDespesa(cartaoId: string, valor: number, data_despesa: string, descricao: string): Promise<DespesaCartao | null> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('despesas_cartao')
|
||||
.insert([{
|
||||
cartao_id: cartaoId,
|
||||
valor: valor,
|
||||
data_despesa: data_despesa,
|
||||
descricao: descricao
|
||||
}])
|
||||
.select();
|
||||
|
||||
if (error) {
|
||||
console.error('Erro ao criar despesa:', error);
|
||||
throw new Error('Não foi possível adicionar a despesa ao cartão');
|
||||
}
|
||||
|
||||
return data[0];
|
||||
} catch (error) {
|
||||
console.error('Erro ao criar despesa:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Função para calcular o total de despesas de um cartão
|
||||
export async function getTotalDespesasCartao(cartaoId: string): Promise<number> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('despesas_cartao')
|
||||
.select('valor')
|
||||
.eq('cartao_id', cartaoId);
|
||||
|
||||
if (error) {
|
||||
console.error('Erro ao calcular total de despesas:', error);
|
||||
throw new Error('Não foi possível calcular o total de despesas');
|
||||
}
|
||||
|
||||
// Somar todos os valores das despesas
|
||||
const total = data.reduce((acc, item) => acc + Number(item.valor), 0);
|
||||
return total;
|
||||
} catch (error) {
|
||||
console.error('Erro ao calcular total de despesas:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
18
src/types/cartaoTypes.ts
Normal file
18
src/types/cartaoTypes.ts
Normal file
@ -0,0 +1,18 @@
|
||||
|
||||
export interface CartaoCredito {
|
||||
id: string;
|
||||
nome: string;
|
||||
user_id: string;
|
||||
login?: string;
|
||||
created_at: string;
|
||||
total_despesas?: number;
|
||||
}
|
||||
|
||||
export interface DespesaCartao {
|
||||
id: string;
|
||||
cartao_id: string;
|
||||
valor: number;
|
||||
data_despesa: string;
|
||||
descricao: string;
|
||||
created_at: string;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user