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:
@@ -25,7 +25,7 @@ DB_PORT=5432
|
|||||||
# === Email (Opcional) ===
|
# === Email (Opcional) ===
|
||||||
# Provider: Resend (https://resend.com)
|
# Provider: Resend (https://resend.com)
|
||||||
RESEND_API_KEY=
|
RESEND_API_KEY=
|
||||||
RESEND_FROM_EMAIL=OpenMonetis <noreply@seudominio.com>
|
RESEND_FROM_EMAIL="OpenMonetis <noreply@seudominio.com>"
|
||||||
|
|
||||||
# === OAuth (Opcional) ===
|
# === OAuth (Opcional) ===
|
||||||
# Google: https://console.cloud.google.com/apis/credentials
|
# Google: https://console.cloud.google.com/apis/credentials
|
||||||
|
|||||||
45
CHANGELOG.md
45
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/),
|
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/).
|
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
|
## [1.5.3] - 2026-02-21
|
||||||
|
|
||||||
### Adicionado
|
### Adicionado
|
||||||
@@ -222,3 +266,4 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
|||||||
|
|
||||||
- Atualização de dependências
|
- Atualização de dependências
|
||||||
- Aplicada formatação no código
|
- Aplicada formatação no código
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ const VALID_FONTS = [
|
|||||||
|
|
||||||
const updatePreferencesSchema = z.object({
|
const updatePreferencesSchema = z.object({
|
||||||
disableMagnetlines: z.boolean(),
|
disableMagnetlines: z.boolean(),
|
||||||
|
extratoNoteAsColumn: z.boolean(),
|
||||||
|
lancamentosColumnOrder: z.array(z.string()).nullable(),
|
||||||
systemFont: z.enum(VALID_FONTS).default("ai-sans"),
|
systemFont: z.enum(VALID_FONTS).default("ai-sans"),
|
||||||
moneyFont: 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)
|
.update(schema.preferenciasUsuario)
|
||||||
.set({
|
.set({
|
||||||
disableMagnetlines: validated.disableMagnetlines,
|
disableMagnetlines: validated.disableMagnetlines,
|
||||||
|
extratoNoteAsColumn: validated.extratoNoteAsColumn,
|
||||||
|
lancamentosColumnOrder: validated.lancamentosColumnOrder,
|
||||||
systemFont: validated.systemFont,
|
systemFont: validated.systemFont,
|
||||||
moneyFont: validated.moneyFont,
|
moneyFont: validated.moneyFont,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
@@ -427,6 +431,8 @@ export async function updatePreferencesAction(
|
|||||||
await db.insert(schema.preferenciasUsuario).values({
|
await db.insert(schema.preferenciasUsuario).values({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
disableMagnetlines: validated.disableMagnetlines,
|
disableMagnetlines: validated.disableMagnetlines,
|
||||||
|
extratoNoteAsColumn: validated.extratoNoteAsColumn,
|
||||||
|
lancamentosColumnOrder: validated.lancamentosColumnOrder,
|
||||||
systemFont: validated.systemFont,
|
systemFont: validated.systemFont,
|
||||||
moneyFont: validated.moneyFont,
|
moneyFont: validated.moneyFont,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { db, schema } from "@/lib/db";
|
|||||||
|
|
||||||
export interface UserPreferences {
|
export interface UserPreferences {
|
||||||
disableMagnetlines: boolean;
|
disableMagnetlines: boolean;
|
||||||
|
extratoNoteAsColumn: boolean;
|
||||||
|
lancamentosColumnOrder: string[] | null;
|
||||||
systemFont: string;
|
systemFont: string;
|
||||||
moneyFont: string;
|
moneyFont: string;
|
||||||
}
|
}
|
||||||
@@ -32,6 +34,8 @@ export async function fetchUserPreferences(
|
|||||||
const result = await db
|
const result = await db
|
||||||
.select({
|
.select({
|
||||||
disableMagnetlines: schema.preferenciasUsuario.disableMagnetlines,
|
disableMagnetlines: schema.preferenciasUsuario.disableMagnetlines,
|
||||||
|
extratoNoteAsColumn: schema.preferenciasUsuario.extratoNoteAsColumn,
|
||||||
|
lancamentosColumnOrder: schema.preferenciasUsuario.lancamentosColumnOrder,
|
||||||
systemFont: schema.preferenciasUsuario.systemFont,
|
systemFont: schema.preferenciasUsuario.systemFont,
|
||||||
moneyFont: schema.preferenciasUsuario.moneyFont,
|
moneyFont: schema.preferenciasUsuario.moneyFont,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { RiArrowRightSLine } from "@remixicon/react";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
@@ -35,17 +36,28 @@ export default async function Page() {
|
|||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Tabs defaultValue="preferencias" className="w-full">
|
<Tabs defaultValue="preferencias" className="w-full">
|
||||||
<TabsList>
|
{/* No mobile: rolagem horizontal + seta indicando mais opções à direita */}
|
||||||
<TabsTrigger value="preferencias">Preferências</TabsTrigger>
|
<div className="relative -mx-6 px-6 md:mx-0 md:px-0">
|
||||||
<TabsTrigger value="companion">Companion</TabsTrigger>
|
<div className="overflow-x-auto overflow-y-hidden scroll-smooth md:overflow-visible [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
<TabsTrigger value="nome">Alterar nome</TabsTrigger>
|
<TabsList className="inline-flex w-max flex-nowrap md:w-full">
|
||||||
<TabsTrigger value="senha">Alterar senha</TabsTrigger>
|
<TabsTrigger value="preferencias">Preferências</TabsTrigger>
|
||||||
<TabsTrigger value="email">Alterar e-mail</TabsTrigger>
|
<TabsTrigger value="companion">Companion</TabsTrigger>
|
||||||
<TabsTrigger value="changelog">Changelog</TabsTrigger>
|
<TabsTrigger value="nome">Alterar nome</TabsTrigger>
|
||||||
<TabsTrigger value="deletar" className="text-destructive">
|
<TabsTrigger value="senha">Alterar senha</TabsTrigger>
|
||||||
Deletar conta
|
<TabsTrigger value="email">Alterar e-mail</TabsTrigger>
|
||||||
</TabsTrigger>
|
<TabsTrigger value="changelog">Changelog</TabsTrigger>
|
||||||
</TabsList>
|
<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">
|
<TabsContent value="preferencias" className="mt-4">
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
@@ -61,6 +73,12 @@ export default async function Page() {
|
|||||||
disableMagnetlines={
|
disableMagnetlines={
|
||||||
userPreferences?.disableMagnetlines ?? false
|
userPreferences?.disableMagnetlines ?? false
|
||||||
}
|
}
|
||||||
|
extratoNoteAsColumn={
|
||||||
|
userPreferences?.extratoNoteAsColumn ?? false
|
||||||
|
}
|
||||||
|
lancamentosColumnOrder={
|
||||||
|
userPreferences?.lancamentosColumnOrder ?? null
|
||||||
|
}
|
||||||
systemFont={userPreferences?.systemFont ?? "ai-sans"}
|
systemFont={userPreferences?.systemFont ?? "ai-sans"}
|
||||||
moneyFont={userPreferences?.moneyFont ?? "ai-sans"}
|
moneyFont={userPreferences?.moneyFont ?? "ai-sans"}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { RiPencilLine } from "@remixicon/react";
|
import { RiPencilLine } from "@remixicon/react";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data";
|
||||||
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
||||||
import { CardDialog } from "@/components/cartoes/card-dialog";
|
import { CardDialog } from "@/components/cartoes/card-dialog";
|
||||||
import type { Card } from "@/components/cartoes/types";
|
import type { Card } from "@/components/cartoes/types";
|
||||||
@@ -51,12 +52,13 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const [filterSources, logoOptions, invoiceData, estabelecimentos] =
|
const [filterSources, logoOptions, invoiceData, estabelecimentos, userPreferences] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchLancamentoFilterSources(userId),
|
fetchLancamentoFilterSources(userId),
|
||||||
loadLogoOptions(),
|
loadLogoOptions(),
|
||||||
fetchInvoiceData(userId, cartaoId, selectedPeriod),
|
fetchInvoiceData(userId, cartaoId, selectedPeriod),
|
||||||
getRecentEstablishmentsAction(),
|
getRecentEstablishmentsAction(),
|
||||||
|
fetchUserPreferences(userId),
|
||||||
]);
|
]);
|
||||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||||
const slugMaps = buildSlugMaps(sluggedFilters);
|
const slugMaps = buildSlugMaps(sluggedFilters);
|
||||||
@@ -182,6 +184,8 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
selectedPeriod={selectedPeriod}
|
selectedPeriod={selectedPeriod}
|
||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
allowCreate
|
allowCreate
|
||||||
|
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
|
||||||
|
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
|
||||||
defaultCartaoId={card.id}
|
defaultCartaoId={card.id}
|
||||||
defaultPaymentMethod="Cartão de crédito"
|
defaultPaymentMethod="Cartão de crédito"
|
||||||
lockCartaoSelection
|
lockCartaoSelection
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data";
|
||||||
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
||||||
import { CategoryDetailHeader } from "@/components/categorias/category-detail-header";
|
import { CategoryDetailHeader } from "@/components/categorias/category-detail-header";
|
||||||
import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page";
|
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 periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||||
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
|
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
|
||||||
|
|
||||||
const [detail, filterSources, estabelecimentos] = await Promise.all([
|
const [detail, filterSources, estabelecimentos, userPreferences] = await Promise.all([
|
||||||
fetchCategoryDetails(userId, categoryId, selectedPeriod),
|
fetchCategoryDetails(userId, categoryId, selectedPeriod),
|
||||||
fetchLancamentoFilterSources(userId),
|
fetchLancamentoFilterSources(userId),
|
||||||
getRecentEstablishmentsAction(),
|
getRecentEstablishmentsAction(),
|
||||||
|
fetchUserPreferences(userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!detail) {
|
if (!detail) {
|
||||||
@@ -92,6 +94,8 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
selectedPeriod={detail.period}
|
selectedPeriod={detail.period}
|
||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
allowCreate={true}
|
allowCreate={true}
|
||||||
|
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
|
||||||
|
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { RiPencilLine } from "@remixicon/react";
|
import { RiPencilLine } from "@remixicon/react";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data";
|
||||||
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
||||||
import { AccountDialog } from "@/components/contas/account-dialog";
|
import { AccountDialog } from "@/components/contas/account-dialog";
|
||||||
import { AccountStatementCard } from "@/components/contas/account-statement-card";
|
import { AccountStatementCard } from "@/components/contas/account-statement-card";
|
||||||
@@ -57,12 +58,13 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const [filterSources, logoOptions, accountSummary, estabelecimentos] =
|
const [filterSources, logoOptions, accountSummary, estabelecimentos, userPreferences] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchLancamentoFilterSources(userId),
|
fetchLancamentoFilterSources(userId),
|
||||||
loadLogoOptions(),
|
loadLogoOptions(),
|
||||||
fetchAccountSummary(userId, contaId, selectedPeriod),
|
fetchAccountSummary(userId, contaId, selectedPeriod),
|
||||||
getRecentEstablishmentsAction(),
|
getRecentEstablishmentsAction(),
|
||||||
|
fetchUserPreferences(userId),
|
||||||
]);
|
]);
|
||||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||||
const slugMaps = buildSlugMaps(sluggedFilters);
|
const slugMaps = buildSlugMaps(sluggedFilters);
|
||||||
@@ -161,6 +163,8 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
selectedPeriod={selectedPeriod}
|
selectedPeriod={selectedPeriod}
|
||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
allowCreate={false}
|
allowCreate={false}
|
||||||
|
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
|
||||||
|
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ import { noteSchema, uuidSchema } from "@/lib/schemas/common";
|
|||||||
import {
|
import {
|
||||||
TRANSFER_CATEGORY_NAME,
|
TRANSFER_CATEGORY_NAME,
|
||||||
TRANSFER_CONDITION,
|
TRANSFER_CONDITION,
|
||||||
TRANSFER_ESTABLISHMENT,
|
TRANSFER_ESTABLISHMENT_ENTRADA,
|
||||||
|
TRANSFER_ESTABLISHMENT_SAIDA,
|
||||||
TRANSFER_PAYMENT_METHOD,
|
TRANSFER_PAYMENT_METHOD,
|
||||||
} from "@/lib/transferencias/constants";
|
} from "@/lib/transferencias/constants";
|
||||||
import { formatDecimalForDbRequired } from "@/lib/utils/currency";
|
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)
|
// Create outgoing transaction (transfer from source account)
|
||||||
await tx.insert(lancamentos).values({
|
await tx.insert(lancamentos).values({
|
||||||
condition: TRANSFER_CONDITION,
|
condition: TRANSFER_CONDITION,
|
||||||
name: `${TRANSFER_ESTABLISHMENT} → ${toAccount.name}`,
|
name: TRANSFER_ESTABLISHMENT_SAIDA,
|
||||||
paymentMethod: TRANSFER_PAYMENT_METHOD,
|
paymentMethod: TRANSFER_PAYMENT_METHOD,
|
||||||
note: `Transferência para ${toAccount.name}`,
|
note: transferNote,
|
||||||
amount: formatDecimalForDbRequired(-Math.abs(data.amount)),
|
amount: formatDecimalForDbRequired(-Math.abs(data.amount)),
|
||||||
purchaseDate: data.date,
|
purchaseDate: data.date,
|
||||||
transactionType: "Transferência",
|
transactionType: "Transferência",
|
||||||
@@ -362,9 +365,9 @@ export async function transferBetweenAccountsAction(
|
|||||||
// Create incoming transaction (transfer to destination account)
|
// Create incoming transaction (transfer to destination account)
|
||||||
await tx.insert(lancamentos).values({
|
await tx.insert(lancamentos).values({
|
||||||
condition: TRANSFER_CONDITION,
|
condition: TRANSFER_CONDITION,
|
||||||
name: `${TRANSFER_ESTABLISHMENT} ← ${fromAccount.name}`,
|
name: TRANSFER_ESTABLISHMENT_ENTRADA,
|
||||||
paymentMethod: TRANSFER_PAYMENT_METHOD,
|
paymentMethod: TRANSFER_PAYMENT_METHOD,
|
||||||
note: `Transferência de ${fromAccount.name}`,
|
note: transferNote,
|
||||||
amount: formatDecimalForDbRequired(Math.abs(data.amount)),
|
amount: formatDecimalForDbRequired(Math.abs(data.amount)),
|
||||||
purchaseDate: data.date,
|
purchaseDate: data.date,
|
||||||
transactionType: "Transferência",
|
transactionType: "Transferência",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data";
|
||||||
import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page";
|
import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page";
|
||||||
import MonthNavigation from "@/components/month-picker/month-navigation";
|
import MonthNavigation from "@/components/month-picker/month-navigation";
|
||||||
import { getUserId } from "@/lib/auth/server";
|
import { getUserId } from "@/lib/auth/server";
|
||||||
@@ -31,7 +32,10 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
|
|
||||||
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
|
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
|
||||||
|
|
||||||
const filterSources = await fetchLancamentoFilterSources(userId);
|
const [filterSources, userPreferences] = await Promise.all([
|
||||||
|
fetchLancamentoFilterSources(userId),
|
||||||
|
fetchUserPreferences(userId),
|
||||||
|
]);
|
||||||
|
|
||||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||||
const slugMaps = buildSlugMaps(sluggedFilters);
|
const slugMaps = buildSlugMaps(sluggedFilters);
|
||||||
@@ -80,6 +84,8 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
||||||
selectedPeriod={selectedPeriod}
|
selectedPeriod={selectedPeriod}
|
||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
|
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
|
||||||
|
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export default async function DashboardLayout({
|
|||||||
/>
|
/>
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<SiteHeader notificationsSnapshot={notificationsSnapshot} />
|
<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="@container/main flex flex-1 flex-col gap-2">
|
||||||
<div className="flex flex-col gap-4 py-4 md:gap-6">
|
<div className="flex flex-col gap-4 py-4 md:gap-6">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { lancamentos, pagadores } from "@/db/schema";
|
import { lancamentos, pagadores } from "@/db/schema";
|
||||||
|
import { getResendFromEmail } from "@/lib/email/resend";
|
||||||
import { getUser } from "@/lib/auth/server";
|
import { getUser } from "@/lib/auth/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import {
|
import {
|
||||||
@@ -418,8 +419,7 @@ export async function sendPagadorSummaryAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resendApiKey = process.env.RESEND_API_KEY;
|
const resendApiKey = process.env.RESEND_API_KEY;
|
||||||
const resendFrom =
|
const resendFrom = getResendFromEmail();
|
||||||
process.env.RESEND_FROM_EMAIL ?? "OpenMonetis <onboarding@resend.dev>";
|
|
||||||
|
|
||||||
if (!resendApiKey) {
|
if (!resendApiKey) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
RiWallet3Line,
|
RiWallet3Line,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data";
|
||||||
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
||||||
import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
|
import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
|
||||||
import type {
|
import type {
|
||||||
@@ -168,6 +169,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
shareRows,
|
shareRows,
|
||||||
currentUserShare,
|
currentUserShare,
|
||||||
estabelecimentos,
|
estabelecimentos,
|
||||||
|
userPreferences,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
fetchPagadorLancamentos(filters),
|
fetchPagadorLancamentos(filters),
|
||||||
fetchPagadorMonthlyBreakdown({
|
fetchPagadorMonthlyBreakdown({
|
||||||
@@ -203,6 +205,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
sharesPromise,
|
sharesPromise,
|
||||||
currentUserSharePromise,
|
currentUserSharePromise,
|
||||||
getRecentEstablishmentsAction(),
|
getRecentEstablishmentsAction(),
|
||||||
|
fetchUserPreferences(userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const mappedLancamentos = mapLancamentosData(lancamentoRows);
|
const mappedLancamentos = mapLancamentosData(lancamentoRows);
|
||||||
@@ -381,6 +384,8 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
selectedPeriod={selectedPeriod}
|
selectedPeriod={selectedPeriod}
|
||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
allowCreate={canEdit}
|
allowCreate={canEdit}
|
||||||
|
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
|
||||||
|
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
|
||||||
importPagadorOptions={loggedUserOptionSets?.pagadorOptions}
|
importPagadorOptions={loggedUserOptionSets?.pagadorOptions}
|
||||||
importSplitPagadorOptions={
|
importSplitPagadorOptions={
|
||||||
loggedUserOptionSets?.splitPagadorOptions
|
loggedUserOptionSets?.splitPagadorOptions
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import type { ChangelogVersion } from "@/lib/changelog/parse-changelog";
|
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<
|
const sectionBadgeVariant: Record<
|
||||||
string,
|
string,
|
||||||
"success" | "info" | "destructive" | "secondary"
|
"success" | "info" | "destructive" | "secondary"
|
||||||
@@ -46,6 +56,29 @@ export function ChangelogTab({ versions }: { versions: ChangelogVersion[] }) {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
"use client";
|
"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 { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -15,16 +27,58 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
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";
|
import { FONT_OPTIONS, getFontVariable } from "@/public/fonts/font_index";
|
||||||
|
|
||||||
interface PreferencesFormProps {
|
interface PreferencesFormProps {
|
||||||
disableMagnetlines: boolean;
|
disableMagnetlines: boolean;
|
||||||
|
extratoNoteAsColumn: boolean;
|
||||||
|
lancamentosColumnOrder: string[] | null;
|
||||||
systemFont: string;
|
systemFont: string;
|
||||||
moneyFont: 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({
|
export function PreferencesForm({
|
||||||
disableMagnetlines,
|
disableMagnetlines,
|
||||||
|
extratoNoteAsColumn: initialExtratoNoteAsColumn,
|
||||||
|
lancamentosColumnOrder: initialColumnOrder,
|
||||||
systemFont: initialSystemFont,
|
systemFont: initialSystemFont,
|
||||||
moneyFont: initialMoneyFont,
|
moneyFont: initialMoneyFont,
|
||||||
}: PreferencesFormProps) {
|
}: PreferencesFormProps) {
|
||||||
@@ -32,10 +86,33 @@ export function PreferencesForm({
|
|||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [magnetlinesDisabled, setMagnetlinesDisabled] =
|
const [magnetlinesDisabled, setMagnetlinesDisabled] =
|
||||||
useState(disableMagnetlines);
|
useState(disableMagnetlines);
|
||||||
|
const [extratoNoteAsColumn, setExtratoNoteAsColumn] =
|
||||||
|
useState(initialExtratoNoteAsColumn);
|
||||||
|
const [columnOrder, setColumnOrder] = useState<string[]>(
|
||||||
|
initialColumnOrder && initialColumnOrder.length > 0
|
||||||
|
? initialColumnOrder
|
||||||
|
: DEFAULT_LANCAMENTOS_COLUMN_ORDER,
|
||||||
|
);
|
||||||
const [selectedSystemFont, setSelectedSystemFont] =
|
const [selectedSystemFont, setSelectedSystemFont] =
|
||||||
useState(initialSystemFont);
|
useState(initialSystemFont);
|
||||||
const [selectedMoneyFont, setSelectedMoneyFont] = useState(initialMoneyFont);
|
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();
|
const fontCtx = useFont();
|
||||||
|
|
||||||
// Live preview: update CSS vars when font selection changes
|
// Live preview: update CSS vars when font selection changes
|
||||||
@@ -53,6 +130,8 @@ export function PreferencesForm({
|
|||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await updatePreferencesAction({
|
const result = await updatePreferencesAction({
|
||||||
disableMagnetlines: magnetlinesDisabled,
|
disableMagnetlines: magnetlinesDisabled,
|
||||||
|
extratoNoteAsColumn,
|
||||||
|
lancamentosColumnOrder: columnOrder,
|
||||||
systemFont: selectedSystemFont,
|
systemFont: selectedSystemFont,
|
||||||
moneyFont: selectedMoneyFont,
|
moneyFont: selectedMoneyFont,
|
||||||
});
|
});
|
||||||
@@ -148,7 +227,59 @@ export function PreferencesForm({
|
|||||||
|
|
||||||
<div className="border-b" />
|
<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">
|
<section className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-semibold">Dashboard</h3>
|
<h3 className="text-base font-semibold">Dashboard</h3>
|
||||||
|
|||||||
@@ -126,7 +126,10 @@ export function CardDialog({
|
|||||||
currentName: formState.name,
|
currentName: formState.name,
|
||||||
onUpdate: (updates) => {
|
onUpdate: (updates) => {
|
||||||
updateFields(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.";
|
: "Atualize as informações do cartão selecionado.";
|
||||||
const submitLabel = mode === "create" ? "Salvar cartão" : "Atualizar cartão";
|
const submitLabel = mode === "create" ? "Salvar cartão" : "Atualizar cartão";
|
||||||
|
|
||||||
|
const handleMainDialogOpenChange = useCallback(
|
||||||
|
(open: boolean) => {
|
||||||
|
if (!open && logoDialogOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDialogOpen(open);
|
||||||
|
},
|
||||||
|
[logoDialogOpen, setDialogOpen],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={handleMainDialogOpenChange}>
|
||||||
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||||
<DialogContent className="">
|
<DialogContent
|
||||||
|
className=""
|
||||||
|
onPointerDownOutside={(e) => {
|
||||||
|
if (logoDialogOpen) e.preventDefault();
|
||||||
|
}}
|
||||||
|
onInteractOutside={(e) => {
|
||||||
|
if (logoDialogOpen) e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
|||||||
@@ -152,7 +152,10 @@ export function AccountDialog({
|
|||||||
currentName: formState.name,
|
currentName: formState.name,
|
||||||
onUpdate: (updates) => {
|
onUpdate: (updates) => {
|
||||||
updateFields(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.";
|
: "Atualize as informações da conta selecionada.";
|
||||||
const submitLabel = mode === "create" ? "Salvar conta" : "Atualizar conta";
|
const submitLabel = mode === "create" ? "Salvar conta" : "Atualizar conta";
|
||||||
|
|
||||||
|
const handleMainDialogOpenChange = useCallback(
|
||||||
|
(open: boolean) => {
|
||||||
|
if (!open && logoDialogOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDialogOpen(open);
|
||||||
|
},
|
||||||
|
[logoDialogOpen, setDialogOpen],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={handleMainDialogOpenChange}>
|
||||||
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
{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>
|
<DialogHeader>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AnimatedThemeToggler } from "./animated-theme-toggler";
|
|||||||
import LogoutButton from "./auth/logout-button";
|
import LogoutButton from "./auth/logout-button";
|
||||||
import { CalculatorDialogButton } from "./calculadora/calculator-dialog";
|
import { CalculatorDialogButton } from "./calculadora/calculator-dialog";
|
||||||
import { PrivacyModeToggle } from "./privacy-mode-toggle";
|
import { PrivacyModeToggle } from "./privacy-mode-toggle";
|
||||||
|
import { RefreshPageButton } from "./refresh-page-button";
|
||||||
|
|
||||||
type SiteHeaderProps = {
|
type SiteHeaderProps = {
|
||||||
notificationsSnapshot: DashboardNotificationsSnapshot;
|
notificationsSnapshot: DashboardNotificationsSnapshot;
|
||||||
@@ -16,7 +17,7 @@ export async function SiteHeader({ notificationsSnapshot }: SiteHeaderProps) {
|
|||||||
const _user = await getUser();
|
const _user = await getUser();
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
||||||
<SidebarTrigger className="-ml-1" />
|
<SidebarTrigger className="-ml-1" />
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
@@ -25,6 +26,7 @@ export async function SiteHeader({ notificationsSnapshot }: SiteHeaderProps) {
|
|||||||
totalCount={notificationsSnapshot.totalCount}
|
totalCount={notificationsSnapshot.totalCount}
|
||||||
/>
|
/>
|
||||||
<CalculatorDialogButton withTooltip />
|
<CalculatorDialogButton withTooltip />
|
||||||
|
<RefreshPageButton />
|
||||||
<PrivacyModeToggle />
|
<PrivacyModeToggle />
|
||||||
<AnimatedThemeToggler />
|
<AnimatedThemeToggler />
|
||||||
<span className="text-muted-foreground">|</span>
|
<span className="text-muted-foreground">|</span>
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ interface LancamentosPageProps {
|
|||||||
selectedPeriod: string;
|
selectedPeriod: string;
|
||||||
estabelecimentos: string[];
|
estabelecimentos: string[];
|
||||||
allowCreate?: boolean;
|
allowCreate?: boolean;
|
||||||
|
noteAsColumn?: boolean;
|
||||||
|
columnOrder?: string[] | null;
|
||||||
defaultCartaoId?: string | null;
|
defaultCartaoId?: string | null;
|
||||||
defaultPaymentMethod?: string | null;
|
defaultPaymentMethod?: string | null;
|
||||||
lockCartaoSelection?: boolean;
|
lockCartaoSelection?: boolean;
|
||||||
@@ -76,6 +78,8 @@ export function LancamentosPage({
|
|||||||
selectedPeriod,
|
selectedPeriod,
|
||||||
estabelecimentos,
|
estabelecimentos,
|
||||||
allowCreate = true,
|
allowCreate = true,
|
||||||
|
noteAsColumn = false,
|
||||||
|
columnOrder = null,
|
||||||
defaultCartaoId,
|
defaultCartaoId,
|
||||||
defaultPaymentMethod,
|
defaultPaymentMethod,
|
||||||
lockCartaoSelection,
|
lockCartaoSelection,
|
||||||
@@ -377,6 +381,8 @@ export function LancamentosPage({
|
|||||||
<LancamentosTable
|
<LancamentosTable
|
||||||
data={lancamentos}
|
data={lancamentos}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
|
noteAsColumn={noteAsColumn}
|
||||||
|
columnOrder={columnOrder}
|
||||||
pagadorFilterOptions={pagadorFilterOptions}
|
pagadorFilterOptions={pagadorFilterOptions}
|
||||||
categoriaFilterOptions={categoriaFilterOptions}
|
categoriaFilterOptions={categoriaFilterOptions}
|
||||||
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function EstabelecimentoInput({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
estabelecimentos = [],
|
estabelecimentos = [],
|
||||||
placeholder = "Ex.: Padaria",
|
placeholder = "Ex.: Padaria, Transferência, Saldo inicial",
|
||||||
required = false,
|
required = false,
|
||||||
maxLength = 20,
|
maxLength = 20,
|
||||||
}: EstabelecimentoInputProps) {
|
}: EstabelecimentoInputProps) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
RiAddCircleFill,
|
RiAddCircleFill,
|
||||||
RiAddCircleLine,
|
RiAddCircleLine,
|
||||||
RiArrowLeftRightLine,
|
RiArrowLeftRightLine,
|
||||||
|
RiArrowRightSLine,
|
||||||
RiChat1Line,
|
RiChat1Line,
|
||||||
RiCheckLine,
|
RiCheckLine,
|
||||||
RiDeleteBin5Line,
|
RiDeleteBin5Line,
|
||||||
@@ -68,6 +69,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { DEFAULT_LANCAMENTOS_COLUMN_ORDER } from "@/lib/lancamentos/column-order";
|
||||||
import { getAvatarSrc } from "@/lib/pagadores/utils";
|
import { getAvatarSrc } from "@/lib/pagadores/utils";
|
||||||
import { formatDate } from "@/lib/utils/date";
|
import { formatDate } from "@/lib/utils/date";
|
||||||
import { getConditionIcon, getPaymentMethodIcon } from "@/lib/utils/icons";
|
import { getConditionIcon, getPaymentMethodIcon } from "@/lib/utils/icons";
|
||||||
@@ -92,6 +94,7 @@ const resolveLogoSrc = (logo: string | null) => {
|
|||||||
|
|
||||||
type BuildColumnsArgs = {
|
type BuildColumnsArgs = {
|
||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
|
noteAsColumn: boolean;
|
||||||
onEdit?: (item: LancamentoItem) => void;
|
onEdit?: (item: LancamentoItem) => void;
|
||||||
onCopy?: (item: LancamentoItem) => void;
|
onCopy?: (item: LancamentoItem) => void;
|
||||||
onImport?: (item: LancamentoItem) => void;
|
onImport?: (item: LancamentoItem) => void;
|
||||||
@@ -106,6 +109,7 @@ type BuildColumnsArgs = {
|
|||||||
|
|
||||||
const buildColumns = ({
|
const buildColumns = ({
|
||||||
currentUserId,
|
currentUserId,
|
||||||
|
noteAsColumn,
|
||||||
onEdit,
|
onEdit,
|
||||||
onCopy,
|
onCopy,
|
||||||
onImport,
|
onImport,
|
||||||
@@ -269,7 +273,7 @@ const buildColumns = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasNote ? (
|
{!noteAsColumn && hasNote ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="inline-flex rounded-full p-1 hover:bg-muted/60">
|
<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) {
|
if (showActions) {
|
||||||
columns.push({
|
columns.push({
|
||||||
id: "actions",
|
id: "actions",
|
||||||
@@ -645,9 +667,51 @@ const buildColumns = ({
|
|||||||
return columns;
|
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 = {
|
type LancamentosTableProps = {
|
||||||
data: LancamentoItem[];
|
data: LancamentoItem[];
|
||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
|
noteAsColumn?: boolean;
|
||||||
|
columnOrder?: string[] | null;
|
||||||
pagadorFilterOptions?: LancamentoFilterOption[];
|
pagadorFilterOptions?: LancamentoFilterOption[];
|
||||||
categoriaFilterOptions?: LancamentoFilterOption[];
|
categoriaFilterOptions?: LancamentoFilterOption[];
|
||||||
contaCartaoFilterOptions?: ContaCartaoFilterOption[];
|
contaCartaoFilterOptions?: ContaCartaoFilterOption[];
|
||||||
@@ -672,6 +736,8 @@ type LancamentosTableProps = {
|
|||||||
export function LancamentosTable({
|
export function LancamentosTable({
|
||||||
data,
|
data,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
|
noteAsColumn = false,
|
||||||
|
columnOrder: columnOrderPreference = null,
|
||||||
pagadorFilterOptions = [],
|
pagadorFilterOptions = [],
|
||||||
categoriaFilterOptions = [],
|
categoriaFilterOptions = [],
|
||||||
contaCartaoFilterOptions = [],
|
contaCartaoFilterOptions = [],
|
||||||
@@ -704,23 +770,10 @@ export function LancamentosTable({
|
|||||||
});
|
});
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(() => {
|
||||||
() =>
|
const built = buildColumns({
|
||||||
buildColumns({
|
|
||||||
currentUserId,
|
|
||||||
onEdit,
|
|
||||||
onCopy,
|
|
||||||
onImport,
|
|
||||||
onConfirmDelete,
|
|
||||||
onViewDetails,
|
|
||||||
onToggleSettlement,
|
|
||||||
onAnticipate,
|
|
||||||
onViewAnticipationHistory,
|
|
||||||
isSettlementLoading: isSettlementLoading ?? (() => false),
|
|
||||||
showActions,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
currentUserId,
|
currentUserId,
|
||||||
|
noteAsColumn,
|
||||||
onEdit,
|
onEdit,
|
||||||
onCopy,
|
onCopy,
|
||||||
onImport,
|
onImport,
|
||||||
@@ -729,10 +782,28 @@ export function LancamentosTable({
|
|||||||
onToggleSettlement,
|
onToggleSettlement,
|
||||||
onAnticipate,
|
onAnticipate,
|
||||||
onViewAnticipationHistory,
|
onViewAnticipationHistory,
|
||||||
isSettlementLoading,
|
isSettlementLoading: isSettlementLoading ?? (() => false),
|
||||||
showActions,
|
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({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
@@ -789,47 +860,57 @@ export function LancamentosTable({
|
|||||||
{showTopControls ? (
|
{showTopControls ? (
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
{onCreate || onMassAdd ? (
|
{onCreate || onMassAdd ? (
|
||||||
<div className="flex gap-2">
|
<div className="relative -mx-6 px-6 md:mx-0 md:px-0">
|
||||||
{onCreate ? (
|
<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">
|
||||||
<Button
|
{onCreate ? (
|
||||||
onClick={() => onCreate("Receita")}
|
<>
|
||||||
variant="outline"
|
<Button
|
||||||
className="w-full sm:w-auto"
|
onClick={() => onCreate("Receita")}
|
||||||
>
|
variant="outline"
|
||||||
<RiAddCircleLine className="size-4 text-success" />
|
className="h-8 shrink-0 px-3 text-xs sm:w-auto md:h-9 md:px-4 md:text-sm"
|
||||||
Nova Receita
|
>
|
||||||
</Button>
|
<RiAddCircleLine className="size-4 text-success" />
|
||||||
<Button
|
Nova Receita
|
||||||
onClick={() => onCreate("Despesa")}
|
</Button>
|
||||||
variant="outline"
|
<Button
|
||||||
className="w-full sm:w-auto"
|
onClick={() => onCreate("Despesa")}
|
||||||
>
|
variant="outline"
|
||||||
<RiAddCircleLine className="size-4 text-destructive" />
|
className="h-8 shrink-0 px-3 text-xs sm:w-auto md:h-9 md:px-4 md:text-sm"
|
||||||
Nova Despesa
|
>
|
||||||
</Button>
|
<RiAddCircleLine className="size-4 text-destructive" />
|
||||||
</>
|
Nova Despesa
|
||||||
) : null}
|
</Button>
|
||||||
{onMassAdd ? (
|
</>
|
||||||
<Tooltip>
|
) : null}
|
||||||
<TooltipTrigger asChild>
|
{onMassAdd ? (
|
||||||
<Button
|
<Tooltip>
|
||||||
onClick={onMassAdd}
|
<TooltipTrigger asChild>
|
||||||
variant="outline"
|
<Button
|
||||||
size="icon"
|
onClick={onMassAdd}
|
||||||
className="shrink-0"
|
variant="outline"
|
||||||
>
|
size="icon"
|
||||||
<RiAddCircleFill className="size-4" />
|
className="size-8 shrink-0 md:size-9"
|
||||||
<span className="sr-only">
|
>
|
||||||
Adicionar múltiplos lançamentos
|
<RiAddCircleFill className="size-4" />
|
||||||
</span>
|
<span className="sr-only">
|
||||||
</Button>
|
Adicionar múltiplos lançamentos
|
||||||
</TooltipTrigger>
|
</span>
|
||||||
<TooltipContent>
|
</Button>
|
||||||
<p>Adicionar múltiplos lançamentos</p>
|
</TooltipTrigger>
|
||||||
</TooltipContent>
|
<TooltipContent>
|
||||||
</Tooltip>
|
<p>Adicionar múltiplos lançamentos</p>
|
||||||
) : null}
|
</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className={showFilters ? "hidden sm:block" : ""} />
|
<span className={showFilters ? "hidden sm:block" : ""} />
|
||||||
|
|||||||
@@ -158,7 +158,13 @@ export function LogoPickerDialog({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={logo}
|
key={logo}
|
||||||
onClick={() => onSelect(logo)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
onSelect(logo);
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onTouchStart={(e) => e.stopPropagation()}
|
||||||
className={cn(
|
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",
|
"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 &&
|
isActive &&
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export default function MonthNavigation() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center gap-1">
|
||||||
<NavigationButton
|
<NavigationButton
|
||||||
direction="left"
|
direction="left"
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiAddCircleLine, RiFileCopyLine, RiFundsLine } from "@remixicon/react";
|
import {
|
||||||
|
RiAddCircleLine,
|
||||||
|
RiArrowRightSLine,
|
||||||
|
RiFileCopyLine,
|
||||||
|
RiFundsLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@@ -105,26 +110,41 @@ export function BudgetsPage({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
<div className="flex justify-start gap-4">
|
{/* No mobile: rolagem horizontal + seta + botões menores */}
|
||||||
<BudgetDialog
|
<div className="relative -mx-6 px-6 md:mx-0 md:px-0">
|
||||||
mode="create"
|
<div className="overflow-x-auto overflow-y-hidden scroll-smooth md:overflow-visible [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
categories={categories}
|
<div className="flex w-max shrink-0 justify-start gap-3 py-1 md:w-full md:gap-4 md:py-0">
|
||||||
defaultPeriod={selectedPeriod}
|
<BudgetDialog
|
||||||
trigger={
|
mode="create"
|
||||||
<Button disabled={categories.length === 0}>
|
categories={categories}
|
||||||
<RiAddCircleLine className="size-4" />
|
defaultPeriod={selectedPeriod}
|
||||||
Novo orçamento
|
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>
|
||||||
}
|
</div>
|
||||||
/>
|
</div>
|
||||||
<Button
|
<div
|
||||||
variant="outline"
|
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"
|
||||||
disabled={categories.length === 0}
|
aria-hidden
|
||||||
onClick={() => setDuplicateOpen(true)}
|
|
||||||
>
|
>
|
||||||
<RiFileCopyLine className="size-4" />
|
<RiArrowRightSLine className="size-5 shrink-0 text-muted-foreground" />
|
||||||
Copiar orçamentos do último mês
|
</div>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasBudgets ? (
|
{hasBudgets ? (
|
||||||
|
|||||||
56
components/refresh-page-button.tsx
Normal file
56
components/refresh-page-button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,24 +49,36 @@ function ChartContainer({
|
|||||||
}) {
|
}) {
|
||||||
const uniqueId = React.useId();
|
const uniqueId = React.useId();
|
||||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||||
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContext.Provider value={{ config }}>
|
<ChartContext.Provider value={{ config }}>
|
||||||
<div
|
<div
|
||||||
data-slot="chart"
|
data-slot="chart"
|
||||||
data-chart={chartId}
|
data-chart={chartId}
|
||||||
style={{ minWidth: 0, minHeight: 0, ...style }}
|
style={style}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChartStyle id={chartId} config={config} />
|
<ChartStyle id={chartId} config={config} />
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full min-h-[200px] min-w-[280px]">
|
||||||
<RechartsPrimitive.ResponsiveContainer width="100%" height="100%">
|
{mounted ? (
|
||||||
{children}
|
<RechartsPrimitive.ResponsiveContainer
|
||||||
</RechartsPrimitive.ResponsiveContainer>
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
minWidth={280}
|
||||||
|
minHeight={200}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ChartContext.Provider>
|
</ChartContext.Provider>
|
||||||
|
|||||||
@@ -107,8 +107,10 @@ export const preferenciasUsuario = pgTable("preferencias_usuario", {
|
|||||||
.unique()
|
.unique()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
disableMagnetlines: boolean("disable_magnetlines").notNull().default(false),
|
disableMagnetlines: boolean("disable_magnetlines").notNull().default(false),
|
||||||
|
extratoNoteAsColumn: boolean("extrato_note_as_column").notNull().default(false),
|
||||||
systemFont: text("system_font").notNull().default("ai-sans"),
|
systemFont: text("system_font").notNull().default("ai-sans"),
|
||||||
moneyFont: text("money_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<{
|
dashboardWidgets: jsonb("dashboard_widgets").$type<{
|
||||||
order: string[];
|
order: string[];
|
||||||
hidden: string[];
|
hidden: string[];
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER:-openmonetis}
|
POSTGRES_USER: ${POSTGRES_USER:-openmonetis}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db}
|
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
|
# Configurações de performance
|
||||||
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
|
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
|
||||||
|
|
||||||
@@ -91,7 +93,7 @@ services:
|
|||||||
|
|
||||||
# Configurações de email (se usar)
|
# Configurações de email (se usar)
|
||||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||||
EMAIL_FROM: ${EMAIL_FROM:-}
|
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
|
||||||
|
|
||||||
# Configurações de OAuth (se usar)
|
# Configurações de OAuth (se usar)
|
||||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
|
|||||||
1
drizzle/0017_add_extrato_note_as_column.sql
Normal file
1
drizzle/0017_add_extrato_note_as_column.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "preferencias_usuario" ADD COLUMN IF NOT EXISTS "extrato_note_as_column" boolean DEFAULT false NOT NULL;
|
||||||
1
drizzle/0018_add_lancamentos_column_order.sql
Normal file
1
drizzle/0018_add_lancamentos_column_order.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "preferencias_usuario" ADD COLUMN IF NOT EXISTS "lancamentos_column_order" jsonb;
|
||||||
@@ -25,6 +25,7 @@ export const revalidateConfig = {
|
|||||||
cartoes: ["/cartoes", "/contas", "/lancamentos"],
|
cartoes: ["/cartoes", "/contas", "/lancamentos"],
|
||||||
contas: ["/contas", "/lancamentos"],
|
contas: ["/contas", "/lancamentos"],
|
||||||
categorias: ["/categorias"],
|
categorias: ["/categorias"],
|
||||||
|
estabelecimentos: ["/estabelecimentos", "/lancamentos"],
|
||||||
orcamentos: ["/orcamentos"],
|
orcamentos: ["/orcamentos"],
|
||||||
pagadores: ["/pagadores"],
|
pagadores: ["/pagadores"],
|
||||||
anotacoes: ["/anotacoes", "/anotacoes/arquivadas"],
|
anotacoes: ["/anotacoes", "/anotacoes/arquivadas"],
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export type ChangelogVersion = {
|
|||||||
version: string;
|
version: string;
|
||||||
date: string;
|
date: string;
|
||||||
sections: ChangelogSection[];
|
sections: ChangelogSection[];
|
||||||
|
/** Linha de contribuições/autor (pode conter markdown, ex: [Nome](url)) */
|
||||||
|
contributor?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function parseChangelog(): ChangelogVersion[] {
|
export function parseChangelog(): ChangelogVersion[] {
|
||||||
@@ -49,6 +51,13 @@ export function parseChangelog(): ChangelogVersion[] {
|
|||||||
const itemMatch = line.match(/^- (.+)$/);
|
const itemMatch = line.match(/^- (.+)$/);
|
||||||
if (itemMatch && currentSection) {
|
if (itemMatch && currentSection) {
|
||||||
currentSection.items.push(itemMatch[1]);
|
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
16
lib/email/resend.ts
Normal 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;
|
||||||
|
}
|
||||||
33
lib/lancamentos/column-order.ts
Normal file
33
lib/lancamentos/column-order.ts
Normal 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,
|
||||||
|
];
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { inArray } from "drizzle-orm";
|
import { inArray } from "drizzle-orm";
|
||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
import { pagadores } from "@/db/schema";
|
import { pagadores } from "@/db/schema";
|
||||||
|
import { getResendFromEmail } from "@/lib/email/resend";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
type ActionType = "created" | "deleted";
|
type ActionType = "created" | "deleted";
|
||||||
@@ -118,8 +119,7 @@ export async function sendPagadorAutoEmails({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resendApiKey = process.env.RESEND_API_KEY;
|
const resendApiKey = process.env.RESEND_API_KEY;
|
||||||
const resendFrom =
|
const resendFrom = getResendFromEmail();
|
||||||
process.env.RESEND_FROM_EMAIL ?? "OpenMonetis <onboarding@resend.dev>";
|
|
||||||
|
|
||||||
if (!resendApiKey) {
|
if (!resendApiKey) {
|
||||||
console.warn(
|
console.warn(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export const TRANSFER_CATEGORY_NAME = "Transferência interna";
|
export const TRANSFER_CATEGORY_NAME = "Transferência interna";
|
||||||
export const TRANSFER_ESTABLISHMENT = "Transf. entre contas";
|
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_PAGADOR = "Admin";
|
||||||
export const TRANSFER_PAYMENT_METHOD = "Pix";
|
export const TRANSFER_PAYMENT_METHOD = "Pix";
|
||||||
export const TRANSFER_CONDITION = "À vista";
|
export const TRANSFER_CONDITION = "À vista";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "openmonetis",
|
"name": "openmonetis",
|
||||||
"version": "1.5.3",
|
"version": "1.6.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
|
|||||||
Reference in New Issue
Block a user