forked from git.gladyson/openmonetis
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:
@@ -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: {
|
||||
|
||||
19
app/(dashboard)/cartoes/inativos/page.tsx
Normal file
19
app/(dashboard)/cartoes/inativos/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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})`
|
||||
)
|
||||
)
|
||||
|
||||
14
app/(dashboard)/contas/inativos/page.tsx
Normal file
14
app/(dashboard)/contas/inativos/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user