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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user