feat: merge PR #16 — melhorias mobile, fixes e preferências de coluna

Inclui (do contributor Guilherme Bano):
- Ajustes de layout mobile em várias páginas
- Fix: diálogo de conta/cartão fechava ao selecionar logo no mobile
- Botão de atualizar página no header
- Fix: integração com Resend (RESEND_FROM_EMAIL)
- Preferência "Anotações em coluna" nos lançamentos
- Preferência "Ordem das colunas" nos lançamentos
- Transferências com nome padronizado ("Saída/Entrada - Transf. entre contas")
- ChartContainer: fix do aviso width/height no Recharts

Removido antes do merge:
- Página /estabelecimentos e tabela do banco
- Página /relatorios/gastos-por-categoria
- Widget expenses-by-category revertido ao original

Co-Authored-By: Guilherme Bano <guilhermesaboia2011@hotmail.com>
This commit is contained in:
Felipe Coutinho
2026-02-21 21:29:06 +00:00
37 changed files with 680 additions and 125 deletions

View File

@@ -25,7 +25,7 @@ DB_PORT=5432
# === Email (Opcional) ===
# Provider: Resend (https://resend.com)
RESEND_API_KEY=
RESEND_FROM_EMAIL=OpenMonetis <noreply@seudominio.com>
RESEND_FROM_EMAIL="OpenMonetis <noreply@seudominio.com>"
# === OAuth (Opcional) ===
# Google: https://console.cloud.google.com/apis/credentials

View File

@@ -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

View File

@@ -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,
});

View File

@@ -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,
})

View File

@@ -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 (
<div className="w-full">
<Tabs defaultValue="preferencias" className="w-full">
<TabsList>
<TabsTrigger value="preferencias">Preferências</TabsTrigger>
<TabsTrigger value="companion">Companion</TabsTrigger>
<TabsTrigger value="nome">Alterar nome</TabsTrigger>
<TabsTrigger value="senha">Alterar senha</TabsTrigger>
<TabsTrigger value="email">Alterar e-mail</TabsTrigger>
<TabsTrigger value="changelog">Changelog</TabsTrigger>
<TabsTrigger value="deletar" className="text-destructive">
Deletar conta
</TabsTrigger>
</TabsList>
{/* No mobile: rolagem horizontal + seta indicando mais opções à direita */}
<div className="relative -mx-6 px-6 md:mx-0 md:px-0">
<div className="overflow-x-auto overflow-y-hidden scroll-smooth md:overflow-visible [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<TabsList className="inline-flex w-max flex-nowrap md:w-full">
<TabsTrigger value="preferencias">Preferências</TabsTrigger>
<TabsTrigger value="companion">Companion</TabsTrigger>
<TabsTrigger value="nome">Alterar nome</TabsTrigger>
<TabsTrigger value="senha">Alterar senha</TabsTrigger>
<TabsTrigger value="email">Alterar e-mail</TabsTrigger>
<TabsTrigger value="changelog">Changelog</TabsTrigger>
<TabsTrigger value="deletar" className="text-destructive">
Deletar conta
</TabsTrigger>
</TabsList>
</div>
<div
className="pointer-events-none absolute right-0 top-0 hidden h-9 w-10 items-center justify-end bg-gradient-to-l from-background to-transparent md:hidden"
aria-hidden
>
<RiArrowRightSLine className="size-5 shrink-0 text-muted-foreground" />
</div>
</div>
<TabsContent value="preferencias" className="mt-4">
<Card className="p-6">
@@ -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"}
/>

View File

@@ -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

View File

@@ -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}
/>
</main>
);

View File

@@ -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}
/>
</section>
</main>

View File

@@ -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",

View File

@@ -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}
/>
</main>
);

View File

@@ -70,7 +70,7 @@ export default async function DashboardLayout({
/>
<SidebarInset>
<SiteHeader notificationsSnapshot={notificationsSnapshot} />
<div className="flex flex-1 flex-col">
<div className="flex flex-1 flex-col pt-12 md:pt-0">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6">
{children}

View File

@@ -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 <onboarding@resend.dev>";
const resendFrom = getResendFromEmail();
if (!resendApiKey) {
return {

View File

@@ -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

View File

@@ -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[] }) {
</ul>
</div>
))}
{version.contributor && (
<div className="border-t pt-4 mt-4">
<span className="text-sm text-muted-foreground">
Contribuições:{" "}
{(() => {
const { label, url } = parseContributorLine(version.contributor);
if (url) {
return (
<Link
href={url}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-foreground underline underline-offset-2 hover:text-primary"
>
{label}
</Link>
);
}
return <span className="font-medium text-foreground">{label}</span>;
})()}
</span>
</div>
)}
</div>
</Card>
))}

View File

@@ -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 (
<div
ref={setNodeRef}
style={style}
className={`flex cursor-grab active:cursor-grabbing items-center gap-2 rounded-md border bg-card px-3 py-2 text-sm touch-none select-none ${
isDragging ? "z-10 opacity-90 shadow-md" : ""
}`}
aria-label={`Arrastar ${label}`}
{...attributes}
{...listeners}
>
<RiDragMove2Line className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<span>{label}</span>
</div>
);
}
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<string[]>(
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({
<div className="border-b" />
{/* Seção 3: Dashboard */}
{/* Seção: Extrato / Lançamentos */}
<section className="space-y-4">
<div>
<h3 className="text-base font-semibold">Extrato e lançamentos</h3>
<p className="text-sm text-muted-foreground">
Como exibir anotações e a ordem das colunas na tabela de movimentações.
</p>
</div>
<div className="flex items-center justify-between rounded-lg border p-4 max-w-md">
<div className="space-y-0.5">
<Label htmlFor="extrato-note-column" className="text-base">
Anotações em coluna
</Label>
<p className="text-sm text-muted-foreground">
Quando ativo, as anotações aparecem em uma coluna na tabela. Quando desativado, aparecem em um balão ao passar o mouse no ícone.
</p>
</div>
<Switch
id="extrato-note-column"
checked={extratoNoteAsColumn}
onCheckedChange={setExtratoNoteAsColumn}
disabled={isPending}
/>
</div>
<div className="space-y-2 max-w-md">
<Label className="text-base">Ordem das colunas</Label>
<p className="text-sm text-muted-foreground">
Arraste os itens para definir a ordem em que as colunas aparecem na tabela do extrato e dos lançamentos.
</p>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleColumnDragEnd}
>
<SortableContext
items={columnOrder}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-2 pt-2">
{columnOrder.map((id) => (
<SortableColumnItem key={id} id={id} />
))}
</div>
</SortableContext>
</DndContext>
</div>
</section>
<div className="border-b" />
{/* Seção: Dashboard */}
<section className="space-y-4">
<div>
<h3 className="text-base font-semibold">Dashboard</h3>

View File

@@ -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 (
<>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<Dialog open={dialogOpen} onOpenChange={handleMainDialogOpenChange}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent className="">
<DialogContent
className=""
onPointerDownOutside={(e) => {
if (logoDialogOpen) e.preventDefault();
}}
onInteractOutside={(e) => {
if (logoDialogOpen) e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>

View File

@@ -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 (
<>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<Dialog open={dialogOpen} onOpenChange={handleMainDialogOpenChange}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent className="sm:max-w-xl">
<DialogContent
className="sm:max-w-xl"
onPointerDownOutside={(e) => {
if (logoDialogOpen) e.preventDefault();
}}
onInteractOutside={(e) => {
if (logoDialogOpen) e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>

View File

@@ -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 (
<header className="border-b flex h-12 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<header className="fixed top-0 left-0 right-0 z-50 border-b bg-background md:static md:z-auto flex h-12 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<SidebarTrigger className="-ml-1" />
<div className="ml-auto flex items-center gap-2">
@@ -25,6 +26,7 @@ export async function SiteHeader({ notificationsSnapshot }: SiteHeaderProps) {
totalCount={notificationsSnapshot.totalCount}
/>
<CalculatorDialogButton withTooltip />
<RefreshPageButton />
<PrivacyModeToggle />
<AnimatedThemeToggler />
<span className="text-muted-foreground">|</span>

View File

@@ -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({
<LancamentosTable
data={lancamentos}
currentUserId={currentUserId}
noteAsColumn={noteAsColumn}
columnOrder={columnOrder}
pagadorFilterOptions={pagadorFilterOptions}
categoriaFilterOptions={categoriaFilterOptions}
contaCartaoFilterOptions={contaCartaoFilterOptions}

View File

@@ -33,7 +33,7 @@ export function EstabelecimentoInput({
value,
onChange,
estabelecimentos = [],
placeholder = "Ex.: Padaria",
placeholder = "Ex.: Padaria, Transferência, Saldo inicial",
required = false,
maxLength = 20,
}: EstabelecimentoInputProps) {

View File

@@ -3,6 +3,7 @@ import {
RiAddCircleFill,
RiAddCircleLine,
RiArrowLeftRightLine,
RiArrowRightSLine,
RiChat1Line,
RiCheckLine,
RiDeleteBin5Line,
@@ -68,6 +69,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DEFAULT_LANCAMENTOS_COLUMN_ORDER } from "@/lib/lancamentos/column-order";
import { getAvatarSrc } from "@/lib/pagadores/utils";
import { formatDate } from "@/lib/utils/date";
import { getConditionIcon, getPaymentMethodIcon } from "@/lib/utils/icons";
@@ -92,6 +94,7 @@ const resolveLogoSrc = (logo: string | null) => {
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 = ({
</Tooltip>
)}
{hasNote ? (
{!noteAsColumn && hasNote ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1 hover:bg-muted/60">
@@ -493,6 +497,24 @@ const buildColumns = ({
},
];
if (noteAsColumn) {
const contaCartaoIndex = columns.findIndex((c) => c.id === "contaCartao");
const noteColumn: ColumnDef<LancamentoItem> = {
accessorKey: "note",
header: "Anotação",
cell: ({ row }) => {
const note = row.original.note;
if (!note?.trim()) return <span className="text-muted-foreground"></span>;
return (
<span className="max-w-[200px] truncate whitespace-pre-line text-sm" title={note}>
{note}
</span>
);
},
};
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<LancamentoItem>): string {
const c = col as { id?: string; accessorKey?: string };
return c.id ?? c.accessorKey ?? "";
}
function reorderColumnsByPreference<T>(
columns: ColumnDef<T>[],
orderPreference: string[] | null | undefined,
): ColumnDef<T>[] {
if (!orderPreference || orderPreference.length === 0) return columns;
const order = orderPreference;
const fixedStart: ColumnDef<T>[] = [];
const reorderable: ColumnDef<T>[] = [];
const fixedEnd: ColumnDef<T>[] = [];
for (const col of columns) {
const id = getColumnId(col as ColumnDef<LancamentoItem>);
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<LancamentoItem>);
const idB = getColumnId(b as ColumnDef<LancamentoItem>);
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<RowSelectionState>({});
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 ? (
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
{onCreate || onMassAdd ? (
<div className="flex gap-2">
{onCreate ? (
<>
<Button
onClick={() => onCreate("Receita")}
variant="outline"
className="w-full sm:w-auto"
>
<RiAddCircleLine className="size-4 text-success" />
Nova Receita
</Button>
<Button
onClick={() => onCreate("Despesa")}
variant="outline"
className="w-full sm:w-auto"
>
<RiAddCircleLine className="size-4 text-destructive" />
Nova Despesa
</Button>
</>
) : null}
{onMassAdd ? (
<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 className="relative -mx-6 px-6 md:mx-0 md:px-0">
<div className="overflow-x-auto overflow-y-hidden scroll-smooth md:overflow-visible [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="flex w-max shrink-0 gap-2 py-1 md:w-full md:py-0">
{onCreate ? (
<>
<Button
onClick={() => onCreate("Receita")}
variant="outline"
className="h-8 shrink-0 px-3 text-xs sm:w-auto md:h-9 md:px-4 md:text-sm"
>
<RiAddCircleLine className="size-4 text-success" />
Nova Receita
</Button>
<Button
onClick={() => onCreate("Despesa")}
variant="outline"
className="h-8 shrink-0 px-3 text-xs sm:w-auto md:h-9 md:px-4 md:text-sm"
>
<RiAddCircleLine className="size-4 text-destructive" />
Nova Despesa
</Button>
</>
) : null}
{onMassAdd ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onMassAdd}
variant="outline"
size="icon"
className="size-8 shrink-0 md:size-9"
>
<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>
</div>
<div
className="pointer-events-none absolute right-0 top-0 hidden h-9 w-10 items-center justify-end bg-gradient-to-l from-background to-transparent py-1 md:hidden"
aria-hidden
>
<RiArrowRightSLine className="size-5 shrink-0 text-muted-foreground" />
</div>
</div>
) : (
<span className={showFilters ? "hidden sm:block" : ""} />

View File

@@ -158,7 +158,13 @@ export function LogoPickerDialog({
<button
type="button"
key={logo}
onClick={() => onSelect(logo)}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onSelect(logo);
}}
onPointerDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
className={cn(
"flex flex-col items-center gap-1 rounded-md bg-card p-2 text-center text-xs transition-all hover:border-primary hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
isActive &&

View File

@@ -79,7 +79,7 @@ export default function MonthNavigation() {
};
return (
<Card className="sticky top-0 z-30 w-full flex-row bg-card text-card-foreground p-4">
<Card className="w-full flex-row bg-card text-card-foreground p-4">
<div className="flex items-center gap-1">
<NavigationButton
direction="left"

View File

@@ -1,6 +1,11 @@
"use client";
import { RiAddCircleLine, RiFileCopyLine, RiFundsLine } from "@remixicon/react";
import {
RiAddCircleLine,
RiArrowRightSLine,
RiFileCopyLine,
RiFundsLine,
} from "@remixicon/react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import {
@@ -105,26 +110,41 @@ export function BudgetsPage({
return (
<>
<div className="flex w-full flex-col gap-6">
<div className="flex justify-start gap-4">
<BudgetDialog
mode="create"
categories={categories}
defaultPeriod={selectedPeriod}
trigger={
<Button disabled={categories.length === 0}>
<RiAddCircleLine className="size-4" />
Novo orçamento
{/* No mobile: rolagem horizontal + seta + botões menores */}
<div className="relative -mx-6 px-6 md:mx-0 md:px-0">
<div className="overflow-x-auto overflow-y-hidden scroll-smooth md:overflow-visible [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="flex w-max shrink-0 justify-start gap-3 py-1 md:w-full md:gap-4 md:py-0">
<BudgetDialog
mode="create"
categories={categories}
defaultPeriod={selectedPeriod}
trigger={
<Button
disabled={categories.length === 0}
className="h-8 shrink-0 px-3 text-xs md:h-9 md:px-4 md:text-sm"
>
<RiAddCircleLine className="size-4" />
Novo orçamento
</Button>
}
/>
<Button
variant="outline"
disabled={categories.length === 0}
onClick={() => setDuplicateOpen(true)}
className="h-8 shrink-0 px-3 text-xs md:h-9 md:px-4 md:text-sm"
>
<RiFileCopyLine className="size-4" />
Copiar orçamentos do último mês
</Button>
}
/>
<Button
variant="outline"
disabled={categories.length === 0}
onClick={() => setDuplicateOpen(true)}
</div>
</div>
<div
className="pointer-events-none absolute right-0 top-0 hidden h-9 w-10 items-center justify-end bg-gradient-to-l from-background to-transparent py-1 md:hidden"
aria-hidden
>
<RiFileCopyLine className="size-4" />
Copiar orçamentos do último mês
</Button>
<RiArrowRightSLine className="size-5 shrink-0 text-muted-foreground" />
</div>
</div>
{hasBudgets ? (

View File

@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleClick}
disabled={isPending}
aria-label="Atualizar página"
title="Atualizar página"
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"size-8 text-muted-foreground transition-all duration-200",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40 border",
"disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...props}
>
<RiRefreshLine
className={cn("size-4 transition-transform duration-200", isPending && "animate-spin")}
aria-hidden
/>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Atualizar página</TooltipContent>
</Tooltip>
);
}

View File

@@ -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 (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
style={{ minWidth: 0, minHeight: 0, ...style }}
style={style}
className={cn(
"flex w-full min-h-0 min-w-0 justify-center text-xs aspect-video [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
"flex w-full min-w-0 min-h-[200px] justify-center text-xs aspect-video [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<div className="h-full w-full">
<RechartsPrimitive.ResponsiveContainer width="100%" height="100%">
{children}
</RechartsPrimitive.ResponsiveContainer>
<div className="h-full w-full min-h-[200px] min-w-[280px]">
{mounted ? (
<RechartsPrimitive.ResponsiveContainer
width="100%"
height="100%"
minWidth={280}
minHeight={200}
>
{children}
</RechartsPrimitive.ResponsiveContainer>
) : null}
</div>
</div>
</ChartContext.Provider>

View File

@@ -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<string[] | null>(),
dashboardWidgets: jsonb("dashboard_widgets").$type<{
order: string[];
hidden: string[];

View File

@@ -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:-}

View File

@@ -0,0 +1 @@
ALTER TABLE "preferencias_usuario" ADD COLUMN IF NOT EXISTS "extrato_note_as_column" boolean DEFAULT false NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "preferencias_usuario" ADD COLUMN IF NOT EXISTS "lancamentos_column_order" jsonb;

View File

@@ -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"],

View File

@@ -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;
}
}

16
lib/email/resend.ts Normal file
View File

@@ -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 <noreply@resend.dev>";
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;
}

View File

@@ -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<string, string> = {
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,
];

View File

@@ -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 <onboarding@resend.dev>";
const resendFrom = getResendFromEmail();
if (!resendApiKey) {
console.warn(

View File

@@ -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";

View File

@@ -1,6 +1,6 @@
{
"name": "openmonetis",
"version": "1.5.3",
"version": "1.6.3",
"private": true,
"scripts": {
"dev": "next dev --turbopack",