feat: implementar melhorias em importação, compartilhamento e contas inativas

- Corrigir cálculo de valor na importação de lançamentos parcelados
    - Exibir valor total (parcela × quantidade) ao invés do valor da parcela individual
    - Permite recriar parcelamentos importados com valor correto

  - Permitir que usuários compartilhados se descompartilhem de pagadores
    - Adicionar componente PagadorLeaveShareCard na aba Perfil
    - Usuário filho pode sair do compartilhamento sem precisar do usuário pai
    - Manter autorização bidirecionada na action de remoção de share

  - Implementar submenu "Inativos" para contas bancárias
    - Criar página /contas/inativos seguindo padrão de cartões
    - Filtrar contas ativas e inativas em páginas separadas
    - Adicionar ícone e navegação no sidebar
This commit is contained in:
Felipe Coutinho
2026-01-11 22:44:20 +00:00
parent 147857c5bd
commit 6a45a5110d
26 changed files with 812 additions and 405 deletions

View File

@@ -1,7 +1,7 @@
import { cartoes, contas, lancamentos } from "@/db/schema";
import { db } from "@/lib/db";
import { loadLogoOptions } from "@/lib/logo/options";
import { and, eq, isNull, or, sql } from "drizzle-orm";
import { and, eq, ilike, isNull, not, or, sql } from "drizzle-orm";
export type CardData = {
id: string;
@@ -33,7 +33,91 @@ export async function fetchCardsForUser(userId: string): Promise<{
const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([
db.query.cartoes.findMany({
orderBy: (card: typeof cartoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(card.name)],
where: eq(cartoes.userId, userId),
where: and(eq(cartoes.userId, userId), not(ilike(cartoes.status, "inativo"))),
with: {
conta: {
columns: {
id: true,
name: true,
},
},
},
}),
db.query.contas.findMany({
orderBy: (account: typeof contas.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(account.name)],
where: eq(contas.userId, userId),
columns: {
id: true,
name: true,
logo: true,
},
}),
loadLogoOptions(),
db
.select({
cartaoId: lancamentos.cartaoId,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false))
)
)
.groupBy(lancamentos.cartaoId),
]);
const usageMap = new Map<string, number>();
usageRows.forEach((row: { cartaoId: string | null; total: number | null }) => {
if (!row.cartaoId) return;
usageMap.set(row.cartaoId, Number(row.total ?? 0));
});
const cards = cardRows.map((card) => ({
id: card.id,
name: card.name,
brand: card.brand,
status: card.status,
closingDay: card.closingDay,
dueDay: card.dueDay,
note: card.note,
logo: card.logo,
limit: card.limit ? Number(card.limit) : null,
limitInUse: (() => {
const total = usageMap.get(card.id) ?? 0;
return total < 0 ? Math.abs(total) : 0;
})(),
limitAvailable: (() => {
if (!card.limit) {
return null;
}
const total = usageMap.get(card.id) ?? 0;
const inUse = total < 0 ? Math.abs(total) : 0;
return Math.max(Number(card.limit) - inUse, 0);
})(),
contaId: card.contaId,
contaName: card.conta?.name ?? "Conta não encontrada",
}));
const accounts = accountRows.map((account) => ({
id: account.id,
name: account.name,
logo: account.logo,
}));
return { cards, accounts, logoOptions };
}
export async function fetchInativosForUser(userId: string): Promise<{
cards: CardData[];
accounts: AccountSimple[];
logoOptions: LogoOption[];
}> {
const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([
db.query.cartoes.findMany({
orderBy: (card: typeof cartoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(card.name)],
where: and(eq(cartoes.userId, userId), ilike(cartoes.status, "inativo")),
with: {
conta: {
columns: {

View File

@@ -0,0 +1,19 @@
import { CardsPage } from "@/components/cartoes/cards-page";
import { getUserId } from "@/lib/auth/server";
import { fetchInativosForUser } from "../data";
export default async function InativosPage() {
const userId = await getUserId();
const { cards, accounts, logoOptions } = await fetchInativosForUser(userId);
return (
<main className="flex flex-col items-start gap-6">
<CardsPage
cards={cards}
accounts={accounts}
logoOptions={logoOptions}
isInativos={true}
/>
</main>
);
}

View File

@@ -1,5 +1,5 @@
import PageDescription from "@/components/page-description";
import { RiBankCardLine } from "@remixicon/react";
import { RiBankCard2Line } from "@remixicon/react";
export const metadata = {
title: "Cartões | Opensheets",
@@ -13,7 +13,7 @@ export default function RootLayout({
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiBankCardLine />}
icon={<RiBankCard2Line />}
title="Cartões"
subtitle="Acompanhe todas os cartões do mês selecionado incluindo faturas, limites
e transações previstas. Use o seletor abaixo para navegar pelos meses e

View File

@@ -3,7 +3,7 @@ import { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { loadLogoOptions } from "@/lib/logo/options";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { and, eq, sql } from "drizzle-orm";
import { and, eq, ilike, not, sql } from "drizzle-orm";
export type AccountData = {
id: string;
@@ -58,6 +58,83 @@ export async function fetchAccountsForUser(
.where(
and(
eq(contas.userId, userId),
not(ilike(contas.status, "inativa")),
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`
)
)
.groupBy(
contas.id,
contas.name,
contas.accountType,
contas.status,
contas.note,
contas.logo,
contas.initialBalance,
contas.excludeFromBalance,
contas.excludeInitialBalanceFromIncome
),
loadLogoOptions(),
]);
const accounts = accountRows.map((account) => ({
id: account.id,
name: account.name,
accountType: account.accountType,
status: account.status,
note: account.note,
logo: account.logo,
initialBalance: Number(account.initialBalance ?? 0),
balance:
Number(account.initialBalance ?? 0) +
Number(account.balanceMovements ?? 0),
excludeFromBalance: account.excludeFromBalance,
excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome,
}));
return { accounts, logoOptions };
}
export async function fetchInativosForUser(
userId: string
): Promise<{ accounts: AccountData[]; logoOptions: LogoOption[] }> {
const [accountRows, logoOptions] = await Promise.all([
db
.select({
id: contas.id,
name: contas.name,
accountType: contas.accountType,
status: contas.status,
note: contas.note,
logo: contas.logo,
initialBalance: contas.initialBalance,
excludeFromBalance: contas.excludeFromBalance,
excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome,
balanceMovements: sql<number>`
coalesce(
sum(
case
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
else ${lancamentos.amount}
end
),
0
)
`,
})
.from(contas)
.leftJoin(
lancamentos,
and(
eq(lancamentos.contaId, contas.id),
eq(lancamentos.userId, userId),
eq(lancamentos.isSettled, true)
)
)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(contas.userId, userId),
ilike(contas.status, "inativa"),
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`
)
)

View File

@@ -0,0 +1,14 @@
import { AccountsPage } from "@/components/contas/accounts-page";
import { getUserId } from "@/lib/auth/server";
import { fetchInativosForUser } from "../data";
export default async function InativosPage() {
const userId = await getUserId();
const { accounts, logoOptions } = await fetchInativosForUser(userId);
return (
<main className="flex flex-col items-start gap-6">
<AccountsPage accounts={accounts} logoOptions={logoOptions} isInativos={true} />
</main>
);
}

View File

@@ -33,14 +33,24 @@ export const PROVIDERS = {
* Lista de modelos de IA disponíveis para análise de insights
*/
export const AVAILABLE_MODELS = [
// OpenAI Models (5)
{ id: "gpt-5.1", name: "GPT-5.1 ", provider: "openai" as const },
{ id: "gpt-5.1-chat", name: "GPT-5.1 Chat", provider: "openai" as const },
{ id: "gpt-5", name: "GPT-5", provider: "openai" as const },
{ id: "gpt-5-mini", name: "GPT-5 Mini", provider: "openai" as const },
{ id: "gpt-5-nano", name: "GPT-5 Nano", provider: "openai" as const },
// OpenAI Models - GPT-5.2 Family (Latest)
{ id: "gpt-5.2", name: "GPT-5.2", provider: "openai" as const },
{
id: "gpt-5.2-instant",
name: "GPT-5.2 Instant",
provider: "openai" as const,
},
{
id: "gpt-5.2-thinking",
name: "GPT-5.2 Thinking",
provider: "openai" as const,
},
// Anthropic Models (5)
// OpenAI Models - GPT-5 Family
{ id: "gpt-5", name: "GPT-5", provider: "openai" as const },
{ id: "gpt-5-instant", name: "GPT-5 Instant", provider: "openai" as const },
// Anthropic Models - Claude 4.5
{
id: "claude-4.5-haiku",
name: "Claude 4.5 Haiku",
@@ -57,20 +67,27 @@ export const AVAILABLE_MODELS = [
provider: "anthropic" as const,
},
// Google Models (5)
// Google Models - Gemini 3 (Latest)
{
id: "gemini-2.5-pro",
name: "Gemini 2.5 Pro",
id: "gemini-3-flash-preview",
name: "Gemini 3 Flash",
provider: "google" as const,
},
{
id: "gemini-2.5-flash",
name: "Gemini 2.5 Flash",
id: "gemini-3-pro-preview",
name: "Gemini 3 Pro",
provider: "google" as const,
},
// Google Models - Gemini 2.0
{
id: "gemini-2.0-flash",
name: "Gemini 2.0 Flash",
provider: "google" as const,
},
] as const;
export const DEFAULT_MODEL = "gpt-5.1";
export const DEFAULT_MODEL = "gpt-5.2";
export const DEFAULT_PROVIDER = "openai";
/**

View File

@@ -1,4 +1,4 @@
import { lancamentos, pagadorShares, user as usersTable } from "@/db/schema";
import { lancamentos, pagadorShares, user as usersTable, contas, cartoes, categorias, pagadores } from "@/db/schema";
import { db } from "@/lib/db";
import { and, desc, eq, type SQL } from "drizzle-orm";
@@ -37,17 +37,54 @@ export async function fetchPagadorShares(
}));
}
export async function fetchPagadorLancamentos(filters: SQL[]) {
const lancamentoRows = await db.query.lancamentos.findMany({
where: and(...filters),
with: {
pagador: true,
conta: true,
cartao: true,
categoria: true,
export async function fetchCurrentUserShare(
pagadorId: string,
userId: string
): Promise<{ id: string; createdAt: string } | null> {
const shareRow = await db.query.pagadorShares.findFirst({
columns: {
id: true,
createdAt: true,
},
orderBy: desc(lancamentos.purchaseDate),
where: and(
eq(pagadorShares.pagadorId, pagadorId),
eq(pagadorShares.sharedWithUserId, userId)
),
});
return lancamentoRows;
if (!shareRow) {
return null;
}
return {
id: shareRow.id,
createdAt: shareRow.createdAt?.toISOString() ?? new Date().toISOString(),
};
}
export async function fetchPagadorLancamentos(filters: SQL[]) {
const lancamentoRows = await db
.select({
lancamento: lancamentos,
pagador: pagadores,
conta: contas,
cartao: cartoes,
categoria: categorias,
})
.from(lancamentos)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(and(...filters))
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt));
// Transformar resultado para o formato esperado
return lancamentoRows.map((row: any) => ({
...row.lancamento,
pagador: row.pagador,
conta: row.conta,
cartao: row.cartao,
categoria: row.categoria,
}));
}

View File

@@ -5,6 +5,7 @@ import { PagadorInfoCard } from "@/components/pagadores/details/pagador-info-car
import { PagadorMonthlySummaryCard } from "@/components/pagadores/details/pagador-monthly-summary-card";
import { PagadorBoletoCard } from "@/components/pagadores/details/pagador-payment-method-cards";
import { PagadorSharingCard } from "@/components/pagadores/details/pagador-sharing-card";
import { PagadorLeaveShareCard } from "@/components/pagadores/details/pagador-leave-share-card";
import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
import type {
ContaCartaoFilterOption,
@@ -39,7 +40,7 @@ import {
fetchPagadorMonthlyBreakdown,
} from "@/lib/pagadores/details";
import { notFound } from "next/navigation";
import { fetchPagadorLancamentos, fetchPagadorShares } from "./data";
import { fetchPagadorLancamentos, fetchPagadorShares, fetchCurrentUserShare } from "./data";
type PageSearchParams = Promise<ResolvedSearchParams>;
@@ -92,9 +93,13 @@ export default async function Page({ params, searchParams }: PageProps) {
} = parsePeriodParam(periodoParamRaw);
const periodLabel = `${capitalize(monthName)} de ${year}`;
const allSearchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
const searchFilters = canEdit
? extractLancamentoSearchFilters(resolvedSearchParams)
: EMPTY_FILTERS;
? allSearchFilters
: {
...EMPTY_FILTERS,
searchFilter: allSearchFilters.searchFilter, // Permitir busca mesmo em modo read-only
};
let filterSources: Awaited<
ReturnType<typeof fetchLancamentoFilterSources>
@@ -133,6 +138,10 @@ export default async function Page({ params, searchParams }: PageProps) {
? fetchPagadorShares(pagador.id)
: Promise.resolve([]);
const currentUserSharePromise = !canEdit
? fetchCurrentUserShare(pagador.id, userId)
: Promise.resolve(null);
const [
lancamentoRows,
monthlyBreakdown,
@@ -140,6 +149,7 @@ export default async function Page({ params, searchParams }: PageProps) {
cardUsage,
boletoStats,
shareRows,
currentUserShare,
estabelecimentos,
] = await Promise.all([
fetchPagadorLancamentos(filters),
@@ -164,6 +174,7 @@ export default async function Page({ params, searchParams }: PageProps) {
period: selectedPeriod,
}),
sharesPromise,
currentUserSharePromise,
getRecentEstablishmentsAction(),
]);
@@ -281,6 +292,13 @@ export default async function Page({ params, searchParams }: PageProps) {
shares={pagadorSharesData}
/>
) : null}
{!canEdit && currentUserShare ? (
<PagadorLeaveShareCard
shareId={currentUserShare.id}
pagadorName={pagadorData.name}
createdAt={currentUserShare.createdAt}
/>
) : null}
</TabsContent>
<TabsContent value="painel" className="space-y-4">

View File

@@ -6,7 +6,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { getOptionalUserSession } from "@/lib/auth/server";
import {
RiArrowRightSLine,
RiBankCardLine,
RiBankCard2Line,
RiBarChartBoxLine,
RiCalendarLine,
RiCodeSSlashLine,
@@ -15,7 +15,6 @@ import {
RiGithubFill,
RiLineChartLine,
RiLockLine,
RiMoneyDollarCircleLine,
RiPieChartLine,
RiShieldCheckLine,
RiTimeLine,
@@ -234,9 +233,9 @@ export default async function Page() {
Parcelamentos avançados
</h3>
<p className="text-sm text-muted-foreground">
Controle completo de compras parceladas. Antecipe parcelas
com cálculo automático de desconto. Veja análise
consolidada de todas as parcelas em aberto.
Controle completo de compras parceladas. Antecipe
parcelas com cálculo automático de desconto. Veja
análise consolidada de todas as parcelas em aberto.
</p>
</div>
</div>
@@ -254,9 +253,9 @@ export default async function Page() {
Insights com IA
</h3>
<p className="text-sm text-muted-foreground">
Análises financeiras geradas por IA (Claude, GPT, Gemini).
Insights personalizados sobre seus padrões de gastos e
recomendações inteligentes.
Análises financeiras geradas por IA (Claude, GPT,
Gemini). Insights personalizados sobre seus padrões de
gastos e recomendações inteligentes.
</p>
</div>
</div>
@@ -287,16 +286,16 @@ export default async function Page() {
<CardContent className="pt-6">
<div className="flex flex-col gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<RiBankCardLine size={24} className="text-primary" />
<RiBankCard2Line size={24} className="text-primary" />
</div>
<div>
<h3 className="font-semibold text-lg mb-2">
Faturas de cartão
</h3>
<p className="text-sm text-muted-foreground">
Cadastre seus cartões e acompanhe as faturas por período.
Veja o que ainda não foi fechado. Controle limites,
vencimentos e fechamentos.
Cadastre seus cartões e acompanhe as faturas por
período. Veja o que ainda não foi fechado. Controle
limites, vencimentos e fechamentos.
</p>
</div>
</div>
@@ -315,8 +314,8 @@ export default async function Page() {
</h3>
<p className="text-sm text-muted-foreground">
Compartilhe pagadores com permissões granulares (admin/
viewer). Notificações automáticas por e-mail. Colabore em
lançamentos compartilhados.
viewer). Notificações automáticas por e-mail. Colabore
em lançamentos compartilhados.
</p>
</div>
</div>
@@ -334,9 +333,9 @@ export default async function Page() {
Categorias e orçamentos
</h3>
<p className="text-sm text-muted-foreground">
Crie categorias personalizadas. Defina orçamentos mensais
e acompanhe o quanto gastou vs. planejado com indicadores
visuais.
Crie categorias personalizadas. Defina orçamentos
mensais e acompanhe o quanto gastou vs. planejado com
indicadores visuais.
</p>
</div>
</div>
@@ -394,9 +393,9 @@ export default async function Page() {
Importação em massa
</h3>
<p className="text-sm text-muted-foreground">
Cole múltiplos lançamentos de uma vez. Economize tempo ao
registrar várias transações. Formatação inteligente para
facilitar a entrada de dados.
Cole múltiplos lançamentos de uma vez. Economize tempo
ao registrar várias transações. Formatação inteligente
para facilitar a entrada de dados.
</p>
</div>
</div>
@@ -434,9 +433,9 @@ export default async function Page() {
Performance otimizada
</h3>
<p className="text-sm text-muted-foreground">
Dashboard carrega em ~200-500ms com 18+ queries paralelas.
Índices otimizados. Type-safe em toda codebase. Isolamento
completo de dados por usuário.
Dashboard carrega em ~200-500ms com 18+ queries
paralelas. Índices otimizados. Type-safe em toda
codebase. Isolamento completo de dados por usuário.
</p>
</div>
</div>