chore: remover seções vazias de mudanças de código

Este commit remove seções vazias de mudanças de código do arquivo de
mudanças. Isso ajuda a manter o histórico de mudanças mais limpo e
organizado, facilitando a leitura e a compreensão das alterações
realizadas no projeto.
This commit is contained in:
Felipe Coutinho
2025-12-16 23:20:47 +00:00
parent 0767636eed
commit e7cb9c9db1
37 changed files with 1350 additions and 920 deletions

56
LICENSE Normal file
View File

@@ -0,0 +1,56 @@
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
Copyright (c) 2024-2025 Felipe Coutinho
Esta licença permite que outras pessoas remixem, adaptem e criem a partir do seu
trabalho para fins não comerciais, desde que atribuam o devido crédito e que licenciem
as novas criações sob termos idênticos.
---
VOCÊ É LIVRE PARA:
• Compartilhar — copiar e redistribuir o material em qualquer suporte ou formato
• Adaptar — remixar, transformar, e criar a partir do material
O licenciante não pode revogar estes direitos desde que você respeite os termos da licença.
---
DE ACORDO COM OS TERMOS SEGUINTES:
• Atribuição — Você deve dar o crédito apropriado, prover um link para a licença e indicar
se mudanças foram feitas. Você deve fazê-lo em qualquer circunstância razoável, mas de
maneira alguma que sugira ao licenciante a apoiar você ou o seu uso.
• NãoComercial — Você não pode usar o material para fins comerciais. Isso inclui:
- Vender o software ou serviços baseados nele
- Usar em produtos/serviços comerciais
- Oferecer como SaaS (Software as a Service) pago
- Qualquer uso que gere receita direta ou indireta
• CompartilhaIgual — Se você remixar, transformar, ou criar a partir do material, tem de
distribuir as suas contribuições sob a mesma licença que o original.
• Sem restrições adicionais — Você não pode aplicar termos jurídicos ou medidas de caráter
tecnológico que restrinjam legalmente outros de fazerem algo que a licença permita.
---
AVISOS:
Você não tem de cumprir com os termos da licença relativamente a elementos do material
que estejam no domínio público ou cuja utilização seja permitida por uma exceção ou
limitação que seja aplicável.
Não são dadas quaisquer garantias. A licença pode não lhe dar todas as autorizações
necessárias para o uso pretendido. Por exemplo, outros direitos, tais como direitos de
imagem, de privacidade ou direitos morais, podem limitar o uso do material.
---
TEXTO LEGAL COMPLETO:
https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode.pt
RESUMO LEGÍVEL:
https://creativecommons.org/licenses/by-nc-sa/4.0/deed.pt

View File

@@ -12,11 +12,13 @@
[![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-18-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue?style=flat-square&logo=docker)](https://www.docker.com/)
[![License](https://img.shields.io/badge/License-CC_BY--NC--SA_4.0-orange?style=flat-square&logo=creative-commons)](LICENSE)
[![Sponsor](https://img.shields.io/badge/Sponsor-❤️-ea4aaa?style=flat-square&logo=github-sponsors)](https://github.com/sponsors/felipegcoutinho)
---
<p align="center">
<img src="./public/dashboard-preview.png" alt="Dashboard Preview" width="800" />
<img src="./public/dashboard-preview-light.png" alt="Dashboard Preview" width="800" />
</p>
---
@@ -36,6 +38,7 @@
- [Banco de Dados](#-banco-de-dados)
- [Arquitetura](#-arquitetura)
- [Contribuindo](#-contribuindo)
- [Apoie o Projeto](#-apoie-o-projeto)
---
@@ -45,6 +48,8 @@
A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartões, gastos e receitas de forma clara. Se isso for útil pra você também, fique à vontade para usar e contribuir.
> 💡 **Licença Não-Comercial:** Este projeto é gratuito para uso pessoal, mas não pode ser usado comercialmente. Veja mais detalhes na seção [Licença](#-licença).
### ⚠️ Avisos importantes
**1. Não há versão hospedada online**
@@ -835,9 +840,59 @@ Contribuições são muito bem-vindas!
---
## 💖 Apoie o Projeto
Se o **Opensheets** está sendo útil para você e você quer apoiar o desenvolvimento contínuo do projeto, considere se tornar um sponsor!
[![Sponsor no GitHub](https://img.shields.io/badge/Sponsor_no_GitHub-❤️-ea4aaa?style=for-the-badge&logo=github-sponsors)](https://github.com/sponsors/felipegcoutinho)
### Por que apoiar?
- 🚀 **Desenvolvimento contínuo** - Novas features e melhorias regulares
- 🐛 **Correções de bugs** - Manutenção ativa e suporte
- 📚 **Documentação** - Guias e tutoriais detalhados
- 💡 **Novas ideias** - Implementação de sugestões da comunidade
### Outras formas de contribuir
Além do suporte financeiro, você pode contribuir:
- ⭐ Dando uma **estrela** no repositório
- 🐛 Reportando **bugs** e sugerindo melhorias
- 📖 Melhorando a **documentação**
- 💻 Submetendo **pull requests**
- 💬 Compartilhando o projeto com outras pessoas
---
## 📄 Licença
Este projeto é open source e está disponível sob a [Licença MIT](LICENSE).
Este projeto está licenciado sob a **Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International** (CC BY-NC-SA 4.0).
[![License: CC BY-NC-SA 4.0](https://img.shields.io/badge/License-CC_BY--NC--SA_4.0-lightgrey.svg)](https://creativecommons.org/licenses/by-nc-sa/4.0/)
### ✅ Você PODE:
- **Usar** o projeto para fins pessoais
- **Modificar** o código-fonte
- **Distribuir** e compartilhar o projeto
- **Fazer fork** e criar versões modificadas
### ❌ Você NÃO PODE:
- **Uso comercial** - Ganhar dinheiro com o projeto (vender, SaaS, consultoria baseada nele)
- **Remover créditos** - Você deve manter a atribuição ao autor original
- **Mudar a licença** - Suas modificações devem usar a mesma licença
### 📋 Requisitos:
- Dar **crédito** ao autor original (Felipe Coutinho)
- Indicar se **modificações** foram feitas
- Distribuir sob a **mesma licença** (CC BY-NC-SA 4.0)
**Resumo:** Use livremente para projetos pessoais, contribua, modifique - mas não ganhe dinheiro com isso.
Para o texto legal completo, consulte o arquivo [LICENSE](LICENSE) ou visite [creativecommons.org](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.pt).
---
@@ -861,7 +916,11 @@ Este projeto é open source e está disponível sob a [Licença MIT](LICENSE).
<div align="center">
**⭐ Se este projeto foi útil, considere dar uma estrela!**
**⭐ Se este projeto foi útil pra você:**
- Dê uma estrela no repositório
- [Apoie o projeto como sponsor](https://github.com/sponsors/felipegcoutinho)
- Compartilhe com outras pessoas
Desenvolvido com ❤️ para a comunidade open source

View File

@@ -1,16 +1,19 @@
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
import { cartoes, lancamentos } from "@/db/schema";
import { db } from "@/lib/db";
import {
buildOptionSets,
buildSluggedFilters,
fetchLancamentoFilterSources,
mapLancamentosData,
} from "@/lib/lancamentos/page-helpers";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { and, eq, gte, lte, ne, or } from "drizzle-orm";
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
import type { CalendarData, CalendarEvent } from "@/components/calendario/types";
import type {
CalendarData,
CalendarEvent,
} from "@/components/calendario/types";
const PAYMENT_METHOD_BOLETO = "Boleto";
const TRANSACTION_TYPE_TRANSFERENCIA = "Transferência";
@@ -98,7 +101,11 @@ export const fetchCalendarData = async ({
const cardTotals = new Map<string, number>();
for (const item of lancamentosData) {
if (!item.cartaoId || item.period !== period || item.pagadorRole !== PAGADOR_ROLE_ADMIN) {
if (
!item.cartaoId ||
item.period !== period ||
item.pagadorRole !== PAGADOR_ROLE_ADMIN
) {
continue;
}
const amount = Math.abs(item.amount ?? 0);

View File

@@ -10,11 +10,7 @@ export default async function HistoricoCategoriasPage() {
const data = await fetchCategoryHistory(user.id, currentPeriod);
return (
<main className="flex flex-col gap-6">
<p className="text-muted-foreground">
Acompanhe o histórico de desempenho das suas categorias ao longo de 9
meses.
</p>
<main>
<CategoryHistoryWidget data={data} />
</main>
);

View File

@@ -15,7 +15,7 @@ export default function RootLayout({
<PageDescription
icon={<RiPriceTag3Line />}
title="Categorias"
subtitle="Gerencie suas categorias de despesas e receitas. Acompanhe o desempenho financeiro por categoria e faça ajustes conforme necessário."
subtitle="Gerencie suas categorias de despesas e receitas acompanhando o histórico de desempenho dos últimos 9 meses, permitindo ajustes financeiros precisos conforme necessário."
/>
{children}
</section>

View File

@@ -1,6 +1,6 @@
"use server";
import { contas, lancamentos } from "@/db/schema";
import { contas, lancamentos, pagadores, categorias, cartoes } from "@/db/schema";
import {
INITIAL_BALANCE_CONDITION,
INITIAL_BALANCE_NOTE,
@@ -31,6 +31,78 @@ import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm";
import { randomUUID } from "node:crypto";
import { z } from "zod";
// ============================================================================
// Authorization Validation Functions
// ============================================================================
async function validatePagadorOwnership(
userId: string,
pagadorId: string | null | undefined
): Promise<boolean> {
if (!pagadorId) return true; // Se não tem pagadorId, não precisa validar
const pagador = await db.query.pagadores.findFirst({
where: and(
eq(pagadores.id, pagadorId),
eq(pagadores.userId, userId)
),
});
return !!pagador;
}
async function validateCategoriaOwnership(
userId: string,
categoriaId: string | null | undefined
): Promise<boolean> {
if (!categoriaId) return true;
const categoria = await db.query.categorias.findFirst({
where: and(
eq(categorias.id, categoriaId),
eq(categorias.userId, userId)
),
});
return !!categoria;
}
async function validateContaOwnership(
userId: string,
contaId: string | null | undefined
): Promise<boolean> {
if (!contaId) return true;
const conta = await db.query.contas.findFirst({
where: and(
eq(contas.id, contaId),
eq(contas.userId, userId)
),
});
return !!conta;
}
async function validateCartaoOwnership(
userId: string,
cartaoId: string | null | undefined
): Promise<boolean> {
if (!cartaoId) return true;
const cartao = await db.query.cartoes.findFirst({
where: and(
eq(cartoes.id, cartaoId),
eq(cartoes.userId, userId)
),
});
return !!cartao;
}
// ============================================================================
// Utility Functions
// ============================================================================
const resolvePeriod = (purchaseDate: string, period?: string | null) => {
if (period && /^\d{4}-\d{2}$/.test(period)) {
return period;
@@ -472,6 +544,42 @@ export async function createLancamentoAction(
const user = await getUser();
const data = createSchema.parse(input);
// Validar propriedade dos recursos referenciados
if (data.pagadorId) {
const isValid = await validatePagadorOwnership(user.id, data.pagadorId);
if (!isValid) {
return { success: false, error: "Pagador não encontrado ou sem permissão." };
}
}
if (data.secondaryPagadorId) {
const isValid = await validatePagadorOwnership(user.id, data.secondaryPagadorId);
if (!isValid) {
return { success: false, error: "Pagador secundário não encontrado ou sem permissão." };
}
}
if (data.categoriaId) {
const isValid = await validateCategoriaOwnership(user.id, data.categoriaId);
if (!isValid) {
return { success: false, error: "Categoria não encontrada." };
}
}
if (data.contaId) {
const isValid = await validateContaOwnership(user.id, data.contaId);
if (!isValid) {
return { success: false, error: "Conta não encontrada." };
}
}
if (data.cartaoId) {
const isValid = await validateCartaoOwnership(user.id, data.cartaoId);
if (!isValid) {
return { success: false, error: "Cartão não encontrado." };
}
}
const period = resolvePeriod(data.purchaseDate, data.period);
const purchaseDate = parseLocalDateString(data.purchaseDate);
const dueDate = data.dueDate ? parseLocalDateString(data.dueDate) : null;
@@ -556,6 +664,42 @@ export async function updateLancamentoAction(
const user = await getUser();
const data = updateSchema.parse(input);
// Validar propriedade dos recursos referenciados
if (data.pagadorId) {
const isValid = await validatePagadorOwnership(user.id, data.pagadorId);
if (!isValid) {
return { success: false, error: "Pagador não encontrado ou sem permissão." };
}
}
if (data.secondaryPagadorId) {
const isValid = await validatePagadorOwnership(user.id, data.secondaryPagadorId);
if (!isValid) {
return { success: false, error: "Pagador secundário não encontrado ou sem permissão." };
}
}
if (data.categoriaId) {
const isValid = await validateCategoriaOwnership(user.id, data.categoriaId);
if (!isValid) {
return { success: false, error: "Categoria não encontrada." };
}
}
if (data.contaId) {
const isValid = await validateContaOwnership(user.id, data.contaId);
if (!isValid) {
return { success: false, error: "Conta não encontrada." };
}
}
if (data.cartaoId) {
const isValid = await validateCartaoOwnership(user.id, data.cartaoId);
if (!isValid) {
return { success: false, error: "Cartão não encontrado." };
}
}
const existing = await db.query.lancamentos.findFirst({
columns: {
id: true,
@@ -1124,12 +1268,12 @@ const massAddTransactionSchema = z.object({
.number({ message: "Informe o valor da transação." })
.min(0, "Informe um valor maior ou igual a zero."),
categoriaId: uuidSchema("Categoria").nullable().optional(),
pagadorId: uuidSchema("Pagador").nullable().optional(),
});
const massAddSchema = z.object({
fixedFields: z.object({
transactionType: z.enum(LANCAMENTO_TRANSACTION_TYPES).optional(),
pagadorId: uuidSchema("Pagador").nullable().optional(),
paymentMethod: z.enum(LANCAMENTO_PAYMENT_METHODS).optional(),
condition: z.enum(LANCAMENTO_CONDITIONS).optional(),
period: z
@@ -1156,6 +1300,46 @@ export async function createMassLancamentosAction(
const user = await getUser();
const data = massAddSchema.parse(input);
// Validar campos fixos
if (data.fixedFields.contaId) {
const isValid = await validateContaOwnership(user.id, data.fixedFields.contaId);
if (!isValid) {
return { success: false, error: "Conta não encontrada." };
}
}
if (data.fixedFields.cartaoId) {
const isValid = await validateCartaoOwnership(user.id, data.fixedFields.cartaoId);
if (!isValid) {
return { success: false, error: "Cartão não encontrado." };
}
}
// Validar cada transação individual
for (let i = 0; i < data.transactions.length; i++) {
const transaction = data.transactions[i];
if (transaction.pagadorId) {
const isValid = await validatePagadorOwnership(user.id, transaction.pagadorId);
if (!isValid) {
return {
success: false,
error: `Pagador não encontrado na transação ${i + 1}.`
};
}
}
if (transaction.categoriaId) {
const isValid = await validateCategoriaOwnership(user.id, transaction.categoriaId);
if (!isValid) {
return {
success: false,
error: `Categoria não encontrada na transação ${i + 1}.`
};
}
}
}
// Default values for non-fixed fields
const defaultTransactionType = LANCAMENTO_TRANSACTION_TYPES[0];
const defaultCondition = LANCAMENTO_CONDITIONS[0];
@@ -1181,7 +1365,7 @@ export async function createMassLancamentosAction(
const condition = data.fixedFields.condition ?? defaultCondition;
const paymentMethod =
data.fixedFields.paymentMethod ?? defaultPaymentMethod;
const pagadorId = data.fixedFields.pagadorId ?? null;
const pagadorId = transaction.pagadorId ?? null;
const contaId =
paymentMethod === "Cartão de crédito"
? null

View File

@@ -159,11 +159,19 @@ export default async function Page() {
<div className="container">
<div className="mx-auto max-w-6xl">
<Image
src="/dashboard-preview.png"
src="/dashboard-preview-light.png"
alt="opensheets Dashboard Preview"
width={1920}
height={1080}
className="w-full h-auto"
className="w-full h-auto dark:hidden"
priority
/>
<Image
src="/dashboard-preview-dark.png"
alt="opensheets Dashboard Preview"
width={1920}
height={1080}
className="w-full h-auto hidden dark:block"
priority
/>
</div>

View File

@@ -90,11 +90,11 @@
.dark {
/* Base surfaces - true dark with minimal saturation */
--background: oklch(18.674% 0.00002 271.152);
--foreground: oklch(85.505% 0.02038 100.68);
--foreground: oklch(92.189% 0.0186 103.516);
--card: oklch(24.039% 0.00151 16.27);
--card-foreground: oklch(85.505% 0.02038 100.68);
--card-foreground: oklch(92.189% 0.0186 103.516);
--popover: oklch(24.039% 0.00151 16.27);
--popover-foreground: oklch(85.196% 0.0204 100.682);
--popover-foreground: oklch(92.189% 0.0186 103.516);
/* Primary - vibrant terracotta stands out on dark */
--primary: oklch(69.18% 0.18855 38.353);
@@ -102,7 +102,7 @@
/* Secondary - elevated surface */
--secondary: oklch(22% 0.004 285);
--secondary-foreground: oklch(85.196% 0.0204 100.682);
--secondary-foreground: oklch(92.189% 0.0186 103.516);
/* Muted - subtle surface variant */
--muted: oklch(33.674% 0.00531 91.552);
@@ -110,7 +110,7 @@
/* Accent - subtle highlight */
--accent: oklch(26.893% 0.00391 84.539);
--accent-foreground: oklch(85.196% 0.0204 100.682);
--accent-foreground: oklch(92.189% 0.0186 103.516);
/* Destructive - accessible red for dark */
--destructive: oklch(62% 0.2 28);
@@ -131,11 +131,11 @@
/* Sidebar - slight separation from main */
--sidebar: oklch(24.039% 0.00151 16.27);
--sidebar-foreground: oklch(85.196% 0.0204 100.682);
--sidebar-foreground: oklch(92.189% 0.0186 103.516);
--sidebar-primary: oklch(69.18% 0.18855 38.353);
--sidebar-primary-foreground: oklch(12.897% 0.00619 87.19);
--sidebar-accent: oklch(32.242% 0.00447 67.486);
--sidebar-accent-foreground: oklch(85.196% 0.0204 100.682);
--sidebar-accent-foreground: oklch(92.189% 0.0186 103.516);
--sidebar-border: oklch(26% 0.004 285);
--sidebar-ring: oklch(69.18% 0.18855 38.353);
@@ -162,11 +162,11 @@
/* Special components */
--month-picker: oklch(24.039% 0.00151 16.27);
--month-picker-foreground: oklch(85.196% 0.0204 100.682);
--dark: oklch(85.196% 0.0204 100.682);
--month-picker-foreground: oklch(92.189% 0.0186 103.516);
--dark: oklch(92.189% 0.0186 103.516);
--dark-foreground: oklch(18.711% 0.00427 84.566);
--welcome-banner: oklch(24.039% 0.00151 16.27);
--welcome-banner-foreground: oklch(85.196% 0.0204 100.682);
--welcome-banner-foreground: oklch(92.189% 0.0186 103.516);
}
@theme inline {

View File

@@ -1,10 +1,10 @@
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { main_font } from "@/public/fonts/font_index";
import type { Metadata } from "next";
import "./globals.css";
import { Analytics } from "@vercel/analytics/next";
import { SpeedInsights } from "@vercel/speed-insights/next";
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Opensheets",
@@ -22,7 +22,7 @@ export default function RootLayout({
<meta name="apple-mobile-web-app-title" content="Opensheets" />
</head>
<body
className={`${main_font.className} antialiased`}
className={`${main_font.className} antialiased `}
suppressHydrationWarning
>
<ThemeProvider attribute="class" defaultTheme="light">

View File

@@ -1,9 +1,8 @@
"use client";
import { cn } from "@/lib/utils/ui";
import type { CalendarEvent } from "@/components/calendario/types";
import { EVENT_TYPE_STYLES } from "@/components/calendario/day-cell";
import type { CalendarEvent } from "@/components/calendario/types";
import { cn } from "@/lib/utils/ui";
const LEGEND_ITEMS: Array<{
type: CalendarEvent["type"];
@@ -16,17 +15,12 @@ const LEGEND_ITEMS: Array<{
export function CalendarLegend() {
return (
<div className="flex flex-wrap gap-3 rounded-lg border border-border/60 bg-muted/20 px-3 py-2 text-[11px] font-medium text-muted-foreground">
<div className="flex flex-wrap gap-3 rounded-sm border border-border/60 bg-muted/20 p-2 text-xs font-medium text-muted-foreground">
{LEGEND_ITEMS.map((item) => {
const style = EVENT_TYPE_STYLES[item.type];
return (
<span key={item.type} className="flex items-center gap-2">
<span
className={cn(
"size-2.5 rounded-full border border-black/10 dark:border-white/20",
style.dot
)}
/>
<span className={cn("size-3 rounded-full", style.dot)} />
{item.label}
</span>
);

View File

@@ -1,10 +1,10 @@
"use client";
import type { CalendarDay, CalendarEvent } from "@/components/calendario/types";
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
import { cn } from "@/lib/utils/ui";
import { RiAddLine } from "@remixicon/react";
import type { KeyboardEvent, MouseEvent } from "react";
import type { CalendarDay, CalendarEvent } from "@/components/calendario/types";
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
type DayCellProps = {
day: CalendarDay;
@@ -17,17 +17,18 @@ export const EVENT_TYPE_STYLES: Record<
{ wrapper: string; dot: string; accent?: string }
> = {
lancamento: {
wrapper: "bg-cyan-600 text-cyan-50 dark:bg-cyan-500/30 dark:text-cyan-200",
dot: "bg-cyan-600",
wrapper:
"bg-orange-100 text-orange-600 dark:bg-orange-800 dark:text-orange-50",
dot: "bg-orange-600",
},
boleto: {
wrapper: "bg-red-600 text-red-50 dark:bg-red-500/30 dark:text-red-200",
dot: "bg-red-600",
wrapper:
"bg-emerald-100 text-emerald-600 dark:bg-emerald-800 dark:text-emerald-50",
dot: "bg-emerald-600",
},
cartao: {
wrapper:
"bg-violet-600 text-violet-50 dark:bg-violet-500/30 dark:text-violet-200",
dot: "bg-violet-600",
wrapper: "bg-blue-100 text-blue-600 dark:bg-blue-800 dark:text-blue-50",
dot: "bg-blue-600",
},
};
@@ -82,11 +83,12 @@ const DayEventPreview = ({ event }: { event: CalendarEvent }) => {
return (
<div
className={cn(
"flex w-full items-center justify-between gap-2 rounded p-1 text-xs leading-tight",
"flex w-full items-center justify-between gap-2 rounded p-1 text-xs",
style.wrapper
)}
>
<div className="flex min-w-0">
<div className="flex min-w-0 items-center gap-1">
<span className={cn("size-1.5 rounded-full", style.dot)} />
<span className="truncate">{label}</span>
</div>
{complement ? (

View File

@@ -87,7 +87,7 @@ export function CategoryDetailHeader({
<Card className="px-4">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-start gap-3">
<span className="flex size-12 items-center justify-center rounded-xl bg-muted border text-primary">
<span className="flex size-12 items-center justify-center rounded-xl bg-muted">
{IconComponent ? (
<IconComponent className="size-6" aria-hidden />
) : (

View File

@@ -19,6 +19,7 @@ import type { ChangelogEntry } from "@/lib/changelog/data";
import {
getCategoryLabel,
groupEntriesByCategory,
parseSafariCompatibleDate,
} from "@/lib/changelog/utils";
import { cn } from "@/lib/utils";
import { RiMegaphoneLine } from "@remixicon/react";
@@ -115,7 +116,7 @@ export function ChangelogNotification({
{entry.title}
</p>
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(entry.date), {
{formatDistanceToNow(parseSafariCompatibleDate(entry.date), {
addSuffix: true,
locale: ptBR,
})}

View File

@@ -205,7 +205,7 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
<CardContent className="space-y-2.5">
<div className="space-y-2">
{selectedCategoryDetails.length > 0 && (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start justify-between gap-4 mb-4">
<div className="flex flex-wrap gap-2">
{selectedCategoryDetails.map((category) => {
if (!category) return null;

View File

@@ -130,7 +130,7 @@ export function InstallmentAnalysisPage({
return (
<div className="flex flex-col gap-4">
{/* Card de resumo principal */}
<Card className="border-primary/20 bg-primary/5">
<Card className="border-none bg-primary/15">
<CardContent className="flex flex-col items-start justify-center gap-2 py-2">
<p className="text-sm font-medium text-muted-foreground">
Se você pagar tudo que está selecionado:

View File

@@ -7,6 +7,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import type { DashboardCardMetrics } from "@/lib/dashboard/metrics";
import { title_font } from "@/public/fonts/font_index";
import {
RiArrowDownLine,
RiArrowUpLine,
@@ -15,7 +16,6 @@ import {
RiSubtractLine,
} from "@remixicon/react";
import MoneyValues from "../money-values";
import { title_font } from "@/public/fonts/font_index";
type SectionCardsProps = {
metrics: DashboardCardMetrics;
@@ -73,7 +73,7 @@ export function SectionCards({ metrics }: SectionCardsProps) {
<Card key={label} className="@container/card gap-2">
<CardHeader>
<CardTitle className="flex items-center gap-1">
<Icon className="size-4" />
<Icon className="size-4 text-primary" />
{label}
</CardTitle>
<MoneyValues className="text-2xl" amount={metric.current} />
@@ -84,9 +84,9 @@ export function SectionCards({ metrics }: SectionCardsProps) {
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 text-sm">
Mês anterior:
<CardFooter className="flex-col items-start gap-2 text-sm">
<div className="line-clamp-1 flex gap-2 text-xs">
Mês anterior
</div>
<div className="text-muted-foreground">
<MoneyValues amount={metric.previous} />

View File

@@ -1,25 +1,30 @@
"use client";
import {
deleteSavedInsightsAction,
generateInsightsAction,
loadSavedInsightsAction,
saveInsightsAction,
deleteSavedInsightsAction,
} from "@/app/(dashboard)/insights/actions";
import { DEFAULT_MODEL } from "@/app/(dashboard)/insights/data";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import type { InsightsResponse } from "@/lib/schemas/insights";
import { RiDeleteBinLine, RiSaveLine, RiSparklingLine, RiAlertLine } from "@remixicon/react";
import {
RiAlertLine,
RiDeleteBinLine,
RiSaveLine,
RiSparklingLine,
} from "@remixicon/react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner";
import { EmptyState } from "../empty-state";
import { InsightsGrid } from "./insights-grid";
import { ModelSelector } from "./model-selector";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
interface InsightsPageProps {
period: string;
@@ -129,11 +134,14 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
return (
<div className="flex flex-col gap-6">
{/* Privacy Warning */}
<Alert>
<Alert className="border-none">
<RiAlertLine className="size-4" />
<AlertDescription className="text-sm">
<strong>Aviso de privacidade:</strong> Ao gerar insights, seus dados financeiros serão enviados para o provedor de IA selecionado
(Anthropic, OpenAI, Google ou OpenRouter) para processamento. Certifique-se de que você confia no provedor escolhido antes de prosseguir.
<strong>Aviso de privacidade:</strong> Ao gerar insights, seus dados
financeiros serão enviados para o provedor de IA selecionado
(Anthropic, OpenAI, Google ou OpenRouter) para processamento.
Certifique-se de que você confia no provedor escolhido antes de
prosseguir.
</AlertDescription>
</Alert>

View File

@@ -30,7 +30,6 @@ import { createMonthOptions } from "@/lib/utils/period";
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { EstabelecimentoInput } from "../shared/estabelecimento-input";
import type { SelectOption } from "../../types";
import {
CategoriaSelectContent,
@@ -40,6 +39,7 @@ import {
PaymentMethodSelectContent,
TransactionTypeSelectContent,
} from "../select-items";
import { EstabelecimentoInput } from "../shared/estabelecimento-input";
interface MassAddDialogProps {
open: boolean;
@@ -57,7 +57,6 @@ interface MassAddDialogProps {
export interface MassAddFormData {
fixedFields: {
transactionType?: string;
pagadorId?: string;
paymentMethod?: string;
condition?: string;
period?: string;
@@ -69,6 +68,7 @@ export interface MassAddFormData {
name: string;
amount: string;
categoriaId?: string;
pagadorId?: string;
}>;
}
@@ -78,6 +78,7 @@ interface TransactionRow {
name: string;
amount: string;
categoriaId: string | undefined;
pagadorId: string | undefined;
}
export function MassAddDialog({
@@ -96,9 +97,6 @@ export function MassAddDialog({
// Fixed fields state (sempre ativos, sem checkboxes)
const [transactionType, setTransactionType] = useState<string>("Despesa");
const [pagadorId, setPagadorId] = useState<string | undefined>(
defaultPagadorId ?? undefined
);
const [paymentMethod, setPaymentMethod] = useState<string>(
LANCAMENTO_PAYMENT_METHODS[0]
);
@@ -115,6 +113,7 @@ export function MassAddDialog({
name: "",
amount: "",
categoriaId: undefined,
pagadorId: defaultPagadorId ?? undefined,
},
]);
@@ -141,6 +140,7 @@ export function MassAddDialog({
name: "",
amount: "",
categoriaId: undefined,
pagadorId: defaultPagadorId ?? undefined,
},
]);
};
@@ -191,7 +191,6 @@ export function MassAddDialog({
const formData: MassAddFormData = {
fixedFields: {
transactionType,
pagadorId,
paymentMethod,
condition,
period,
@@ -203,6 +202,7 @@ export function MassAddDialog({
name: t.name.trim(),
amount: t.amount.trim(),
categoriaId: t.categoriaId,
pagadorId: t.pagadorId,
})),
};
@@ -212,7 +212,6 @@ export function MassAddDialog({
onOpenChange(false);
// Reset form
setTransactionType("Despesa");
setPagadorId(defaultPagadorId ?? undefined);
setPaymentMethod(LANCAMENTO_PAYMENT_METHODS[0]);
setCondition("À vista");
setPeriod(selectedPeriod);
@@ -225,6 +224,7 @@ export function MassAddDialog({
name: "",
amount: "",
categoriaId: undefined,
pagadorId: defaultPagadorId ?? undefined,
},
]);
} catch (_error) {
@@ -236,7 +236,7 @@ export function MassAddDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto p-6 sm:px-8">
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto p-6 sm:px-8">
<DialogHeader>
<DialogTitle>Adicionar múltiplos lançamentos</DialogTitle>
<DialogDescription>
@@ -248,7 +248,7 @@ export function MassAddDialog({
{/* Fixed Fields Section */}
<div className="space-y-4">
<h3 className="text-sm font-semibold">Valores Padrão</h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
{/* Transaction Type */}
<div className="space-y-2">
<Label htmlFor="transaction-type">Tipo de Transação</Label>
@@ -274,39 +274,6 @@ export function MassAddDialog({
</Select>
</div>
{/* Pagador */}
<div className="space-y-2">
<Label htmlFor="pagador">Pagador</Label>
<Select value={pagadorId} onValueChange={setPagadorId}>
<SelectTrigger id="pagador" className="w-full">
<SelectValue placeholder="Selecione o pagador">
{pagadorId &&
(() => {
const selectedOption = pagadorOptions.find(
(opt) => opt.value === pagadorId
);
return selectedOption ? (
<PagadorSelectContent
label={selectedOption.label}
avatarUrl={selectedOption.avatarUrl}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{pagadorOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<PagadorSelectContent
label={option.label}
avatarUrl={option.avatarUrl}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Payment Method */}
<div className="space-y-2">
<Label htmlFor="payment-method">Forma de Pagamento</Label>
@@ -489,6 +456,7 @@ export function MassAddDialog({
)
}
placeholder="Data"
className="w-32 truncate"
required
/>
</div>
@@ -528,6 +496,52 @@ export function MassAddDialog({
required
/>
</div>
<div className="w-full">
<Label
htmlFor={`pagador-${transaction.id}`}
className="sr-only"
>
Pagador {index + 1}
</Label>
<Select
value={transaction.pagadorId}
onValueChange={(value) =>
updateTransaction(transaction.id, "pagadorId", value)
}
>
<SelectTrigger
id={`pagador-${transaction.id}`}
className="w-32 truncate"
>
<SelectValue placeholder="Pagador">
{transaction.pagadorId &&
(() => {
const selectedOption = pagadorOptions.find(
(opt) => opt.value === transaction.pagadorId
);
return selectedOption ? (
<PagadorSelectContent
label={selectedOption.label}
avatarUrl={selectedOption.avatarUrl}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{pagadorOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<PagadorSelectContent
label={option.label}
avatarUrl={option.avatarUrl}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-full">
<Label
htmlFor={`categoria-${transaction.id}`}
@@ -547,7 +561,7 @@ export function MassAddDialog({
>
<SelectTrigger
id={`categoria-${transaction.id}`}
className="w-42 truncate"
className="w-32 truncate"
>
<SelectValue placeholder="Categoria" />
</SelectTrigger>

View File

@@ -712,17 +712,24 @@ export function LancamentosTable({
</Button>
) : null}
{onMassAdd ? (
<Button
onClick={onMassAdd}
variant="outline"
size="icon"
className="shrink-0"
>
<RiAddCircleFill className="size-4" />
<span className="sr-only">
Adicionar múltiplos lançamentos
</span>
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onMassAdd}
variant="outline"
size="icon"
className="shrink-0"
>
<RiAddCircleFill className="size-4" />
<span className="sr-only">
Adicionar múltiplos lançamentos
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Adicionar múltiplos lançamentos</p>
</TooltipContent>
</Tooltip>
) : null}
</div>
) : (

View File

@@ -21,7 +21,7 @@ export function Logo({ variant = "full", className }: LogoProps) {
}
return (
<div className={cn("flex items-center", className)}>
<div className={cn("flex items-center py-4", className)}>
<Image
src="/logo_small.png"
alt="Opensheets"

View File

@@ -22,7 +22,7 @@ function MoneyValues({ amount, className }: Props) {
<span
className={cn(
money_font.className,
"inline-flex items-baseline font-medium transition-all duration-200",
"inline-flex items-baseline transition-all duration-200 tracking-tighter",
privacyMode &&
"blur-[6px] select-none hover:blur-none focus-within:blur-none",
className

View File

@@ -89,7 +89,7 @@ export default function MonthPicker() {
<div className="flex items-center">
<div
className="mx-1 space-x-1 capitalize font-medium"
className="mx-1 space-x-1 capitalize font-bold"
aria-current={!isDifferentFromCurrent ? "date" : undefined}
aria-label={`Período selecionado: ${currentMonthLabel} de ${currentYear}`}
>

View File

@@ -51,7 +51,7 @@ export function AppSidebar({
<SidebarMenuItem>
<SidebarMenuButton
asChild
className="data-[slot=sidebar-menu-button]:px-1.5! hover:bg-transparent"
className="data-[slot=sidebar-menu-button]:px-1.5! hover:bg-transparent active:bg-transparent pt-4 justify-center hover:scale-105 transition-all duration-200"
>
<a href="/dashboard">
<LogoContent />

View File

@@ -48,6 +48,14 @@ type NavSection = {
items: NavItem[];
};
const MONTH_PERIOD_PARAM = "periodo";
const PERIOD_AWARE_PATHS = new Set([
"/dashboard",
"/lancamentos",
"/orcamentos",
]);
export function NavMain({ sections }: { sections: NavSection[] }) {
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -167,7 +175,7 @@ export function NavMain({ sections }: { sections: NavSection[] }) {
src={avatarSrc}
alt={`Avatar de ${subItem.title}`}
/>
<AvatarFallback className="text-[10px] font-medium uppercase">
<AvatarFallback className="text-xs font-medium uppercase">
{initial}
</AvatarFallback>
</Avatar>
@@ -196,11 +204,3 @@ export function NavMain({ sections }: { sections: NavSection[] }) {
</>
);
}
const MONTH_PERIOD_PARAM = "periodo";
const PERIOD_AWARE_PATHS = new Set([
"/dashboard",
"/lancamentos",
"/orcamentos",
]);

View File

@@ -12,10 +12,10 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { title_font } from "@/public/fonts/font_index";
import { RiExpandDiagonalLine } from "@remixicon/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "./ui/button";
import { title_font } from "@/public/fonts/font_index";
const OVERFLOW_THRESHOLD_PX = 16;
const OVERFLOW_CHECK_DEBOUNCE_MS = 100;
@@ -82,7 +82,7 @@ export default function WidgetCard({
<CardTitle
className={`${title_font.className} flex items-center gap-1`}
>
{icon}
<span className="text-primary">{icon}</span>
{title}
</CardTitle>
<CardDescription className="text-muted-foreground text-sm capitalize mt-1">

View File

@@ -49,6 +49,14 @@ function getNameFromGoogleProfile(profile: GoogleProfile): string {
// ============================================================================
export const auth = betterAuth({
// Base URL configuration
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
// Trust host configuration for production environments
trustedOrigins: process.env.BETTER_AUTH_URL
? [process.env.BETTER_AUTH_URL]
: [],
// Email/Password authentication
emailAndPassword: {
enabled: true,
@@ -62,6 +70,26 @@ export const auth = betterAuth({
camelCase: true,
}),
// Session configuration - Safari compatibility
session: {
cookieCache: {
enabled: true,
maxAge: 60 * 5, // 5 minutes
},
},
// Advanced configuration for Safari compatibility
advanced: {
cookieOptions: {
sameSite: "lax", // Safari compatible
secure: process.env.NODE_ENV === "production", // HTTPS in production only
httpOnly: true,
},
crossSubDomainCookies: {
enabled: false, // Disable for better Safari compatibility
},
},
// Google OAuth (se configurado)
socialProviders:
googleClientId && googleClientSecret

View File

@@ -1,5 +1,19 @@
import type { ChangelogEntry } from "./data";
/**
* Converte uma string de data para um formato compatível com Safari.
* Safari não aceita "YYYY-MM-DD HH:mm:ss ±HHMM", requer "YYYY-MM-DDTHH:mm:ss±HHMM"
*
* @param dateString - String de data no formato "YYYY-MM-DD HH:mm:ss ±HHMM"
* @returns Date object válido
*/
export function parseSafariCompatibleDate(dateString: string): Date {
// Substitui o espaço entre data e hora por "T" (formato ISO 8601)
// Exemplo: "2025-12-09 17:26:08 +0000" → "2025-12-09T17:26:08+0000"
const isoString = dateString.replace(/(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s+/, "$1T$2");
return new Date(isoString);
}
export function getCategoryLabel(category: string): string {
const labels: Record<string, string> = {
feature: "Novidades",

View File

@@ -19,6 +19,28 @@ const nextConfig: NextConfig = {
devIndicators: {
position: "bottom-right",
},
// Headers for Safari compatibility
async headers() {
return [
{
source: "/:path*",
headers: [
{
key: "X-DNS-Prefetch-Control",
value: "on",
},
{
key: "Strict-Transport-Security",
value: "max-age=31536000; includeSubDomains",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
],
},
];
},
};
export default nextConfig;

View File

@@ -28,10 +28,10 @@
"docker:rebuild": "docker compose up --build --force-recreate"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.54",
"@ai-sdk/google": "^2.0.45",
"@ai-sdk/openai": "^2.0.80",
"@openrouter/ai-sdk-provider": "^1.5.2",
"@ai-sdk/anthropic": "^2.0.56",
"@ai-sdk/google": "^2.0.46",
"@ai-sdk/openai": "^2.0.86",
"@openrouter/ai-sdk-provider": "^1.5.3",
"@radix-ui/react-alert-dialog": "1.1.15",
"@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3",
@@ -56,42 +56,42 @@
"@tanstack/react-table": "8.21.3",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.3.1",
"ai": "^5.0.108",
"ai": "^5.0.113",
"babel-plugin-react-compiler": "^1.0.0",
"better-auth": "1.4.6",
"better-auth": "1.4.7",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "0.45.1",
"motion": "^12.23.26",
"next": "16.0.8",
"next": "16.0.10",
"next-themes": "0.4.6",
"pg": "8.16.3",
"react": "19.2.1",
"react": "19.2.3",
"react-day-picker": "^9.12.0",
"react-dom": "19.2.1",
"recharts": "3.5.1",
"react-dom": "19.2.3",
"recharts": "3.6.0",
"resend": "^6.6.0",
"sonner": "2.0.7",
"tailwind-merge": "3.4.0",
"vaul": "1.1.2",
"zod": "4.1.13"
"zod": "4.2.0"
},
"devDependencies": {
"@tailwindcss/postcss": "4.1.17",
"@tailwindcss/postcss": "4.1.18",
"@types/d3-array": "^3.2.2",
"@types/node": "24.10.2",
"@types/pg": "^8.15.6",
"@types/node": "25.0.2",
"@types/pg": "^8.16.0",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"baseline-browser-mapping": "^2.9.6",
"baseline-browser-mapping": "^2.9.7",
"depcheck": "^1.4.7",
"dotenv": "^17.2.3",
"drizzle-kit": "0.31.8",
"eslint": "9.39.1",
"eslint-config-next": "16.0.8",
"tailwindcss": "4.1.17",
"eslint": "9.39.2",
"eslint-config-next": "16.0.10",
"tailwindcss": "4.1.18",
"tsx": "4.21.0",
"typescript": "5.9.3"
}

1478
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,31 @@
{
"version": "1.0.0",
"generatedAt": "2025-12-10T16:45:04.592Z",
"generatedAt": "2025-12-13T13:52:07.921Z",
"entries": [
{
"id": "0767636eed5085a211e08de52577beed658f05cf",
"type": "feat",
"title": "ajustar layout e estilos",
"date": "2025-12-11 17:43:33 +0000",
"icon": "✨",
"category": "feature"
},
{
"id": "0744991edd717748fce24e148907eafd7222c64e",
"type": "chore",
"title": "remove unused code and clean up imports",
"date": "2025-12-10 16:53:19 +0000",
"icon": "🔧",
"category": "chore"
},
{
"id": "b767bd959955854e54b41270e9c220fe0b546947",
"type": "feat",
"title": "adicionar widgets de despesas e receitas com gráfico - Adiciona o widget de despesas por categoria com gráfico. - Adiciona o widget de receitas por categoria com gráfico. - Atualiza a configuração dos widgets para incluir novos componentes. - Ajusta estilos e tamanhos de elementos nos widgets existentes.",
"date": "2025-12-10 16:51:45 +0000",
"icon": "✨",
"category": "feature"
},
{
"id": "89765d4373b820a3e7c8e4fa40479dd2673558b0",
"type": "chore",
@@ -137,30 +161,6 @@
"date": "2025-11-23 12:26:05 -0300",
"icon": "✨",
"category": "feature"
},
{
"id": "3ce8541a5699317c747c629e1c0e07d579458633",
"type": "fix",
"title": "corrige a grafia de \"OpenSheets\" para \"Opensheets\"",
"date": "2025-11-22 20:29:25 -0300",
"icon": "🐛",
"category": "bugfix"
},
{
"id": "ac24961e4b97bfb58a52e1b95f3d9696fe1e5d86",
"type": "refactor",
"title": "substitui '•' por '-' em textos de exibição",
"date": "2025-11-22 12:58:57 -0300",
"icon": "♻️",
"category": "refactor"
},
{
"id": "8c5313119dafaf3a33ab4bffeeb40d7f0278eb08",
"type": "feat",
"title": "atualiza fontes e altera avatar SVG",
"date": "2025-11-22 12:49:56 -0300",
"icon": "✨",
"category": "feature"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1021 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 869 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -1,17 +1,23 @@
import { Barlow, Inter } from "next/font/google";
import localFont from "next/font/local";
const inter = Inter({
subsets: ["latin"],
weight: ["500", "600", "700"],
const laranjinha = localFont({
src: [
{
path: "./LaranjinhaTextPro_Rg.woff2",
weight: "400",
style: "normal",
},
{
path: "./LaranjinhaDisplayPro_Bd.woff2",
weight: "700",
style: "normal",
},
],
display: "swap",
});
const barlow = Barlow({
subsets: ["latin"],
weight: "500",
});
const main_font = inter;
const money_font = barlow;
const title_font = inter;
const main_font = laranjinha;
const money_font = laranjinha;
const title_font = laranjinha;
export { main_font, money_font, title_font };