diff --git a/.env.example b/.env.example index a97e03d..39627b7 100644 --- a/.env.example +++ b/.env.example @@ -25,7 +25,7 @@ DB_PORT=5432 # === Email (Opcional) === # Provider: Resend (https://resend.com) RESEND_API_KEY= -RESEND_FROM_EMAIL=OpenMonetis +RESEND_FROM_EMAIL="OpenMonetis " # === OAuth (Opcional) === # Google: https://console.cloud.google.com/apis/credentials diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ffd5e4..be1368c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,50 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo. O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/), e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/). +## [1.6.3] - 2026-02-19 + +### Corrigido + +- E-mail Resend: variável `RESEND_FROM_EMAIL` não era lida do `.env` (valores com espaço precisam estar entre aspas). Leitura centralizada em `lib/email/resend.ts` com `getResendFromEmail()` e carregamento explícito do `.env` no contexto de Server Actions + +### Alterado + +- `.env.example`: `RESEND_FROM_EMAIL` com valor entre aspas e comentário para uso em Docker/produção +- `docker-compose.yml`: env do app passa `RESEND_FROM_EMAIL` (em vez de `EMAIL_FROM`) para o container, alinhado ao nome usado pela aplicação + +## [1.6.2] - 2026-02-19 + +### Corrigido + +- Bug no mobile onde, ao selecionar um logo no diálogo de criação de conta/cartão, o diálogo principal fechava inesperadamente: adicionado `stopPropagation` nos eventos de click/touch dos botões de logo e delay com `requestAnimationFrame` antes de fechar o seletor de logo + +## [1.6.1] - 2026-02-18 + +### Alterado + +- Transferências entre contas: nome do estabelecimento passa a ser "Saída - Transf. entre contas" na saída e "Entrada - Transf. entre contas" na entrada e adicionando em anotação no formato "de {conta origem} -> {conta destino}" +- ChartContainer (Recharts): renderização do gráfico apenas após montagem no cliente e uso de `minWidth`/`minHeight` no ResponsiveContainer para evitar aviso "width(-1) and height(-1)" no console + +## [1.6.0] - 2026-02-18 + +### Adicionado + +- Preferência "Anotações em coluna" em Ajustes > Extrato e lançamentos: quando ativa, a anotação dos lançamentos aparece em coluna na tabela; quando inativa, permanece no balão (tooltip) no ícone +- Preferência "Ordem das colunas" em Ajustes > Extrato e lançamentos: lista ordenável por arraste para definir a ordem das colunas na tabela do extrato e dos lançamentos (Estabelecimento, Transação, Valor, etc.); a linha inteira é arrastável +- Coluna `extrato_note_as_column` e `lancamentos_column_order` na tabela `preferencias_usuario` (migrations 0017 e 0018) +- Constantes e labels das colunas reordenáveis em `lib/lancamentos/column-order.ts` + +### Alterado + +- Header do dashboard fixo apenas no mobile (`fixed top-0` com `md:static`); conteúdo com `pt-12 md:pt-0` para não ficar sob o header +- Abas da página Ajustes (Preferências, Companion, etc.): no mobile, rolagem horizontal com seta indicando mais opções à direita; scrollbar oculta +- Botões "Novo orçamento" e "Copiar orçamentos do último mês": no mobile, rolagem horizontal (`h-8`, `text-xs`) +- Botões "Nova Receita", "Nova Despesa" e ícone de múltiplos lançamentos: no mobile, mesma rolagem horizontal + botões menores +- Tabela de lançamentos aplica a ordem de colunas salva nas preferências (extrato, lançamentos, categoria, fatura, pagador) +- Adicionado variavel no docker compose para manter o caminho do volume no compose up/down + +**Contribuições:** [Guilherme Bano](https://github.com/Gbano1) + ## [1.5.3] - 2026-02-21 ### Adicionado @@ -222,3 +266,4 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR - Atualização de dependências - Aplicada formatação no código + diff --git a/app/(dashboard)/ajustes/actions.ts b/app/(dashboard)/ajustes/actions.ts index 73760db..0776328 100644 --- a/app/(dashboard)/ajustes/actions.ts +++ b/app/(dashboard)/ajustes/actions.ts @@ -70,6 +70,8 @@ const VALID_FONTS = [ const updatePreferencesSchema = z.object({ disableMagnetlines: z.boolean(), + extratoNoteAsColumn: z.boolean(), + lancamentosColumnOrder: z.array(z.string()).nullable(), systemFont: z.enum(VALID_FONTS).default("ai-sans"), moneyFont: z.enum(VALID_FONTS).default("ai-sans"), }); @@ -417,6 +419,8 @@ export async function updatePreferencesAction( .update(schema.preferenciasUsuario) .set({ disableMagnetlines: validated.disableMagnetlines, + extratoNoteAsColumn: validated.extratoNoteAsColumn, + lancamentosColumnOrder: validated.lancamentosColumnOrder, systemFont: validated.systemFont, moneyFont: validated.moneyFont, updatedAt: new Date(), @@ -427,6 +431,8 @@ export async function updatePreferencesAction( await db.insert(schema.preferenciasUsuario).values({ userId: session.user.id, disableMagnetlines: validated.disableMagnetlines, + extratoNoteAsColumn: validated.extratoNoteAsColumn, + lancamentosColumnOrder: validated.lancamentosColumnOrder, systemFont: validated.systemFont, moneyFont: validated.moneyFont, }); diff --git a/app/(dashboard)/ajustes/data.ts b/app/(dashboard)/ajustes/data.ts index 84ed107..3f19f56 100644 --- a/app/(dashboard)/ajustes/data.ts +++ b/app/(dashboard)/ajustes/data.ts @@ -4,6 +4,8 @@ import { db, schema } from "@/lib/db"; export interface UserPreferences { disableMagnetlines: boolean; + extratoNoteAsColumn: boolean; + lancamentosColumnOrder: string[] | null; systemFont: string; moneyFont: string; } @@ -32,6 +34,8 @@ export async function fetchUserPreferences( const result = await db .select({ disableMagnetlines: schema.preferenciasUsuario.disableMagnetlines, + extratoNoteAsColumn: schema.preferenciasUsuario.extratoNoteAsColumn, + lancamentosColumnOrder: schema.preferenciasUsuario.lancamentosColumnOrder, systemFont: schema.preferenciasUsuario.systemFont, moneyFont: schema.preferenciasUsuario.moneyFont, }) diff --git a/app/(dashboard)/ajustes/page.tsx b/app/(dashboard)/ajustes/page.tsx index 9c46a6f..376cdf2 100644 --- a/app/(dashboard)/ajustes/page.tsx +++ b/app/(dashboard)/ajustes/page.tsx @@ -1,3 +1,4 @@ +import { RiArrowRightSLine } from "@remixicon/react"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; @@ -35,17 +36,28 @@ export default async function Page() { return (
- - Preferências - Companion - Alterar nome - Alterar senha - Alterar e-mail - Changelog - - Deletar conta - - + {/* No mobile: rolagem horizontal + seta indicando mais opções à direita */} +
+
+ + Preferências + Companion + Alterar nome + Alterar senha + Alterar e-mail + Changelog + + Deletar conta + + +
+
+ +
+
@@ -61,6 +73,12 @@ export default async function Page() { disableMagnetlines={ userPreferences?.disableMagnetlines ?? false } + extratoNoteAsColumn={ + userPreferences?.extratoNoteAsColumn ?? false + } + lancamentosColumnOrder={ + userPreferences?.lancamentosColumnOrder ?? null + } systemFont={userPreferences?.systemFont ?? "ai-sans"} moneyFont={userPreferences?.moneyFont ?? "ai-sans"} /> diff --git a/app/(dashboard)/cartoes/[cartaoId]/fatura/page.tsx b/app/(dashboard)/cartoes/[cartaoId]/fatura/page.tsx index 7b5e6a3..0a7654d 100644 --- a/app/(dashboard)/cartoes/[cartaoId]/fatura/page.tsx +++ b/app/(dashboard)/cartoes/[cartaoId]/fatura/page.tsx @@ -1,5 +1,6 @@ import { RiPencilLine } from "@remixicon/react"; import { notFound } from "next/navigation"; +import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data"; import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions"; import { CardDialog } from "@/components/cartoes/card-dialog"; import type { Card } from "@/components/cartoes/types"; @@ -51,12 +52,13 @@ export default async function Page({ params, searchParams }: PageProps) { notFound(); } - const [filterSources, logoOptions, invoiceData, estabelecimentos] = + const [filterSources, logoOptions, invoiceData, estabelecimentos, userPreferences] = await Promise.all([ fetchLancamentoFilterSources(userId), loadLogoOptions(), fetchInvoiceData(userId, cartaoId, selectedPeriod), getRecentEstablishmentsAction(), + fetchUserPreferences(userId), ]); const sluggedFilters = buildSluggedFilters(filterSources); const slugMaps = buildSlugMaps(sluggedFilters); @@ -182,6 +184,8 @@ export default async function Page({ params, searchParams }: PageProps) { selectedPeriod={selectedPeriod} estabelecimentos={estabelecimentos} allowCreate + noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false} + columnOrder={userPreferences?.lancamentosColumnOrder ?? null} defaultCartaoId={card.id} defaultPaymentMethod="Cartão de crédito" lockCartaoSelection diff --git a/app/(dashboard)/categorias/[categoryId]/page.tsx b/app/(dashboard)/categorias/[categoryId]/page.tsx index e897c5d..ded00a2 100644 --- a/app/(dashboard)/categorias/[categoryId]/page.tsx +++ b/app/(dashboard)/categorias/[categoryId]/page.tsx @@ -1,4 +1,5 @@ import { notFound } from "next/navigation"; +import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data"; import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions"; import { CategoryDetailHeader } from "@/components/categorias/category-detail-header"; import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page"; @@ -36,10 +37,11 @@ export default async function Page({ params, searchParams }: PageProps) { const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); const { period: selectedPeriod } = parsePeriodParam(periodoParam); - const [detail, filterSources, estabelecimentos] = await Promise.all([ + const [detail, filterSources, estabelecimentos, userPreferences] = await Promise.all([ fetchCategoryDetails(userId, categoryId, selectedPeriod), fetchLancamentoFilterSources(userId), getRecentEstablishmentsAction(), + fetchUserPreferences(userId), ]); if (!detail) { @@ -92,6 +94,8 @@ export default async function Page({ params, searchParams }: PageProps) { selectedPeriod={detail.period} estabelecimentos={estabelecimentos} allowCreate={true} + noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false} + columnOrder={userPreferences?.lancamentosColumnOrder ?? null} /> ); diff --git a/app/(dashboard)/contas/[contaId]/extrato/page.tsx b/app/(dashboard)/contas/[contaId]/extrato/page.tsx index b0acf53..6537bdb 100644 --- a/app/(dashboard)/contas/[contaId]/extrato/page.tsx +++ b/app/(dashboard)/contas/[contaId]/extrato/page.tsx @@ -1,5 +1,6 @@ import { RiPencilLine } from "@remixicon/react"; import { notFound } from "next/navigation"; +import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data"; import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions"; import { AccountDialog } from "@/components/contas/account-dialog"; import { AccountStatementCard } from "@/components/contas/account-statement-card"; @@ -57,12 +58,13 @@ export default async function Page({ params, searchParams }: PageProps) { notFound(); } - const [filterSources, logoOptions, accountSummary, estabelecimentos] = + const [filterSources, logoOptions, accountSummary, estabelecimentos, userPreferences] = await Promise.all([ fetchLancamentoFilterSources(userId), loadLogoOptions(), fetchAccountSummary(userId, contaId, selectedPeriod), getRecentEstablishmentsAction(), + fetchUserPreferences(userId), ]); const sluggedFilters = buildSluggedFilters(filterSources); const slugMaps = buildSlugMaps(sluggedFilters); @@ -161,6 +163,8 @@ export default async function Page({ params, searchParams }: PageProps) { selectedPeriod={selectedPeriod} estabelecimentos={estabelecimentos} allowCreate={false} + noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false} + columnOrder={userPreferences?.lancamentosColumnOrder ?? null} /> diff --git a/app/(dashboard)/contas/actions.ts b/app/(dashboard)/contas/actions.ts index 58dddb6..1c623be 100644 --- a/app/(dashboard)/contas/actions.ts +++ b/app/(dashboard)/contas/actions.ts @@ -22,7 +22,8 @@ import { noteSchema, uuidSchema } from "@/lib/schemas/common"; import { TRANSFER_CATEGORY_NAME, TRANSFER_CONDITION, - TRANSFER_ESTABLISHMENT, + TRANSFER_ESTABLISHMENT_ENTRADA, + TRANSFER_ESTABLISHMENT_SAIDA, TRANSFER_PAYMENT_METHOD, } from "@/lib/transferencias/constants"; import { formatDecimalForDbRequired } from "@/lib/utils/currency"; @@ -341,12 +342,14 @@ export async function transferBetweenAccountsAction( ); } + const transferNote = `de ${fromAccount.name} -> ${toAccount.name}`; + // Create outgoing transaction (transfer from source account) await tx.insert(lancamentos).values({ condition: TRANSFER_CONDITION, - name: `${TRANSFER_ESTABLISHMENT} → ${toAccount.name}`, + name: TRANSFER_ESTABLISHMENT_SAIDA, paymentMethod: TRANSFER_PAYMENT_METHOD, - note: `Transferência para ${toAccount.name}`, + note: transferNote, amount: formatDecimalForDbRequired(-Math.abs(data.amount)), purchaseDate: data.date, transactionType: "Transferência", @@ -362,9 +365,9 @@ export async function transferBetweenAccountsAction( // Create incoming transaction (transfer to destination account) await tx.insert(lancamentos).values({ condition: TRANSFER_CONDITION, - name: `${TRANSFER_ESTABLISHMENT} ← ${fromAccount.name}`, + name: TRANSFER_ESTABLISHMENT_ENTRADA, paymentMethod: TRANSFER_PAYMENT_METHOD, - note: `Transferência de ${fromAccount.name}`, + note: transferNote, amount: formatDecimalForDbRequired(Math.abs(data.amount)), purchaseDate: data.date, transactionType: "Transferência", diff --git a/app/(dashboard)/lancamentos/page.tsx b/app/(dashboard)/lancamentos/page.tsx index 7ad5fc9..5cd3fad 100644 --- a/app/(dashboard)/lancamentos/page.tsx +++ b/app/(dashboard)/lancamentos/page.tsx @@ -1,3 +1,4 @@ +import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data"; import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page"; import MonthNavigation from "@/components/month-picker/month-navigation"; import { getUserId } from "@/lib/auth/server"; @@ -31,7 +32,10 @@ export default async function Page({ searchParams }: PageProps) { const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams); - const filterSources = await fetchLancamentoFilterSources(userId); + const [filterSources, userPreferences] = await Promise.all([ + fetchLancamentoFilterSources(userId), + fetchUserPreferences(userId), + ]); const sluggedFilters = buildSluggedFilters(filterSources); const slugMaps = buildSlugMaps(sluggedFilters); @@ -80,6 +84,8 @@ export default async function Page({ searchParams }: PageProps) { contaCartaoFilterOptions={contaCartaoFilterOptions} selectedPeriod={selectedPeriod} estabelecimentos={estabelecimentos} + noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false} + columnOrder={userPreferences?.lancamentosColumnOrder ?? null} /> ); diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 1773a8b..7df5fb6 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -70,7 +70,7 @@ export default async function DashboardLayout({ /> -
+
{children} diff --git a/app/(dashboard)/pagadores/[pagadorId]/actions.ts b/app/(dashboard)/pagadores/[pagadorId]/actions.ts index c30e862..e3871ab 100644 --- a/app/(dashboard)/pagadores/[pagadorId]/actions.ts +++ b/app/(dashboard)/pagadores/[pagadorId]/actions.ts @@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache"; import { Resend } from "resend"; import { z } from "zod"; import { lancamentos, pagadores } from "@/db/schema"; +import { getResendFromEmail } from "@/lib/email/resend"; import { getUser } from "@/lib/auth/server"; import { db } from "@/lib/db"; import { @@ -418,8 +419,7 @@ export async function sendPagadorSummaryAction( } const resendApiKey = process.env.RESEND_API_KEY; - const resendFrom = - process.env.RESEND_FROM_EMAIL ?? "OpenMonetis "; + const resendFrom = getResendFromEmail(); if (!resendApiKey) { return { diff --git a/app/(dashboard)/pagadores/[pagadorId]/page.tsx b/app/(dashboard)/pagadores/[pagadorId]/page.tsx index ddc7866..3f40f53 100644 --- a/app/(dashboard)/pagadores/[pagadorId]/page.tsx +++ b/app/(dashboard)/pagadores/[pagadorId]/page.tsx @@ -4,6 +4,7 @@ import { RiWallet3Line, } from "@remixicon/react"; import { notFound } from "next/navigation"; +import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data"; import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions"; import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page"; import type { @@ -168,6 +169,7 @@ export default async function Page({ params, searchParams }: PageProps) { shareRows, currentUserShare, estabelecimentos, + userPreferences, ] = await Promise.all([ fetchPagadorLancamentos(filters), fetchPagadorMonthlyBreakdown({ @@ -203,6 +205,7 @@ export default async function Page({ params, searchParams }: PageProps) { sharesPromise, currentUserSharePromise, getRecentEstablishmentsAction(), + fetchUserPreferences(userId), ]); const mappedLancamentos = mapLancamentosData(lancamentoRows); @@ -381,6 +384,8 @@ export default async function Page({ params, searchParams }: PageProps) { selectedPeriod={selectedPeriod} estabelecimentos={estabelecimentos} allowCreate={canEdit} + noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false} + columnOrder={userPreferences?.lancamentosColumnOrder ?? null} importPagadorOptions={loggedUserOptionSets?.pagadorOptions} importSplitPagadorOptions={ loggedUserOptionSets?.splitPagadorOptions diff --git a/components/ajustes/changelog-tab.tsx b/components/ajustes/changelog-tab.tsx index 8208c87..b81a18c 100644 --- a/components/ajustes/changelog-tab.tsx +++ b/components/ajustes/changelog-tab.tsx @@ -1,7 +1,17 @@ +import Link from "next/link"; import { Badge } from "@/components/ui/badge"; import { Card } from "@/components/ui/card"; import type { ChangelogVersion } from "@/lib/changelog/parse-changelog"; +/** Converte "[texto](url)" em link; texto simples fica como está */ +function parseContributorLine(content: string) { + const linkMatch = content.match(/^\[([^\]]+)\]\((https?:\/\/[^)]+)\)$/); + if (linkMatch) { + return { label: linkMatch[1], url: linkMatch[2] }; + } + return { label: content, url: null }; +} + const sectionBadgeVariant: Record< string, "success" | "info" | "destructive" | "secondary" @@ -46,6 +56,29 @@ export function ChangelogTab({ versions }: { versions: ChangelogVersion[] }) {
))} + {version.contributor && ( +
+ + Contribuições:{" "} + {(() => { + const { label, url } = parseContributorLine(version.contributor); + if (url) { + return ( + + {label} + + ); + } + return {label}; + })()} + +
+ )}
))} diff --git a/components/ajustes/preferences-form.tsx b/components/ajustes/preferences-form.tsx index 454617c..ae209e2 100644 --- a/components/ajustes/preferences-form.tsx +++ b/components/ajustes/preferences-form.tsx @@ -1,5 +1,17 @@ "use client"; +import { + DndContext, + closestCenter, + type DragEndEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { RiDragMove2Line } from "@remixicon/react"; import { useRouter } from "next/navigation"; import { useEffect, useState, useTransition } from "react"; import { toast } from "sonner"; @@ -15,16 +27,58 @@ import { SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; +import { + DEFAULT_LANCAMENTOS_COLUMN_ORDER, + LANCAMENTOS_COLUMN_LABELS, +} from "@/lib/lancamentos/column-order"; import { FONT_OPTIONS, getFontVariable } from "@/public/fonts/font_index"; interface PreferencesFormProps { disableMagnetlines: boolean; + extratoNoteAsColumn: boolean; + lancamentosColumnOrder: string[] | null; systemFont: string; moneyFont: string; } +function SortableColumnItem({ id }: { id: string }) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + const label = LANCAMENTOS_COLUMN_LABELS[id] ?? id; + + return ( +
+ + {label} +
+ ); +} + export function PreferencesForm({ disableMagnetlines, + extratoNoteAsColumn: initialExtratoNoteAsColumn, + lancamentosColumnOrder: initialColumnOrder, systemFont: initialSystemFont, moneyFont: initialMoneyFont, }: PreferencesFormProps) { @@ -32,10 +86,33 @@ export function PreferencesForm({ const [isPending, startTransition] = useTransition(); const [magnetlinesDisabled, setMagnetlinesDisabled] = useState(disableMagnetlines); + const [extratoNoteAsColumn, setExtratoNoteAsColumn] = + useState(initialExtratoNoteAsColumn); + const [columnOrder, setColumnOrder] = useState( + initialColumnOrder && initialColumnOrder.length > 0 + ? initialColumnOrder + : DEFAULT_LANCAMENTOS_COLUMN_ORDER, + ); const [selectedSystemFont, setSelectedSystemFont] = useState(initialSystemFont); const [selectedMoneyFont, setSelectedMoneyFont] = useState(initialMoneyFont); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor), + ); + + const handleColumnDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (over && active.id !== over.id) { + setColumnOrder((items) => { + const oldIndex = items.indexOf(active.id as string); + const newIndex = items.indexOf(over.id as string); + return arrayMove(items, oldIndex, newIndex); + }); + } + }; + const fontCtx = useFont(); // Live preview: update CSS vars when font selection changes @@ -53,6 +130,8 @@ export function PreferencesForm({ startTransition(async () => { const result = await updatePreferencesAction({ disableMagnetlines: magnetlinesDisabled, + extratoNoteAsColumn, + lancamentosColumnOrder: columnOrder, systemFont: selectedSystemFont, moneyFont: selectedMoneyFont, }); @@ -148,7 +227,59 @@ export function PreferencesForm({
- {/* Seção 3: Dashboard */} + {/* Seção: Extrato / Lançamentos */} +
+
+

Extrato e lançamentos

+

+ Como exibir anotações e a ordem das colunas na tabela de movimentações. +

+
+ +
+
+ +

+ Quando ativo, as anotações aparecem em uma coluna na tabela. Quando desativado, aparecem em um balão ao passar o mouse no ícone. +

+
+ +
+ +
+ +

+ Arraste os itens para definir a ordem em que as colunas aparecem na tabela do extrato e dos lançamentos. +

+ + +
+ {columnOrder.map((id) => ( + + ))} +
+
+
+
+
+ +
+ + {/* Seção: Dashboard */}

Dashboard

diff --git a/components/cartoes/card-dialog.tsx b/components/cartoes/card-dialog.tsx index 87d71d3..4ddcbfc 100644 --- a/components/cartoes/card-dialog.tsx +++ b/components/cartoes/card-dialog.tsx @@ -126,7 +126,10 @@ export function CardDialog({ currentName: formState.name, onUpdate: (updates) => { updateFields(updates); - setLogoDialogOpen(false); + // Delay closing to avoid race condition on mobile + requestAnimationFrame(() => { + setLogoDialogOpen(false); + }); }, }); @@ -188,11 +191,29 @@ export function CardDialog({ : "Atualize as informações do cartão selecionado."; const submitLabel = mode === "create" ? "Salvar cartão" : "Atualizar cartão"; + const handleMainDialogOpenChange = useCallback( + (open: boolean) => { + if (!open && logoDialogOpen) { + return; + } + setDialogOpen(open); + }, + [logoDialogOpen, setDialogOpen], + ); + return ( <> - + {trigger ? {trigger} : null} - + { + if (logoDialogOpen) e.preventDefault(); + }} + onInteractOutside={(e) => { + if (logoDialogOpen) e.preventDefault(); + }} + > {title} {description} diff --git a/components/contas/account-dialog.tsx b/components/contas/account-dialog.tsx index f9747f2..81fb443 100644 --- a/components/contas/account-dialog.tsx +++ b/components/contas/account-dialog.tsx @@ -152,7 +152,10 @@ export function AccountDialog({ currentName: formState.name, onUpdate: (updates) => { updateFields(updates); - setLogoDialogOpen(false); + // Delay closing to avoid race condition on mobile + requestAnimationFrame(() => { + setLogoDialogOpen(false); + }); }, }); @@ -205,11 +208,29 @@ export function AccountDialog({ : "Atualize as informações da conta selecionada."; const submitLabel = mode === "create" ? "Salvar conta" : "Atualizar conta"; + const handleMainDialogOpenChange = useCallback( + (open: boolean) => { + if (!open && logoDialogOpen) { + return; + } + setDialogOpen(open); + }, + [logoDialogOpen, setDialogOpen], + ); + return ( <> - + {trigger ? {trigger} : null} - + { + if (logoDialogOpen) e.preventDefault(); + }} + onInteractOutside={(e) => { + if (logoDialogOpen) e.preventDefault(); + }} + > {title} {description} diff --git a/components/header-dashboard.tsx b/components/header-dashboard.tsx index 9baa8af..c9b4f2a 100644 --- a/components/header-dashboard.tsx +++ b/components/header-dashboard.tsx @@ -7,6 +7,7 @@ import { AnimatedThemeToggler } from "./animated-theme-toggler"; import LogoutButton from "./auth/logout-button"; import { CalculatorDialogButton } from "./calculadora/calculator-dialog"; import { PrivacyModeToggle } from "./privacy-mode-toggle"; +import { RefreshPageButton } from "./refresh-page-button"; type SiteHeaderProps = { notificationsSnapshot: DashboardNotificationsSnapshot; @@ -16,7 +17,7 @@ export async function SiteHeader({ notificationsSnapshot }: SiteHeaderProps) { const _user = await getUser(); return ( -
+
@@ -25,6 +26,7 @@ export async function SiteHeader({ notificationsSnapshot }: SiteHeaderProps) { totalCount={notificationsSnapshot.totalCount} /> + | diff --git a/components/lancamentos/page/lancamentos-page.tsx b/components/lancamentos/page/lancamentos-page.tsx index 912f1af..7fe938f 100644 --- a/components/lancamentos/page/lancamentos-page.tsx +++ b/components/lancamentos/page/lancamentos-page.tsx @@ -48,6 +48,8 @@ interface LancamentosPageProps { selectedPeriod: string; estabelecimentos: string[]; allowCreate?: boolean; + noteAsColumn?: boolean; + columnOrder?: string[] | null; defaultCartaoId?: string | null; defaultPaymentMethod?: string | null; lockCartaoSelection?: boolean; @@ -76,6 +78,8 @@ export function LancamentosPage({ selectedPeriod, estabelecimentos, allowCreate = true, + noteAsColumn = false, + columnOrder = null, defaultCartaoId, defaultPaymentMethod, lockCartaoSelection, @@ -377,6 +381,8 @@ export function LancamentosPage({ { type BuildColumnsArgs = { currentUserId: string; + noteAsColumn: boolean; onEdit?: (item: LancamentoItem) => void; onCopy?: (item: LancamentoItem) => void; onImport?: (item: LancamentoItem) => void; @@ -106,6 +109,7 @@ type BuildColumnsArgs = { const buildColumns = ({ currentUserId, + noteAsColumn, onEdit, onCopy, onImport, @@ -269,7 +273,7 @@ const buildColumns = ({ )} - {hasNote ? ( + {!noteAsColumn && hasNote ? ( @@ -493,6 +497,24 @@ const buildColumns = ({ }, ]; + if (noteAsColumn) { + const contaCartaoIndex = columns.findIndex((c) => c.id === "contaCartao"); + const noteColumn: ColumnDef = { + accessorKey: "note", + header: "Anotação", + cell: ({ row }) => { + const note = row.original.note; + if (!note?.trim()) return ; + return ( + + {note} + + ); + }, + }; + columns.splice(contaCartaoIndex, 0, noteColumn); + } + if (showActions) { columns.push({ id: "actions", @@ -645,9 +667,51 @@ const buildColumns = ({ return columns; }; +const FIXED_START_IDS = ["select", "purchaseDate"]; +const FIXED_END_IDS = ["actions"]; + +function getColumnId(col: ColumnDef): string { + const c = col as { id?: string; accessorKey?: string }; + return c.id ?? c.accessorKey ?? ""; +} + +function reorderColumnsByPreference( + columns: ColumnDef[], + orderPreference: string[] | null | undefined, +): ColumnDef[] { + if (!orderPreference || orderPreference.length === 0) return columns; + + const order = orderPreference; + const fixedStart: ColumnDef[] = []; + const reorderable: ColumnDef[] = []; + const fixedEnd: ColumnDef[] = []; + + for (const col of columns) { + const id = getColumnId(col as ColumnDef); + if (FIXED_START_IDS.includes(id)) fixedStart.push(col); + else if (FIXED_END_IDS.includes(id)) fixedEnd.push(col); + else reorderable.push(col); + } + + const sorted = [...reorderable].sort((a, b) => { + const idA = getColumnId(a as ColumnDef); + const idB = getColumnId(b as ColumnDef); + const indexA = order.indexOf(idA); + const indexB = order.indexOf(idB); + if (indexA === -1 && indexB === -1) return 0; + if (indexA === -1) return 1; + if (indexB === -1) return -1; + return indexA - indexB; + }); + + return [...fixedStart, ...sorted, ...fixedEnd]; +} + type LancamentosTableProps = { data: LancamentoItem[]; currentUserId: string; + noteAsColumn?: boolean; + columnOrder?: string[] | null; pagadorFilterOptions?: LancamentoFilterOption[]; categoriaFilterOptions?: LancamentoFilterOption[]; contaCartaoFilterOptions?: ContaCartaoFilterOption[]; @@ -672,6 +736,8 @@ type LancamentosTableProps = { export function LancamentosTable({ data, currentUserId, + noteAsColumn = false, + columnOrder: columnOrderPreference = null, pagadorFilterOptions = [], categoriaFilterOptions = [], contaCartaoFilterOptions = [], @@ -704,23 +770,10 @@ export function LancamentosTable({ }); const [rowSelection, setRowSelection] = useState({}); - const columns = useMemo( - () => - buildColumns({ - currentUserId, - onEdit, - onCopy, - onImport, - onConfirmDelete, - onViewDetails, - onToggleSettlement, - onAnticipate, - onViewAnticipationHistory, - isSettlementLoading: isSettlementLoading ?? (() => false), - showActions, - }), - [ + const columns = useMemo(() => { + const built = buildColumns({ currentUserId, + noteAsColumn, onEdit, onCopy, onImport, @@ -729,10 +782,28 @@ export function LancamentosTable({ onToggleSettlement, onAnticipate, onViewAnticipationHistory, - isSettlementLoading, + isSettlementLoading: isSettlementLoading ?? (() => false), showActions, - ], - ); + }); + const order = columnOrderPreference?.length + ? columnOrderPreference + : DEFAULT_LANCAMENTOS_COLUMN_ORDER; + return reorderColumnsByPreference(built, order); + }, [ + currentUserId, + noteAsColumn, + columnOrderPreference, + onEdit, + onCopy, + onImport, + onConfirmDelete, + onViewDetails, + onToggleSettlement, + onAnticipate, + onViewAnticipationHistory, + isSettlementLoading, + showActions, + ]); const table = useReactTable({ data, @@ -789,47 +860,57 @@ export function LancamentosTable({ {showTopControls ? (
{onCreate || onMassAdd ? ( -
- {onCreate ? ( - <> - - - - ) : null} - {onMassAdd ? ( - - - - - -

Adicionar múltiplos lançamentos

-
-
- ) : null} +
+
+
+ {onCreate ? ( + <> + + + + ) : null} + {onMassAdd ? ( + + + + + +

Adicionar múltiplos lançamentos

+
+
+ ) : null} +
+
+
+ +
) : ( diff --git a/components/logo-picker.tsx b/components/logo-picker.tsx index 44d97e5..786fbe0 100644 --- a/components/logo-picker.tsx +++ b/components/logo-picker.tsx @@ -158,7 +158,13 @@ export function LogoPickerDialog({ + } + /> + - } - /> -
+
+
- - Copiar orçamentos do último mês - + +
{hasBudgets ? ( diff --git a/components/refresh-page-button.tsx b/components/refresh-page-button.tsx new file mode 100644 index 0000000..5585235 --- /dev/null +++ b/components/refresh-page-button.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { RiRefreshLine } from "@remixicon/react"; +import { useRouter } from "next/navigation"; +import { useTransition } from "react"; +import { buttonVariants } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils/ui"; + +type RefreshPageButtonProps = React.ComponentPropsWithoutRef<"button">; + +export function RefreshPageButton({ + className, + ...props +}: RefreshPageButtonProps) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + + const handleClick = () => { + startTransition(() => { + router.refresh(); + }); + }; + + return ( + + + + + Atualizar página + + ); +} diff --git a/components/ui/chart.tsx b/components/ui/chart.tsx index 0eff464..206d35c 100644 --- a/components/ui/chart.tsx +++ b/components/ui/chart.tsx @@ -49,24 +49,36 @@ function ChartContainer({ }) { const uniqueId = React.useId(); const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; + const [mounted, setMounted] = React.useState(false); + + React.useEffect(() => { + setMounted(true); + }, []); return (
-
- - {children} - +
+ {mounted ? ( + + {children} + + ) : null}
diff --git a/db/schema.ts b/db/schema.ts index 40d969b..fa9b6b9 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -107,8 +107,10 @@ export const preferenciasUsuario = pgTable("preferencias_usuario", { .unique() .references(() => user.id, { onDelete: "cascade" }), disableMagnetlines: boolean("disable_magnetlines").notNull().default(false), + extratoNoteAsColumn: boolean("extrato_note_as_column").notNull().default(false), systemFont: text("system_font").notNull().default("ai-sans"), moneyFont: text("money_font").notNull().default("ai-sans"), + lancamentosColumnOrder: jsonb("lancamentos_column_order").$type(), dashboardWidgets: jsonb("dashboard_widgets").$type<{ order: string[]; hidden: string[]; diff --git a/docker-compose.yml b/docker-compose.yml index dd686a3..26b6b8e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,8 @@ services: POSTGRES_USER: ${POSTGRES_USER:-openmonetis} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password} POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db} + # Garante que os dados ficam no volume montado (evita perda após down/up) + PGDATA: /var/lib/postgresql/data # Configurações de performance POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C" @@ -91,7 +93,7 @@ services: # Configurações de email (se usar) RESEND_API_KEY: ${RESEND_API_KEY:-} - EMAIL_FROM: ${EMAIL_FROM:-} + RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-} # Configurações de OAuth (se usar) GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} diff --git a/drizzle/0017_add_extrato_note_as_column.sql b/drizzle/0017_add_extrato_note_as_column.sql new file mode 100644 index 0000000..92dd36b --- /dev/null +++ b/drizzle/0017_add_extrato_note_as_column.sql @@ -0,0 +1 @@ +ALTER TABLE "preferencias_usuario" ADD COLUMN IF NOT EXISTS "extrato_note_as_column" boolean DEFAULT false NOT NULL; diff --git a/drizzle/0018_add_lancamentos_column_order.sql b/drizzle/0018_add_lancamentos_column_order.sql new file mode 100644 index 0000000..59eb499 --- /dev/null +++ b/drizzle/0018_add_lancamentos_column_order.sql @@ -0,0 +1 @@ +ALTER TABLE "preferencias_usuario" ADD COLUMN IF NOT EXISTS "lancamentos_column_order" jsonb; diff --git a/lib/actions/helpers.ts b/lib/actions/helpers.ts index 0271d80..c901a64 100644 --- a/lib/actions/helpers.ts +++ b/lib/actions/helpers.ts @@ -25,6 +25,7 @@ export const revalidateConfig = { cartoes: ["/cartoes", "/contas", "/lancamentos"], contas: ["/contas", "/lancamentos"], categorias: ["/categorias"], + estabelecimentos: ["/estabelecimentos", "/lancamentos"], orcamentos: ["/orcamentos"], pagadores: ["/pagadores"], anotacoes: ["/anotacoes", "/anotacoes/arquivadas"], diff --git a/lib/changelog/parse-changelog.ts b/lib/changelog/parse-changelog.ts index 9e9a317..57d60da 100644 --- a/lib/changelog/parse-changelog.ts +++ b/lib/changelog/parse-changelog.ts @@ -10,6 +10,8 @@ export type ChangelogVersion = { version: string; date: string; sections: ChangelogSection[]; + /** Linha de contribuições/autor (pode conter markdown, ex: [Nome](url)) */ + contributor?: string; }; export function parseChangelog(): ChangelogVersion[] { @@ -49,6 +51,13 @@ export function parseChangelog(): ChangelogVersion[] { const itemMatch = line.match(/^- (.+)$/); if (itemMatch && currentSection) { currentSection.items.push(itemMatch[1]); + continue; + } + + // **Contribuições:** ou **Autor:** com texto/link opcional + const contributorMatch = line.match(/^\*\*(?:Contribuições|Autor):\*\*\s*(.+)$/); + if (contributorMatch && currentVersion) { + currentVersion.contributor = contributorMatch[1].trim() || undefined; } } diff --git a/lib/email/resend.ts b/lib/email/resend.ts new file mode 100644 index 0000000..99482bd --- /dev/null +++ b/lib/email/resend.ts @@ -0,0 +1,16 @@ +import { config } from "dotenv"; + +/** + * Endereço "from" para envio de e-mails via Resend. + * Lê RESEND_FROM_EMAIL do .env (valor deve estar entre aspas se tiver espaço: + * Garante carregamento do .env no contexto da chamada (ex.: Server Actions). + */ +const FALLBACK_FROM = "OpenMonetis "; + +export function getResendFromEmail(): string { + // Garantir que .env foi carregado (não sobrescreve variáveis já definidas) + config({ path: ".env" }); + const raw = process.env.RESEND_FROM_EMAIL; + const value = typeof raw === "string" ? raw.trim() : ""; + return value.length > 0 ? value : FALLBACK_FROM; +} diff --git a/lib/lancamentos/column-order.ts b/lib/lancamentos/column-order.ts new file mode 100644 index 0000000..9413235 --- /dev/null +++ b/lib/lancamentos/column-order.ts @@ -0,0 +1,33 @@ +/** + * Ids das colunas reordenáveis da tabela de lançamentos (extrato). + * select, purchaseDate e actions são fixos (início, oculto, fim). + */ +export const LANCAMENTOS_REORDERABLE_COLUMN_IDS = [ + "name", + "transactionType", + "amount", + "condition", + "paymentMethod", + "categoriaName", + "pagadorName", + "note", + "contaCartao", +] as const; + +export type LancamentosColumnId = (typeof LANCAMENTOS_REORDERABLE_COLUMN_IDS)[number]; + +export const LANCAMENTOS_COLUMN_LABELS: Record = { + name: "Estabelecimento", + transactionType: "Transação", + amount: "Valor", + condition: "Condição", + paymentMethod: "Forma de Pagamento", + categoriaName: "Categoria", + pagadorName: "Pagador", + note: "Anotação", + contaCartao: "Conta/Cartão", +}; + +export const DEFAULT_LANCAMENTOS_COLUMN_ORDER: string[] = [ + ...LANCAMENTOS_REORDERABLE_COLUMN_IDS, +]; diff --git a/lib/pagadores/notifications.ts b/lib/pagadores/notifications.ts index 1c8fb19..92e92d5 100644 --- a/lib/pagadores/notifications.ts +++ b/lib/pagadores/notifications.ts @@ -1,6 +1,7 @@ import { inArray } from "drizzle-orm"; import { Resend } from "resend"; import { pagadores } from "@/db/schema"; +import { getResendFromEmail } from "@/lib/email/resend"; import { db } from "@/lib/db"; type ActionType = "created" | "deleted"; @@ -118,8 +119,7 @@ export async function sendPagadorAutoEmails({ } const resendApiKey = process.env.RESEND_API_KEY; - const resendFrom = - process.env.RESEND_FROM_EMAIL ?? "OpenMonetis "; + const resendFrom = getResendFromEmail(); if (!resendApiKey) { console.warn( diff --git a/lib/transferencias/constants.ts b/lib/transferencias/constants.ts index af25310..71fcf54 100644 --- a/lib/transferencias/constants.ts +++ b/lib/transferencias/constants.ts @@ -1,5 +1,7 @@ export const TRANSFER_CATEGORY_NAME = "Transferência interna"; export const TRANSFER_ESTABLISHMENT = "Transf. entre contas"; +export const TRANSFER_ESTABLISHMENT_SAIDA = "Saída - Transf. entre contas"; +export const TRANSFER_ESTABLISHMENT_ENTRADA = "Entrada - Transf. entre contas"; export const TRANSFER_PAGADOR = "Admin"; export const TRANSFER_PAYMENT_METHOD = "Pix"; export const TRANSFER_CONDITION = "À vista"; diff --git a/package.json b/package.json index 6ae80ea..df75ced 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openmonetis", - "version": "1.5.3", + "version": "1.6.3", "private": true, "scripts": { "dev": "next dev --turbopack",