diff --git a/.env.example b/.env.example index 5e631fe..0a5e0b7 100644 --- a/.env.example +++ b/.env.example @@ -13,9 +13,6 @@ POSTGRES_USER=opensheets POSTGRES_PASSWORD=opensheets_dev_password POSTGRES_DB=opensheets_db -# Provider: "local" para Docker, "remote" para Supabase/Neon/etc -DB_PROVIDER=local - # === Better Auth === # Gere com: openssl rand -base64 32 BETTER_AUTH_SECRET=your-secret-key-here-change-this diff --git a/README.md b/README.md index 26439fe..ab09e06 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,6 @@ Esta é a **melhor opção para desenvolvedores** que vão modificar o código. ```env # Banco de dados (usando Docker) DATABASE_URL=postgresql://opensheets:opensheets_dev_password@localhost:5432/opensheets_db - DB_PROVIDER=local # Better Auth (gere com: openssl rand -base64 32) BETTER_AUTH_SECRET=seu-secret-aqui @@ -319,7 +318,6 @@ Ideal para quem quer apenas **usar a aplicação** sem mexer no código. ```env # Use o host "db" (nome do serviço Docker) DATABASE_URL=postgresql://opensheets:opensheets_dev_password@db:5432/opensheets_db - DB_PROVIDER=local # Better Auth BETTER_AUTH_SECRET=seu-secret-aqui @@ -365,7 +363,6 @@ Se você já tem PostgreSQL no **Supabase**, **Neon**, **Railway**, etc. ```env DATABASE_URL=postgresql://user:password@host.region.provider.com:5432/database?sslmode=require - DB_PROVIDER=remote BETTER_AUTH_SECRET=seu-secret-aqui BETTER_AUTH_URL=http://localhost:3000 @@ -586,7 +583,6 @@ Copie o `.env.example` para `.env` e configure: ```env # === Database === DATABASE_URL=postgresql://opensheets:opensheets_dev_password@localhost:5432/opensheets_db -DB_PROVIDER=local # ou "remote" # === Better Auth === # Gere com: openssl rand -base64 32 @@ -653,10 +649,10 @@ pnpm env:setup ### Escolhendo entre Local e Remoto -| Modo | Quando usar | Como configurar | -| ---------- | ------------------------------------- | -------------------------------------- | -| **Local** | Desenvolvimento, testes, prototipagem | `DB_PROVIDER=local` + Docker | -| **Remoto** | Produção, deploy, banco gerenciado | `DB_PROVIDER=remote` + URL do provider | +| Modo | Quando usar | Como configurar | +| ---------- | ------------------------------------- | --------------------------------------------- | +| **Local** | Desenvolvimento, testes, prototipagem | `DATABASE_URL` com host "db" ou "localhost" | +| **Remoto** | Produção, deploy, banco gerenciado | `DATABASE_URL` com URL completa do provider | ### Drizzle ORM diff --git a/app/(dashboard)/ajustes/actions.ts b/app/(dashboard)/ajustes/actions.ts index a29f1fe..6685938 100644 --- a/app/(dashboard)/ajustes/actions.ts +++ b/app/(dashboard)/ajustes/actions.ts @@ -48,6 +48,20 @@ const deleteAccountSchema = z.object({ }), }); +const updatePreferencesSchema = z.object({ + disableMagnetlines: z.boolean(), + periodMonthsBefore: z + .number() + .int("Deve ser um número inteiro") + .min(1, "Mínimo de 1 mês") + .max(24, "Máximo de 24 meses"), + periodMonthsAfter: z + .number() + .int("Deve ser um número inteiro") + .min(1, "Mínimo de 1 mês") + .max(24, "Máximo de 24 meses"), +}); + // Actions export async function updateNameAction( @@ -327,3 +341,73 @@ export async function deleteAccountAction( }; } } + +export async function updatePreferencesAction( + data: z.infer +): Promise { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user?.id) { + return { + success: false, + error: "Não autenticado", + }; + } + + const validated = updatePreferencesSchema.parse(data); + + // Check if preferences exist, if not create them + const existingResult = await db + .select() + .from(schema.userPreferences) + .where(eq(schema.userPreferences.userId, session.user.id)) + .limit(1); + + const existing = existingResult[0] || null; + + if (existing) { + // Update existing preferences + await db + .update(schema.userPreferences) + .set({ + disableMagnetlines: validated.disableMagnetlines, + periodMonthsBefore: validated.periodMonthsBefore, + periodMonthsAfter: validated.periodMonthsAfter, + updatedAt: new Date(), + }) + .where(eq(schema.userPreferences.userId, session.user.id)); + } else { + // Create new preferences + await db.insert(schema.userPreferences).values({ + userId: session.user.id, + disableMagnetlines: validated.disableMagnetlines, + periodMonthsBefore: validated.periodMonthsBefore, + periodMonthsAfter: validated.periodMonthsAfter, + }); + } + + // Revalidar o layout do dashboard + revalidatePath("/", "layout"); + + return { + success: true, + message: "Preferências atualizadas com sucesso", + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: error.issues[0]?.message || "Dados inválidos", + }; + } + + console.error("Erro ao atualizar preferências:", error); + return { + success: false, + error: "Erro ao atualizar preferências. Tente novamente.", + }; + } +} diff --git a/app/(dashboard)/ajustes/page.tsx b/app/(dashboard)/ajustes/page.tsx index ad5d0d2..5ed1925 100644 --- a/app/(dashboard)/ajustes/page.tsx +++ b/app/(dashboard)/ajustes/page.tsx @@ -2,6 +2,7 @@ import { DeleteAccountForm } from "@/components/ajustes/delete-account-form"; import { UpdateEmailForm } from "@/components/ajustes/update-email-form"; import { UpdateNameForm } from "@/components/ajustes/update-name-form"; import { UpdatePasswordForm } from "@/components/ajustes/update-password-form"; +import { PreferencesForm } from "@/components/ajustes/preferences-form"; import { Card } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { auth } from "@/lib/auth/config"; @@ -27,14 +28,28 @@ export default async function Page() { where: eq(schema.account.userId, session.user.id), }); + // Buscar preferências do usuário + const userPreferencesResult = await db + .select({ + disableMagnetlines: schema.userPreferences.disableMagnetlines, + periodMonthsBefore: schema.userPreferences.periodMonthsBefore, + periodMonthsAfter: schema.userPreferences.periodMonthsAfter, + }) + .from(schema.userPreferences) + .where(eq(schema.userPreferences.userId, session.user.id)) + .limit(1); + + const userPreferences = userPreferencesResult[0] || null; + // Se o providerId for "google", o usuário usa Google OAuth const authProvider = userAccount?.providerId || "credential"; return ( -
- - - Altere seu nome +
+ + + Preferências + Alterar nome Alterar senha Alterar e-mail @@ -42,56 +57,92 @@ export default async function Page() { - - -
-

Alterar nome

-

- Atualize como seu nome aparece no Opensheets. Esse nome pode ser - exibido em diferentes seções do app e em comunicações. -

+ + +
+
+

Preferências

+

+ Personalize sua experiência no Opensheets ajustando as + configurações de acordo com suas necessidades. +

+
+
- -
+ + - -
-

Alterar senha

-

- Defina uma nova senha para sua conta. Guarde-a em local seguro. -

+ + +
+
+

Alterar nome

+

+ Atualize como seu nome aparece no Opensheets. Esse nome pode + ser exibido em diferentes seções do app e em comunicações. +

+
+
- -
+ + - -
-

Alterar e-mail

-

- Atualize o e-mail associado à sua conta. Você precisará - confirmar os links enviados para o novo e também para o e-mail - atual (quando aplicável) para concluir a alteração. -

+ + +
+
+

Alterar senha

+

+ Defina uma nova senha para sua conta. Guarde-a em local + seguro. +

+
+
- -
+ + - -
-

- Deletar conta -

-

- Ao prosseguir, sua conta e todos os dados associados serão - excluídos de forma irreversível. -

+ + +
+
+

Alterar e-mail

+

+ Atualize o e-mail associado à sua conta. Você precisará + confirmar os links enviados para o novo e também para o e-mail + atual (quando aplicável) para concluir a alteração. +

+
+
- -
- + + + + + +
+
+

+ Deletar conta +

+

+ Ao prosseguir, sua conta e todos os dados associados serão + excluídos de forma irreversível. +

+
+ +
+
+
); diff --git a/app/(dashboard)/calendario/data.ts b/app/(dashboard)/calendario/data.ts index 2a0e5f7..88b532f 100644 --- a/app/(dashboard)/calendario/data.ts +++ b/app/(dashboard)/calendario/data.ts @@ -7,6 +7,7 @@ import { fetchLancamentoFilterSources, mapLancamentosData, } from "@/lib/lancamentos/page-helpers"; +import { fetchUserPeriodPreferences } from "@/lib/user-preferences/period"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; import { and, eq, gte, lte, ne, or } from "drizzle-orm"; @@ -59,42 +60,44 @@ export const fetchCalendarData = async ({ const rangeStartKey = toDateKey(rangeStart); const rangeEndKey = toDateKey(rangeEnd); - const [lancamentoRows, cardRows, filterSources] = await Promise.all([ - db.query.lancamentos.findMany({ - where: and( - eq(lancamentos.userId, userId), - ne(lancamentos.transactionType, TRANSACTION_TYPE_TRANSFERENCIA), - or( - // Lançamentos cuja data de compra esteja no período do calendário - and( - gte(lancamentos.purchaseDate, rangeStart), - lte(lancamentos.purchaseDate, rangeEnd) - ), - // Boletos cuja data de vencimento esteja no período do calendário - and( - eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO), - gte(lancamentos.dueDate, rangeStart), - lte(lancamentos.dueDate, rangeEnd) - ), - // Lançamentos de cartão do período (para calcular totais de vencimento) - and( - eq(lancamentos.period, period), - ne(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO) + const [lancamentoRows, cardRows, filterSources, periodPreferences] = + await Promise.all([ + db.query.lancamentos.findMany({ + where: and( + eq(lancamentos.userId, userId), + ne(lancamentos.transactionType, TRANSACTION_TYPE_TRANSFERENCIA), + or( + // Lançamentos cuja data de compra esteja no período do calendário + and( + gte(lancamentos.purchaseDate, rangeStart), + lte(lancamentos.purchaseDate, rangeEnd) + ), + // Boletos cuja data de vencimento esteja no período do calendário + and( + eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO), + gte(lancamentos.dueDate, rangeStart), + lte(lancamentos.dueDate, rangeEnd) + ), + // Lançamentos de cartão do período (para calcular totais de vencimento) + and( + eq(lancamentos.period, period), + ne(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO) + ) ) - ) - ), - with: { - pagador: true, - conta: true, - cartao: true, - categoria: true, - }, - }), - db.query.cartoes.findMany({ - where: eq(cartoes.userId, userId), - }), - fetchLancamentoFilterSources(userId), - ]); + ), + with: { + pagador: true, + conta: true, + cartao: true, + categoria: true, + }, + }), + db.query.cartoes.findMany({ + where: eq(cartoes.userId, userId), + }), + fetchLancamentoFilterSources(userId), + fetchUserPeriodPreferences(userId), + ]); const lancamentosData = mapLancamentosData(lancamentoRows); const events: CalendarEvent[] = []; @@ -214,6 +217,7 @@ export const fetchCalendarData = async ({ cartaoOptions: optionSets.cartaoOptions, categoriaOptions: optionSets.categoriaOptions, estabelecimentos, + periodPreferences, }, }; }; diff --git a/app/(dashboard)/cartoes/[cartaoId]/fatura/page.tsx b/app/(dashboard)/cartoes/[cartaoId]/fatura/page.tsx index 56fa0c5..b17de4d 100644 --- a/app/(dashboard)/cartoes/[cartaoId]/fatura/page.tsx +++ b/app/(dashboard)/cartoes/[cartaoId]/fatura/page.tsx @@ -1,3 +1,4 @@ +import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions"; import { CardDialog } from "@/components/cartoes/card-dialog"; import type { Card } from "@/components/cartoes/types"; import { InvoiceSummaryCard } from "@/components/faturas/invoice-summary-card"; @@ -19,6 +20,7 @@ import { type ResolvedSearchParams, } from "@/lib/lancamentos/page-helpers"; import { loadLogoOptions } from "@/lib/logo/options"; +import { fetchUserPeriodPreferences } from "@/lib/user-preferences/period"; import { parsePeriodParam } from "@/lib/utils/period"; import { RiPencilLine } from "@remixicon/react"; import { and, desc } from "drizzle-orm"; @@ -52,10 +54,18 @@ export default async function Page({ params, searchParams }: PageProps) { notFound(); } - const [filterSources, logoOptions, invoiceData] = await Promise.all([ + const [ + filterSources, + logoOptions, + invoiceData, + estabelecimentos, + periodPreferences, + ] = await Promise.all([ fetchLancamentoFilterSources(userId), loadLogoOptions(), fetchInvoiceData(userId, cartaoId, selectedPeriod), + getRecentEstablishmentsAction(), + fetchUserPeriodPreferences(userId), ]); const sluggedFilters = buildSluggedFilters(filterSources); const slugMaps = buildSlugMaps(sluggedFilters); @@ -187,6 +197,8 @@ export default async function Page({ params, searchParams }: PageProps) { categoriaFilterOptions={categoriaFilterOptions} contaCartaoFilterOptions={contaCartaoFilterOptions} selectedPeriod={selectedPeriod} + estabelecimentos={estabelecimentos} + periodPreferences={periodPreferences} allowCreate defaultCartaoId={card.id} defaultPaymentMethod="Cartão de crédito" diff --git a/app/(dashboard)/categorias/[categoryId]/page.tsx b/app/(dashboard)/categorias/[categoryId]/page.tsx index d99e722..4e2e1c6 100644 --- a/app/(dashboard)/categorias/[categoryId]/page.tsx +++ b/app/(dashboard)/categorias/[categoryId]/page.tsx @@ -9,7 +9,8 @@ import { buildSluggedFilters, fetchLancamentoFilterSources, } from "@/lib/lancamentos/page-helpers"; -import { parsePeriodParam } from "@/lib/utils/period"; +import { fetchUserPeriodPreferences } from "@/lib/user-preferences/period"; +import { displayPeriod, parsePeriodParam } from "@/lib/utils/period"; import { notFound } from "next/navigation"; type PageSearchParams = Promise>; @@ -28,24 +29,6 @@ const getSingleParam = ( return Array.isArray(value) ? value[0] ?? null : value; }; -const formatPeriodLabel = (period: string) => { - const [yearStr, monthStr] = period.split("-"); - const year = Number.parseInt(yearStr ?? "", 10); - const monthIndex = Number.parseInt(monthStr ?? "", 10) - 1; - - if (Number.isNaN(year) || Number.isNaN(monthIndex) || monthIndex < 0) { - return period; - } - - const date = new Date(year, monthIndex, 1); - const label = date.toLocaleDateString("pt-BR", { - month: "long", - year: "numeric", - }); - - return label.charAt(0).toUpperCase() + label.slice(1); -}; - export default async function Page({ params, searchParams }: PageProps) { const { categoryId } = await params; const userId = await getUserId(); @@ -54,10 +37,11 @@ export default async function Page({ params, searchParams }: PageProps) { const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); const { period: selectedPeriod } = parsePeriodParam(periodoParam); - const [detail, filterSources, estabelecimentos] = await Promise.all([ + const [detail, filterSources, estabelecimentos, periodPreferences] = await Promise.all([ fetchCategoryDetails(userId, categoryId, selectedPeriod), fetchLancamentoFilterSources(userId), getRecentEstablishmentsAction(), + fetchUserPeriodPreferences(userId), ]); if (!detail) { @@ -80,8 +64,8 @@ export default async function Page({ params, searchParams }: PageProps) { pagadorRows: filterSources.pagadorRows, }); - const currentPeriodLabel = formatPeriodLabel(detail.period); - const previousPeriodLabel = formatPeriodLabel(detail.previousPeriod); + const currentPeriodLabel = displayPeriod(detail.period); + const previousPeriodLabel = displayPeriod(detail.previousPeriod); return (
@@ -108,6 +92,7 @@ export default async function Page({ params, searchParams }: PageProps) { contaCartaoFilterOptions={contaCartaoFilterOptions} selectedPeriod={detail.period} estabelecimentos={estabelecimentos} + periodPreferences={periodPreferences} allowCreate={true} />
diff --git a/app/(dashboard)/changelog/layout.tsx b/app/(dashboard)/changelog/layout.tsx new file mode 100644 index 0000000..07c7d03 --- /dev/null +++ b/app/(dashboard)/changelog/layout.tsx @@ -0,0 +1,23 @@ +import PageDescription from "@/components/page-description"; +import { RiGitCommitLine } from "@remixicon/react"; + +export const metadata = { + title: "Changelog | Opensheets", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ } + title="Changelog" + subtitle="Histórico completo de alterações e atualizações do projeto." + /> + {children} +
+ ); +} diff --git a/app/(dashboard)/changelog/loading.tsx b/app/(dashboard)/changelog/loading.tsx new file mode 100644 index 0000000..13d5185 --- /dev/null +++ b/app/(dashboard)/changelog/loading.tsx @@ -0,0 +1,31 @@ +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+
+ + +
+ +
+ {[...Array(5)].map((_, i) => ( + + + +
+ + + +
+
+ + + +
+ ))} +
+
+ ); +} diff --git a/app/(dashboard)/changelog/page.tsx b/app/(dashboard)/changelog/page.tsx new file mode 100644 index 0000000..16ec8b3 --- /dev/null +++ b/app/(dashboard)/changelog/page.tsx @@ -0,0 +1,102 @@ +import { ChangelogList } from "@/components/changelog/changelog-list"; +import { execSync } from "child_process"; + +type GitCommit = { + hash: string; + shortHash: string; + author: string; + date: string; + message: string; + body: string; + filesChanged: string[]; +}; + +function getGitRemoteUrl(): string | null { + try { + const remoteUrl = execSync("git config --get remote.origin.url", { + encoding: "utf-8", + cwd: process.cwd(), + }).trim(); + + // Converter SSH para HTTPS se necessário + if (remoteUrl.startsWith("git@")) { + return remoteUrl + .replace("git@github.com:", "https://github.com/") + .replace("git@gitlab.com:", "https://gitlab.com/") + .replace(".git", ""); + } + + return remoteUrl.replace(".git", ""); + } catch (error) { + console.error("Error fetching git remote URL:", error); + return null; + } +} + +function getGitCommits(): GitCommit[] { + try { + // Buscar os últimos 50 commits + const commits = execSync( + 'git log -50 --pretty=format:"%H|%h|%an|%ad|%s|%b" --date=iso --name-only', + { + encoding: "utf-8", + cwd: process.cwd(), + } + ) + .trim() + .split("\n\n"); + + return commits + .map((commitBlock) => { + const lines = commitBlock.split("\n"); + const [hash, shortHash, author, date, message, ...rest] = + lines[0].split("|"); + + // Separar body e arquivos + const bodyLines: string[] = []; + const filesChanged: string[] = []; + let isBody = true; + + rest.forEach((line) => { + if (line && !line.includes("/") && !line.includes(".")) { + bodyLines.push(line); + } else { + isBody = false; + } + }); + + lines.slice(1).forEach((line) => { + if (line.trim()) { + filesChanged.push(line.trim()); + } + }); + + return { + hash, + shortHash, + author, + date, + message, + body: bodyLines.join("\n").trim(), + filesChanged: filesChanged.filter( + (f) => f && !f.startsWith("git log") + ), + }; + }) + .filter((commit) => commit.hash && commit.message); + } catch (error) { + console.error("Error fetching git commits:", error); + return []; + } +} + +export default async function ChangelogPage() { + const commits = getGitCommits(); + const repoUrl = getGitRemoteUrl(); + + return ( +
+ +
+ ); +} diff --git a/app/(dashboard)/contas/[contaId]/extrato/page.tsx b/app/(dashboard)/contas/[contaId]/extrato/page.tsx index fe013eb..5bde654 100644 --- a/app/(dashboard)/contas/[contaId]/extrato/page.tsx +++ b/app/(dashboard)/contas/[contaId]/extrato/page.tsx @@ -1,3 +1,4 @@ +import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions"; import { AccountDialog } from "@/components/contas/account-dialog"; import { AccountStatementCard } from "@/components/contas/account-statement-card"; import type { Account } from "@/components/contas/types"; @@ -19,6 +20,7 @@ import { type ResolvedSearchParams, } from "@/lib/lancamentos/page-helpers"; import { loadLogoOptions } from "@/lib/logo/options"; +import { fetchUserPeriodPreferences } from "@/lib/user-preferences/period"; import { parsePeriodParam } from "@/lib/utils/period"; import { RiPencilLine } from "@remixicon/react"; import { and, desc, eq } from "drizzle-orm"; @@ -55,10 +57,18 @@ export default async function Page({ params, searchParams }: PageProps) { notFound(); } - const [filterSources, logoOptions, accountSummary] = await Promise.all([ + const [ + filterSources, + logoOptions, + accountSummary, + estabelecimentos, + periodPreferences, + ] = await Promise.all([ fetchLancamentoFilterSources(userId), loadLogoOptions(), fetchAccountSummary(userId, contaId, selectedPeriod), + getRecentEstablishmentsAction(), + fetchUserPeriodPreferences(userId), ]); const sluggedFilters = buildSluggedFilters(filterSources); const slugMaps = buildSlugMaps(sluggedFilters); @@ -165,6 +175,8 @@ export default async function Page({ params, searchParams }: PageProps) { categoriaFilterOptions={categoriaFilterOptions} contaCartaoFilterOptions={contaCartaoFilterOptions} selectedPeriod={selectedPeriod} + estabelecimentos={estabelecimentos} + periodPreferences={periodPreferences} allowCreate={false} /> diff --git a/app/(dashboard)/contas/actions.ts b/app/(dashboard)/contas/actions.ts index 98df16e..3d86786 100644 --- a/app/(dashboard)/contas/actions.ts +++ b/app/(dashboard)/contas/actions.ts @@ -56,6 +56,9 @@ const accountBaseSchema = z.object({ excludeFromBalance: z .union([z.boolean(), z.string()]) .transform((value) => value === true || value === "true"), + excludeInitialBalanceFromIncome: z + .union([z.boolean(), z.string()]) + .transform((value) => value === true || value === "true"), }); const createAccountSchema = accountBaseSchema; @@ -93,6 +96,7 @@ export async function createAccountAction( logo: logoFile, initialBalance: formatDecimalForDbRequired(data.initialBalance), excludeFromBalance: data.excludeFromBalance, + excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome, userId: user.id, }) .returning({ id: contas.id, name: contas.name }); @@ -183,6 +187,7 @@ export async function updateAccountAction( logo: logoFile, initialBalance: formatDecimalForDbRequired(data.initialBalance), excludeFromBalance: data.excludeFromBalance, + excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome, }) .where(and(eq(contas.id, data.id), eq(contas.userId, user.id))) .returning(); diff --git a/app/(dashboard)/contas/data.ts b/app/(dashboard)/contas/data.ts index 62a3631..dae8477 100644 --- a/app/(dashboard)/contas/data.ts +++ b/app/(dashboard)/contas/data.ts @@ -15,6 +15,7 @@ export type AccountData = { initialBalance: number; balance: number; excludeFromBalance: boolean; + excludeInitialBalanceFromIncome: boolean; }; export async function fetchAccountsForUser( @@ -31,6 +32,7 @@ export async function fetchAccountsForUser( logo: contas.logo, initialBalance: contas.initialBalance, excludeFromBalance: contas.excludeFromBalance, + excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome, balanceMovements: sql` coalesce( sum( @@ -67,7 +69,8 @@ export async function fetchAccountsForUser( contas.note, contas.logo, contas.initialBalance, - contas.excludeFromBalance + contas.excludeFromBalance, + contas.excludeInitialBalanceFromIncome ), loadLogoOptions(), ]); @@ -84,6 +87,7 @@ export async function fetchAccountsForUser( Number(account.initialBalance ?? 0) + Number(account.balanceMovements ?? 0), excludeFromBalance: account.excludeFromBalance, + excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome, })); return { accounts, logoOptions }; diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index c6b555f..5d36a35 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -5,6 +5,8 @@ import MonthPicker from "@/components/month-picker/month-picker"; import { getUser } from "@/lib/auth/server"; import { fetchDashboardData } from "@/lib/dashboard/fetch-dashboard-data"; import { parsePeriodParam } from "@/lib/utils/period"; +import { db, schema } from "@/lib/db"; +import { eq } from "drizzle-orm"; type PageSearchParams = Promise>; @@ -29,9 +31,20 @@ export default async function Page({ searchParams }: PageProps) { const data = await fetchDashboardData(user.id, selectedPeriod); + // Buscar preferências do usuário + const preferencesResult = await db + .select({ + disableMagnetlines: schema.userPreferences.disableMagnetlines, + }) + .from(schema.userPreferences) + .where(eq(schema.userPreferences.userId, user.id)) + .limit(1); + + const disableMagnetlines = preferencesResult[0]?.disableMagnetlines ?? false; + return (
- + diff --git a/app/(dashboard)/lancamentos/data.ts b/app/(dashboard)/lancamentos/data.ts index d37d21b..20c3df8 100644 --- a/app/(dashboard)/lancamentos/data.ts +++ b/app/(dashboard)/lancamentos/data.ts @@ -1,18 +1,41 @@ -import { lancamentos } from "@/db/schema"; +import { lancamentos, contas, pagadores, cartoes, categorias } from "@/db/schema"; +import { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants"; import { db } from "@/lib/db"; -import { and, desc, type SQL } from "drizzle-orm"; +import { and, desc, eq, isNull, ne, or, type SQL } from "drizzle-orm"; export async function fetchLancamentos(filters: SQL[]) { - const lancamentoRows = await db.query.lancamentos.findMany({ - where: and(...filters), - with: { - pagador: true, - conta: true, - cartao: true, - categoria: true, - }, - orderBy: [desc(lancamentos.purchaseDate), desc(lancamentos.createdAt)], - }); + 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, + // Excluir saldos iniciais de contas que têm excludeInitialBalanceFromIncome = true + or( + ne(lancamentos.note, INITIAL_BALANCE_NOTE), + isNull(contas.excludeInitialBalanceFromIncome), + eq(contas.excludeInitialBalanceFromIncome, false) + ) + ) + ) + .orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt)); - return lancamentoRows; + // Transformar resultado para o formato esperado + return lancamentoRows.map((row) => ({ + ...row.lancamento, + pagador: row.pagador, + conta: row.conta, + cartao: row.cartao, + categoria: row.categoria, + })); } diff --git a/app/(dashboard)/lancamentos/page.tsx b/app/(dashboard)/lancamentos/page.tsx index 1f2ede2..00a6db9 100644 --- a/app/(dashboard)/lancamentos/page.tsx +++ b/app/(dashboard)/lancamentos/page.tsx @@ -12,6 +12,7 @@ import { mapLancamentosData, type ResolvedSearchParams, } from "@/lib/lancamentos/page-helpers"; +import { fetchUserPeriodPreferences } from "@/lib/user-preferences/period"; import { parsePeriodParam } from "@/lib/utils/period"; import { fetchLancamentos } from "./data"; import { getRecentEstablishmentsAction } from "./actions"; @@ -31,7 +32,11 @@ export default async function Page({ searchParams }: PageProps) { const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams); - const filterSources = await fetchLancamentoFilterSources(userId); + const [filterSources, periodPreferences] = await Promise.all([ + fetchLancamentoFilterSources(userId), + fetchUserPeriodPreferences(userId), + ]); + const sluggedFilters = buildSluggedFilters(filterSources); const slugMaps = buildSlugMaps(sluggedFilters); @@ -78,6 +83,7 @@ export default async function Page({ searchParams }: PageProps) { contaCartaoFilterOptions={contaCartaoFilterOptions} selectedPeriod={selectedPeriod} estabelecimentos={estabelecimentos} + periodPreferences={periodPreferences} />
); diff --git a/app/(dashboard)/orcamentos/page.tsx b/app/(dashboard)/orcamentos/page.tsx index 4ed9370..7466c1c 100644 --- a/app/(dashboard)/orcamentos/page.tsx +++ b/app/(dashboard)/orcamentos/page.tsx @@ -1,6 +1,7 @@ import MonthPicker from "@/components/month-picker/month-picker"; import { BudgetsPage } from "@/components/orcamentos/budgets-page"; import { getUserId } from "@/lib/auth/server"; +import { fetchUserPeriodPreferences } from "@/lib/user-preferences/period"; import { parsePeriodParam } from "@/lib/utils/period"; import { fetchBudgetsForUser } from "./data"; @@ -35,10 +36,10 @@ export default async function Page({ searchParams }: PageProps) { const periodLabel = `${capitalize(rawMonthName)} ${year}`; - const { budgets, categoriesOptions } = await fetchBudgetsForUser( - userId, - selectedPeriod - ); + const [{ budgets, categoriesOptions }, periodPreferences] = await Promise.all([ + fetchBudgetsForUser(userId, selectedPeriod), + fetchUserPeriodPreferences(userId), + ]); return (
@@ -48,6 +49,7 @@ export default async function Page({ searchParams }: PageProps) { categories={categoriesOptions} selectedPeriod={selectedPeriod} periodLabel={periodLabel} + periodPreferences={periodPreferences} />
); diff --git a/app/(dashboard)/pagadores/[pagadorId]/actions.ts b/app/(dashboard)/pagadores/[pagadorId]/actions.ts index 8523adb..8883678 100644 --- a/app/(dashboard)/pagadores/[pagadorId]/actions.ts +++ b/app/(dashboard)/pagadores/[pagadorId]/actions.ts @@ -9,6 +9,7 @@ import { fetchPagadorHistory, fetchPagadorMonthlyBreakdown, } from "@/lib/pagadores/details"; +import { displayPeriod } from "@/lib/utils/period"; import { and, desc, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { Resend } from "resend"; @@ -32,17 +33,6 @@ const formatCurrency = (value: number) => maximumFractionDigits: 2, }); -const formatPeriodLabel = (period: string) => { - const [yearStr, monthStr] = period.split("-"); - const year = Number.parseInt(yearStr, 10); - const month = Number.parseInt(monthStr, 10) - 1; - const date = new Date(year, month, 1); - return date.toLocaleDateString("pt-BR", { - month: "long", - year: "numeric", - }); -}; - const formatDate = (value: Date | null | undefined) => { if (!value) return "—"; return value.toLocaleDateString("pt-BR", { @@ -560,7 +550,7 @@ export async function sendPagadorSummaryAction( const html = buildSummaryHtml({ pagadorName: pagadorRow.name, - periodLabel: formatPeriodLabel(period), + periodLabel: displayPeriod(period), monthlyBreakdown, historyData, cardUsage, @@ -573,7 +563,7 @@ export async function sendPagadorSummaryAction( await resend.emails.send({ from: resendFrom, to: pagadorRow.email, - subject: `Resumo Financeiro | ${formatPeriodLabel(period)}`, + subject: `Resumo Financeiro | ${displayPeriod(period)}`, html, }); diff --git a/app/(dashboard)/pagadores/[pagadorId]/page.tsx b/app/(dashboard)/pagadores/[pagadorId]/page.tsx index d0f2d4d..7e3b03a 100644 --- a/app/(dashboard)/pagadores/[pagadorId]/page.tsx +++ b/app/(dashboard)/pagadores/[pagadorId]/page.tsx @@ -1,3 +1,4 @@ +import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions"; import { PagadorCardUsageCard } from "@/components/pagadores/details/pagador-card-usage-card"; import { PagadorHistoryCard } from "@/components/pagadores/details/pagador-history-card"; import { PagadorInfoCard } from "@/components/pagadores/details/pagador-info-card"; @@ -29,6 +30,7 @@ import { type SlugMaps, type SluggedFilters, } from "@/lib/lancamentos/page-helpers"; +import { fetchUserPeriodPreferences } from "@/lib/user-preferences/period"; import { parsePeriodParam } from "@/lib/utils/period"; import { getPagadorAccess } from "@/lib/pagadores/access"; import { @@ -134,6 +136,8 @@ export default async function Page({ params, searchParams }: PageProps) { cardUsage, boletoStats, shareRows, + estabelecimentos, + periodPreferences, ] = await Promise.all([ fetchPagadorLancamentos(filters), fetchPagadorMonthlyBreakdown({ @@ -157,6 +161,8 @@ export default async function Page({ params, searchParams }: PageProps) { period: selectedPeriod, }), sharesPromise, + getRecentEstablishmentsAction(), + fetchUserPeriodPreferences(dataOwnerId), ]); const mappedLancamentos = mapLancamentosData(lancamentoRows); @@ -289,6 +295,8 @@ export default async function Page({ params, searchParams }: PageProps) { categoriaFilterOptions={optionSets.categoriaFilterOptions} contaCartaoFilterOptions={optionSets.contaCartaoFilterOptions} selectedPeriod={selectedPeriod} + estabelecimentos={estabelecimentos} + periodPreferences={periodPreferences} allowCreate={canEdit} /> diff --git a/components/ajustes/delete-account-form.tsx b/components/ajustes/delete-account-form.tsx index 6d87be7..3406e29 100644 --- a/components/ajustes/delete-account-form.tsx +++ b/components/ajustes/delete-account-form.tsx @@ -1,5 +1,4 @@ "use client"; - import { deleteAccountAction } from "@/app/(dashboard)/ajustes/actions"; import { Button } from "@/components/ui/button"; import { @@ -13,7 +12,6 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { authClient } from "@/lib/auth/client"; -import { RiAlertLine } from "@remixicon/react"; import { useRouter } from "next/navigation"; import { useState, useTransition } from "react"; import { toast } from "sonner"; @@ -54,34 +52,29 @@ export function DeleteAccountForm() { return ( <> -
-
- -
-

- Remoção definitiva de conta -

-

- Ao prosseguir, sua conta e todos os dados associados serão - excluídos de forma irreversível. -

-
+
+
+
    +
  • Lançamentos, orçamentos e anotações
  • +
  • Contas, cartões e categorias
  • +
  • Pagadores (incluindo o pagador padrão)
  • +
  • Preferências e configurações
  • +
  • + Resumindo tudo, sua conta será permanentemente removida +
  • +
-
    -
  • Lançamentos, anexos e notas
  • -
  • Contas, cartões, orçamentos e categorias
  • -
  • Pagadores (incluindo o pagador padrão)
  • -
  • Preferências e configurações
  • -
- - +
+ +
diff --git a/components/ajustes/preferences-form.tsx b/components/ajustes/preferences-form.tsx new file mode 100644 index 0000000..5755fd9 --- /dev/null +++ b/components/ajustes/preferences-form.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { updatePreferencesAction } from "@/app/(dashboard)/ajustes/actions"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; + +interface PreferencesFormProps { + disableMagnetlines: boolean; + periodMonthsBefore: number; + periodMonthsAfter: number; +} + +export function PreferencesForm({ + disableMagnetlines, + periodMonthsBefore, + periodMonthsAfter, +}: PreferencesFormProps) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [magnetlinesDisabled, setMagnetlinesDisabled] = + useState(disableMagnetlines); + const [monthsBefore, setMonthsBefore] = useState(periodMonthsBefore); + const [monthsAfter, setMonthsAfter] = useState(periodMonthsAfter); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + startTransition(async () => { + const result = await updatePreferencesAction({ + disableMagnetlines: magnetlinesDisabled, + periodMonthsBefore: monthsBefore, + periodMonthsAfter: monthsAfter, + }); + + if (result.success) { + toast.success(result.message); + // Recarregar a página para aplicar as mudanças nos componentes + router.refresh(); + // Forçar reload completo para garantir que os hooks re-executem + setTimeout(() => { + window.location.reload(); + }, 500); + } else { + toast.error(result.error); + } + }); + }; + + return ( +
+
+
+
+ +

+ Remove o recurso de linhas magnéticas do sistema. Essa mudança + afeta a interface e interações visuais. +

+
+ +
+ +
+
+

+ Seleção de Período +

+

+ Configure quantos meses antes e depois do mês atual serão exibidos + nos seletores de período. +

+
+ +
+
+ + setMonthsBefore(Number(e.target.value))} + disabled={isPending} + className="w-full" + /> +

+ 1 a 24 meses +

+
+ +
+ + setMonthsAfter(Number(e.target.value))} + disabled={isPending} + className="w-full" + /> +

+ 1 a 24 meses +

+
+
+
+
+ +
+ +
+
+ ); +} diff --git a/components/ajustes/update-email-form.tsx b/components/ajustes/update-email-form.tsx index d81b2cc..2d14b22 100644 --- a/components/ajustes/update-email-form.tsx +++ b/components/ajustes/update-email-form.tsx @@ -68,148 +68,153 @@ export function UpdateEmailForm({ currentEmail, authProvider }: UpdateEmailFormP }; return ( -
- {/* E-mail atual (apenas informativo) */} -
- - -

- Este é seu e-mail atual cadastrado -

-
- - {/* Senha de confirmação (apenas para usuários com login por e-mail/senha) */} - {!isGoogleAuth && ( + +
+ {/* E-mail atual (apenas informativo) */}
-
+ + {/* Senha de confirmação (apenas para usuários com login por e-mail/senha) */} + {!isGoogleAuth && ( +
+ +
+ setPassword(e.target.value)} + disabled={isPending} + placeholder="Digite sua senha para confirmar" + required + aria-required="true" + aria-describedby="password-help" + /> + +
+

+ Por segurança, confirme sua senha antes de alterar seu e-mail +

+
+ )} + + {/* Novo e-mail */} +
+ + setNewEmail(e.target.value)} + disabled={isPending} + placeholder="Digite o novo e-mail" + required + aria-required="true" + aria-describedby="new-email-help" + aria-invalid={!isEmailDifferent} + className={!isEmailDifferent ? "border-red-500 focus-visible:ring-red-500" : ""} + /> + {!isEmailDifferent && newEmail && ( +

+ + O novo e-mail deve ser diferente do atual +

+ )} + {!newEmail && ( +

+ Digite o novo endereço de e-mail para sua conta +

+ )} +
+ + {/* Confirmar novo e-mail */} +
+
setPassword(e.target.value)} + id="confirmEmail" + type="email" + value={confirmEmail} + onChange={(e) => setConfirmEmail(e.target.value)} disabled={isPending} - placeholder="Digite sua senha para confirmar" + placeholder="Repita o novo e-mail" required aria-required="true" - aria-describedby="password-help" + aria-describedby="confirm-email-help" + aria-invalid={emailsMatch === false} + className={ + emailsMatch === false + ? "border-red-500 focus-visible:ring-red-500 pr-10" + : emailsMatch === true + ? "border-green-500 focus-visible:ring-green-500 pr-10" + : "" + } /> - + {/* Indicador visual de match */} + {emailsMatch !== null && ( +
+ {emailsMatch ? ( + + ) : ( + + )} +
+ )}
-

- Por segurança, confirme sua senha antes de alterar seu e-mail -

-
- )} - - {/* Novo e-mail */} -
- - setNewEmail(e.target.value)} - disabled={isPending} - placeholder="Digite o novo e-mail" - required - aria-required="true" - aria-describedby="new-email-help" - aria-invalid={!isEmailDifferent} - className={!isEmailDifferent ? "border-red-500 focus-visible:ring-red-500" : ""} - /> - {!isEmailDifferent && newEmail && ( -

- - O novo e-mail deve ser diferente do atual -

- )} - {!newEmail && ( -

- Digite o novo endereço de e-mail para sua conta -

- )} -
- - {/* Confirmar novo e-mail */} -
- -
- setConfirmEmail(e.target.value)} - disabled={isPending} - placeholder="Repita o novo e-mail" - required - aria-required="true" - aria-describedby="confirm-email-help" - aria-invalid={emailsMatch === false} - className={ - emailsMatch === false - ? "border-red-500 focus-visible:ring-red-500 pr-10" - : emailsMatch === true - ? "border-green-500 focus-visible:ring-green-500 pr-10" - : "" - } - /> - {/* Indicador visual de match */} - {emailsMatch !== null && ( -
- {emailsMatch ? ( - - ) : ( - - )} -
+ {/* Mensagem de erro em tempo real */} + {emailsMatch === false && ( + + )} + {emailsMatch === true && ( +

+ + Os e-mails coincidem +

)}
- {/* Mensagem de erro em tempo real */} - {emailsMatch === false && ( - - )} - {emailsMatch === true && ( -

- - Os e-mails coincidem -

- )}
- +
+ +
); } diff --git a/components/ajustes/update-name-form.tsx b/components/ajustes/update-name-form.tsx index 4fac3dd..cf9dabe 100644 --- a/components/ajustes/update-name-form.tsx +++ b/components/ajustes/update-name-form.tsx @@ -40,32 +40,36 @@ export function UpdateNameForm({ currentName }: UpdateNameFormProps) { }; return ( -
-
- - setFirstName(e.target.value)} - disabled={isPending} - required - /> + +
+
+ + setFirstName(e.target.value)} + disabled={isPending} + required + /> +
+ +
+ + setLastName(e.target.value)} + disabled={isPending} + required + /> +
-
- - setLastName(e.target.value)} - disabled={isPending} - required - /> +
+
- - ); } diff --git a/components/ajustes/update-password-form.tsx b/components/ajustes/update-password-form.tsx index 5bb3824..d4d3298 100644 --- a/components/ajustes/update-password-form.tsx +++ b/components/ajustes/update-password-form.tsx @@ -5,7 +5,13 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { cn } from "@/lib/utils/ui"; -import { RiEyeLine, RiEyeOffLine, RiCheckLine, RiCloseLine, RiAlertLine } from "@remixicon/react"; +import { + RiEyeLine, + RiEyeOffLine, + RiCheckLine, + RiCloseLine, + RiAlertLine, +} from "@remixicon/react"; import { useState, useTransition, useMemo } from "react"; import { toast } from "sonner"; @@ -44,13 +50,7 @@ function validatePassword(password: string): PasswordValidation { }; } -function PasswordRequirement({ - met, - label, -}: { - met: boolean; - label: string; -}) { +function PasswordRequirement({ met, label }: { met: boolean; label: string }) { return (
- +

Alteração de senha não disponível

- Você fez login usando sua conta do Google. A senha é gerenciada diretamente pelo Google - e não pode ser alterada aqui. Para modificar sua senha, acesse as configurações de - segurança da sua conta Google. + Você fez login usando sua conta do Google. A senha é gerenciada + diretamente pelo Google e não pode ser alterada aqui. Para + modificar sua senha, acesse as configurações de segurança da sua + conta Google.

@@ -150,173 +151,213 @@ export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) { } return ( -
- {/* Senha atual */} -
- -
- setCurrentPassword(e.target.value)} - disabled={isPending} - placeholder="Digite sua senha atual" - required - aria-required="true" - aria-describedby="current-password-help" - /> - -
-

- Por segurança, confirme sua senha atual antes de alterá-la -

-
- - {/* Nova senha */} -
- -
- setNewPassword(e.target.value)} - disabled={isPending} - placeholder="Crie uma senha forte" - required - minLength={7} - maxLength={23} - aria-required="true" - aria-describedby="new-password-help" - aria-invalid={newPassword.length > 0 && !passwordValidation.isValid} - /> - -
- {/* Indicadores de requisitos da senha */} - {newPassword.length > 0 && ( -
- +
+ {/* Senha atual */} +
+ +
+ setCurrentPassword(e.target.value)} + disabled={isPending} + placeholder="Digite sua senha atual" + required + aria-required="true" + aria-describedby="current-password-help" /> - - - - - -
- )} -
- - {/* Confirmar nova senha */} -
- -
- setConfirmPassword(e.target.value)} - disabled={isPending} - placeholder="Repita a senha" - required - minLength={6} - aria-required="true" - aria-describedby="confirm-password-help" - aria-invalid={passwordsMatch === false} - className={ - passwordsMatch === false - ? "border-red-500 focus-visible:ring-red-500" - : passwordsMatch === true - ? "border-green-500 focus-visible:ring-green-500" - : "" - } - /> - - {/* Indicador visual de match */} - {passwordsMatch !== null && ( -
- {passwordsMatch ? ( - + +
+

+ Por segurança, confirme sua senha atual antes de alterá-la +

+
+ + {/* Nova senha */} +
+ +
+ setNewPassword(e.target.value)} + disabled={isPending} + placeholder="Crie uma senha forte" + required + minLength={7} + maxLength={23} + aria-required="true" + aria-describedby="new-password-help" + aria-invalid={ + newPassword.length > 0 && !passwordValidation.isValid + } + /> + +
+ {/* Indicadores de requisitos da senha */} + {newPassword.length > 0 && ( +
+ + + + + +
)}
- {/* Mensagem de erro em tempo real */} - {passwordsMatch === false && ( - - )} - {passwordsMatch === true && ( -

- - As senhas coincidem -

- )} + + {/* Confirmar nova senha */} +
+ +
+ setConfirmPassword(e.target.value)} + disabled={isPending} + placeholder="Repita a senha" + required + minLength={6} + aria-required="true" + aria-describedby="confirm-password-help" + aria-invalid={passwordsMatch === false} + className={ + passwordsMatch === false + ? "border-red-500 focus-visible:ring-red-500" + : passwordsMatch === true + ? "border-green-500 focus-visible:ring-green-500" + : "" + } + /> + + {/* Indicador visual de match */} + {passwordsMatch !== null && ( +
+ {passwordsMatch ? ( + + ) : ( + + )} +
+ )} +
+ {/* Mensagem de erro em tempo real */} + {passwordsMatch === false && ( + + )} + {passwordsMatch === true && ( +

+ + As senhas coincidem +

+ )} +
- +
+ +
); } diff --git a/components/calendario/monthly-calendar.tsx b/components/calendario/monthly-calendar.tsx index 92b019f..2704050 100644 --- a/components/calendario/monthly-calendar.tsx +++ b/components/calendario/monthly-calendar.tsx @@ -118,6 +118,7 @@ export function MonthlyCalendar({ cartaoOptions={formOptions.cartaoOptions} categoriaOptions={formOptions.categoriaOptions} estabelecimentos={formOptions.estabelecimentos} + periodPreferences={formOptions.periodPreferences} defaultPeriod={period.period} defaultPurchaseDate={createDate ?? undefined} /> diff --git a/components/calendario/types.ts b/components/calendario/types.ts index c164071..fad5292 100644 --- a/components/calendario/types.ts +++ b/components/calendario/types.ts @@ -1,4 +1,5 @@ import type { LancamentoItem, SelectOption } from "@/components/lancamentos/types"; +import type { PeriodPreferences } from "@/lib/user-preferences/period"; export type CalendarEventType = "lancamento" | "boleto" | "cartao"; @@ -53,6 +54,7 @@ export type CalendarFormOptions = { cartaoOptions: SelectOption[]; categoriaOptions: SelectOption[]; estabelecimentos: string[]; + periodPreferences: PeriodPreferences; }; export type CalendarData = { diff --git a/components/changelog/changelog-list.tsx b/components/changelog/changelog-list.tsx new file mode 100644 index 0000000..4349ac8 --- /dev/null +++ b/components/changelog/changelog-list.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { formatDistanceToNow } from "date-fns"; +import { ptBR } from "date-fns/locale"; +import { + RiGitCommitLine, + RiUserLine, + RiCalendarLine, + RiFileList2Line, +} from "@remixicon/react"; + +type GitCommit = { + hash: string; + shortHash: string; + author: string; + date: string; + message: string; + body: string; + filesChanged: string[]; +}; + +type ChangelogListProps = { + commits: GitCommit[]; + repoUrl: string | null; +}; + +type CommitType = { + type: string; + scope?: string; + description: string; +}; + +function parseCommitMessage(message: string): CommitType { + const conventionalPattern = /^(\w+)(?:$$([^)]+)$$)?:\s*(.+)$/; + const match = message.match(conventionalPattern); + + if (match) { + return { + type: match[1], + scope: match[2], + description: match[3], + }; + } + + return { + type: "chore", + description: message, + }; +} + +function getCommitTypeColor(type: string): string { + const colors: Record = { + feat: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/20", + fix: "bg-red-500/10 text-red-700 dark:text-red-400 border-red-500/20", + docs: "bg-blue-500/10 text-blue-700 dark:text-blue-400 border-blue-500/20", + style: + "bg-purple-500/10 text-purple-700 dark:text-purple-400 border-purple-500/20", + refactor: + "bg-orange-500/10 text-orange-700 dark:text-orange-400 border-orange-500/20", + perf: "bg-yellow-500/10 text-yellow-700 dark:text-yellow-400 border-yellow-500/20", + test: "bg-pink-500/10 text-pink-700 dark:text-pink-400 border-pink-500/20", + chore: "bg-gray-500/10 text-gray-700 dark:text-gray-400 border-gray-500/20", + }; + + return colors[type] || colors.chore; +} + +export function ChangelogList({ commits, repoUrl }: ChangelogListProps) { + if (!commits || commits.length === 0) { + return ( +
+

+ Nenhum commit encontrado no repositório +

+
+ ); + } + + return ( +
+ {commits.map((commit) => ( + + ))} +
+ ); +} + +function CommitCard({ + commit, + repoUrl, +}: { + commit: GitCommit; + repoUrl: string | null; +}) { + const commitDate = new Date(commit.date); + const relativeTime = formatDistanceToNow(commitDate, { + addSuffix: true, + locale: ptBR, + }); + + const commitUrl = repoUrl ? `${repoUrl}/commit/${commit.hash}` : null; + const parsed = parseCommitMessage(commit.message); + + return ( + + +
+ + {parsed.type} + + {parsed.scope && ( + + {parsed.scope} + + )} + + {parsed.description} + +
+ +
+ {commitUrl ? ( + + + {commit.shortHash} + + ) : ( + + + {commit.shortHash} + + )} + + + {commit.author} + + + + {relativeTime} + +
+
+ + {commit.body && ( + + {commit.body} + + )} + + {commit.filesChanged.length > 0 && ( + + + + +
+ + + {commit.filesChanged.length} arquivo + {commit.filesChanged.length !== 1 ? "s" : ""} + +
+
+ +
    + {commit.filesChanged.map((file, index) => ( +
  • + {file} +
  • + ))} +
+
+
+
+
+ )} +
+ ); +} diff --git a/components/changelog/changelog-notification.tsx b/components/changelog/changelog-notification.tsx deleted file mode 100644 index 4743082..0000000 --- a/components/changelog/changelog-notification.tsx +++ /dev/null @@ -1,142 +0,0 @@ -"use client"; - -import { Badge } from "@/components/ui/badge"; -import { Button, buttonVariants } from "@/components/ui/button"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Separator } from "@/components/ui/separator"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { markAllUpdatesAsRead } from "@/lib/changelog/actions"; -import type { ChangelogEntry } from "@/lib/changelog/data"; -import { - getCategoryLabel, - groupEntriesByCategory, - parseSafariCompatibleDate, -} from "@/lib/changelog/utils"; -import { cn } from "@/lib/utils"; -import { RiMegaphoneLine } from "@remixicon/react"; -import { formatDistanceToNow } from "date-fns"; -import { ptBR } from "date-fns/locale"; -import { useState } from "react"; - -interface ChangelogNotificationProps { - unreadCount: number; - entries: ChangelogEntry[]; -} - -export function ChangelogNotification({ - unreadCount: initialUnreadCount, - entries, -}: ChangelogNotificationProps) { - const [unreadCount, setUnreadCount] = useState(initialUnreadCount); - const [isOpen, setIsOpen] = useState(false); - - const handleMarkAllAsRead = async () => { - const updateIds = entries.map((e) => e.id); - await markAllUpdatesAsRead(updateIds); - setUnreadCount(0); - }; - - const grouped = groupEntriesByCategory(entries); - - return ( - - - - - - - - Novidades - - -
-
- -

Novidades

-
- {unreadCount > 0 && ( - - )} -
- - - - -
- {Object.entries(grouped).map(([category, categoryEntries]) => ( -
-

- {getCategoryLabel(category)} -

-
- {categoryEntries.map((entry) => ( -
-
- {entry.icon} -
- - #{entry.id.substring(0, 7)} - -

- {entry.title} -

-

- {formatDistanceToNow(parseSafariCompatibleDate(entry.date), { - addSuffix: true, - locale: ptBR, - })} -

-
-
-
- ))} -
-
- ))} - - {entries.length === 0 && ( -
- Nenhuma atualização recente -
- )} -
-
-
-
- ); -} diff --git a/components/contas/account-card.tsx b/components/contas/account-card.tsx index ba0fac2..bf3199d 100644 --- a/components/contas/account-card.tsx +++ b/components/contas/account-card.tsx @@ -1,16 +1,16 @@ "use client"; - import { cn } from "@/lib/utils/ui"; import { RiArrowLeftRightLine, RiDeleteBin5Line, - RiEyeOffLine, RiFileList2Line, RiPencilLine, + RiInformationLine, } from "@remixicon/react"; import type React from "react"; import MoneyValues from "../money-values"; import { Card, CardContent, CardFooter } from "../ui/card"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; interface AccountCardProps { accountName: string; @@ -19,6 +19,7 @@ interface AccountCardProps { status?: string; icon?: React.ReactNode; excludeFromBalance?: boolean; + excludeInitialBalanceFromIncome?: boolean; onViewStatement?: () => void; onEdit?: () => void; onRemove?: () => void; @@ -33,6 +34,7 @@ export function AccountCard({ status, icon, excludeFromBalance, + excludeInitialBalanceFromIncome, onViewStatement, onEdit, onRemove, @@ -85,21 +87,39 @@ export function AccountCard({

{accountName}

- {excludeFromBalance ? ( -
- -
- ) : null} + + {(excludeFromBalance || excludeInitialBalanceFromIncome) && ( + + +
+ +
+
+ +
+ {excludeFromBalance && ( +

+ Desconsiderado do saldo total: Esta conta + não é incluída no cálculo do saldo total geral. +

+ )} + {excludeInitialBalanceFromIncome && ( +

+ + Saldo inicial desconsiderado das receitas: + {" "} + O saldo inicial desta conta não é contabilizado como + receita nas métricas. +

+ )} +
+
+
+ )}
-
-

Saldo

-

- -

+
+

{accountType}

diff --git a/components/contas/account-dialog.tsx b/components/contas/account-dialog.tsx index bd81bcb..7249394 100644 --- a/components/contas/account-dialog.tsx +++ b/components/contas/account-dialog.tsx @@ -38,7 +38,7 @@ const DEFAULT_ACCOUNT_TYPES = [ "Conta Poupança", "Carteira Digital", "Conta Investimento", - "Cartão Pré-pago", + "Pré-Pago | VR/VA", ] as const; const DEFAULT_ACCOUNT_STATUS = ["Ativa", "Inativa"] as const; @@ -75,6 +75,8 @@ const buildInitialValues = ({ logo: selectedLogo, initialBalance: formatInitialBalanceInput(account?.initialBalance ?? 0), excludeFromBalance: account?.excludeFromBalance ?? false, + excludeInitialBalanceFromIncome: + account?.excludeInitialBalanceFromIncome ?? false, }; }; diff --git a/components/contas/account-form-fields.tsx b/components/contas/account-form-fields.tsx index 0efa008..8f80dce 100644 --- a/components/contas/account-form-fields.tsx +++ b/components/contas/account-form-fields.tsx @@ -106,17 +106,39 @@ export function AccountFormFields({ />
-
- - onChange("excludeFromBalance", checked ? "true" : "false") - } - /> - +
+
+ + onChange("excludeFromBalance", !!checked ? "true" : "false") + } + /> + +
+ +
+ + onChange("excludeInitialBalanceFromIncome", !!checked ? "true" : "false") + } + /> + +
); diff --git a/components/contas/accounts-page.tsx b/components/contas/accounts-page.tsx index de5e00e..a6dd9b4 100644 --- a/components/contas/accounts-page.tsx +++ b/components/contas/accounts-page.tsx @@ -137,10 +137,13 @@ export function AccountsPage({ accounts, logoOptions }: AccountsPageProps) { } title="Selecione categorias para visualizar" - description="Escolha até 5 categorias para acompanhar o histórico nos últimos 6 meses." + description="Escolha até 5 categorias para acompanhar o histórico dos últimos 8 meses, mês atual e próximo mês." />
) : ( diff --git a/components/dashboard/dashboard-welcome.tsx b/components/dashboard/dashboard-welcome.tsx index aa44c75..8cbaff3 100644 --- a/components/dashboard/dashboard-welcome.tsx +++ b/components/dashboard/dashboard-welcome.tsx @@ -6,6 +6,7 @@ import { Card } from "../ui/card"; type DashboardWelcomeProps = { name?: string | null; + disableMagnetlines?: boolean; }; const capitalizeFirstLetter = (value: string) => @@ -44,7 +45,7 @@ const getGreeting = () => { } }; -export function DashboardWelcome({ name }: DashboardWelcomeProps) { +export function DashboardWelcome({ name, disableMagnetlines = false }: DashboardWelcomeProps) { const displayName = name && name.trim().length > 0 ? name : "Administrador"; const formattedDate = formatCurrentDate(); const greeting = getGreeting(); @@ -63,6 +64,7 @@ export function DashboardWelcome({ name }: DashboardWelcomeProps) { lineHeight="5vmin" baseAngle={0} className="text-welcome-banner-foreground" + disabled={disableMagnetlines} />
diff --git a/components/dashboard/my-accounts-widget.tsx b/components/dashboard/my-accounts-widget.tsx index d3fb96d..610407c 100644 --- a/components/dashboard/my-accounts-widget.tsx +++ b/components/dashboard/my-accounts-widget.tsx @@ -76,7 +76,7 @@ export function MyAccountsWidget({ return (
  • {logoSrc ? ( diff --git a/components/header-dashboard.tsx b/components/header-dashboard.tsx index ef9d75f..4b2795e 100644 --- a/components/header-dashboard.tsx +++ b/components/header-dashboard.tsx @@ -1,9 +1,7 @@ -import { ChangelogNotification } from "@/components/changelog/changelog-notification"; import { FeedbackDialog } from "@/components/feedback/feedback-dialog"; import { NotificationBell } from "@/components/notificacoes/notification-bell"; import { SidebarTrigger } from "@/components/ui/sidebar"; import { getUser } from "@/lib/auth/server"; -import { getUnreadUpdates } from "@/lib/changelog/data"; import type { DashboardNotificationsSnapshot } from "@/lib/dashboard/notifications"; import { AnimatedThemeToggler } from "./animated-theme-toggler"; import LogoutButton from "./auth/logout-button"; @@ -16,7 +14,6 @@ type SiteHeaderProps = { export async function SiteHeader({ notificationsSnapshot }: SiteHeaderProps) { const user = await getUser(); - const { unreadCount, allEntries } = await getUnreadUpdates(user.id); return (
    @@ -31,10 +28,6 @@ export async function SiteHeader({ notificationsSnapshot }: SiteHeaderProps) { | -
    diff --git a/components/lancamentos/dialogs/anticipate-installments-dialog/anticipate-installments-dialog.tsx b/components/lancamentos/dialogs/anticipate-installments-dialog/anticipate-installments-dialog.tsx index 018f97d..41ce4d8 100644 --- a/components/lancamentos/dialogs/anticipate-installments-dialog/anticipate-installments-dialog.tsx +++ b/components/lancamentos/dialogs/anticipate-installments-dialog/anticipate-installments-dialog.tsx @@ -35,6 +35,8 @@ import { Textarea } from "@/components/ui/textarea"; import { useControlledState } from "@/hooks/use-controlled-state"; import { useFormState } from "@/hooks/use-form-state"; import type { EligibleInstallment } from "@/lib/installments/anticipation-types"; +import type { PeriodPreferences } from "@/lib/user-preferences/period"; +import { createMonthOptions } from "@/lib/utils/period"; import { RiLoader4Line } from "@remixicon/react"; import { useCallback, @@ -53,6 +55,7 @@ interface AnticipateInstallmentsDialogProps { categorias: Array<{ id: string; name: string; icon: string | null }>; pagadores: Array<{ id: string; name: string }>; defaultPeriod: string; + periodPreferences: PeriodPreferences; open?: boolean; onOpenChange?: (open: boolean) => void; } @@ -65,57 +68,6 @@ type AnticipationFormValues = { note: string; }; -type SelectOption = { - value: string; - label: string; -}; - -const monthFormatter = new Intl.DateTimeFormat("pt-BR", { - month: "long", - year: "numeric", -}); - -const formatPeriodLabel = (period: string) => { - const [year, month] = period.split("-").map(Number); - if (!year || !month) { - return period; - } - const date = new Date(year, month - 1, 1); - if (Number.isNaN(date.getTime())) { - return period; - } - const label = monthFormatter.format(date); - return label.charAt(0).toUpperCase() + label.slice(1); -}; - -const buildPeriodOptions = (currentValue?: string): SelectOption[] => { - const now = new Date(); - const options: SelectOption[] = []; - - // Adiciona opções de 3 meses no passado até 6 meses no futuro - for (let offset = -3; offset <= 6; offset += 1) { - const date = new Date(now.getFullYear(), now.getMonth() + offset, 1); - const value = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart( - 2, - "0" - )}`; - options.push({ value, label: formatPeriodLabel(value) }); - } - - // Adiciona o valor atual se não estiver na lista - if ( - currentValue && - !options.some((option) => option.value === currentValue) - ) { - options.push({ - value: currentValue, - label: formatPeriodLabel(currentValue), - }); - } - - return options.sort((a, b) => a.value.localeCompare(b.value)); -}; - export function AnticipateInstallmentsDialog({ trigger, seriesId, @@ -123,6 +75,7 @@ export function AnticipateInstallmentsDialog({ categorias, pagadores, defaultPeriod, + periodPreferences, open, onOpenChange, }: AnticipateInstallmentsDialogProps) { @@ -152,8 +105,13 @@ export function AnticipateInstallmentsDialog({ }); const periodOptions = useMemo( - () => buildPeriodOptions(formState.anticipationPeriod), - [formState.anticipationPeriod] + () => + createMonthOptions( + formState.anticipationPeriod, + periodPreferences.monthsBefore, + periodPreferences.monthsAfter + ), + [formState.anticipationPeriod, periodPreferences.monthsBefore, periodPreferences.monthsAfter] ); // Buscar parcelas elegíveis ao abrir o dialog diff --git a/components/lancamentos/dialogs/lancamento-details-dialog.tsx b/components/lancamentos/dialogs/lancamento-details-dialog.tsx index 3f20c70..ee13ae7 100644 --- a/components/lancamentos/dialogs/lancamento-details-dialog.tsx +++ b/components/lancamentos/dialogs/lancamento-details-dialog.tsx @@ -110,10 +110,14 @@ export function LancamentoDetailsDialog({ - {lancamento.transactionType} + {lancamento.categoriaName === "Saldo inicial" + ? "Saldo Inicial" + : lancamento.transactionType}
  • diff --git a/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog-types.ts b/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog-types.ts index 9a46f94..bba7035 100644 --- a/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog-types.ts +++ b/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog-types.ts @@ -1,4 +1,5 @@ import type { LancamentoFormState } from "@/lib/lancamentos/form-helpers"; +import type { PeriodPreferences } from "@/lib/user-preferences/period"; import type { LancamentoItem, SelectOption } from "../../types"; export type FormState = LancamentoFormState; @@ -17,6 +18,7 @@ export interface LancamentoDialogProps { estabelecimentos: string[]; lancamento?: LancamentoItem; defaultPeriod?: string; + periodPreferences: PeriodPreferences; defaultCartaoId?: string | null; defaultPaymentMethod?: string | null; defaultPurchaseDate?: string | null; diff --git a/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog.tsx b/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog.tsx index b7e008d..a7b4eaa 100644 --- a/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog.tsx +++ b/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog.tsx @@ -58,6 +58,7 @@ export function LancamentoDialog({ estabelecimentos, lancamento, defaultPeriod, + periodPreferences, defaultCartaoId, defaultPaymentMethod, defaultPurchaseDate, @@ -125,8 +126,13 @@ export function LancamentoDialog({ }, [categoriaOptions, formState.transactionType]); const monthOptions = useMemo( - () => createMonthOptions(formState.period), - [formState.period] + () => + createMonthOptions( + formState.period, + periodPreferences.monthsBefore, + periodPreferences.monthsAfter + ), + [formState.period, periodPreferences.monthsBefore, periodPreferences.monthsAfter] ); const handleFieldChange = useCallback( diff --git a/components/lancamentos/dialogs/lancamento-dialog/payment-method-section.tsx b/components/lancamentos/dialogs/lancamento-dialog/payment-method-section.tsx index 02caf85..2f445aa 100644 --- a/components/lancamentos/dialogs/lancamento-dialog/payment-method-section.tsx +++ b/components/lancamentos/dialogs/lancamento-dialog/payment-method-section.tsx @@ -31,8 +31,18 @@ export function PaymentMethodSection({ "Dinheiro", "Boleto", "Cartão de débito", + "Pré-Pago | VR/VA", + "Transferência bancária", ].includes(formState.paymentMethod); + // Filtrar contas apenas do tipo "Pré-Pago | VR/VA" quando forma de pagamento for "Pré-Pago | VR/VA" + const filteredContaOptions = + formState.paymentMethod === "Pré-Pago | VR/VA" + ? contaOptions.filter( + (option) => option.accountType === "Pré-Pago | VR/VA" + ) + : contaOptions; + return ( <> {!isUpdateMode ? ( @@ -56,7 +66,9 @@ export function PaymentMethodSection({ > {formState.paymentMethod && ( - + )} @@ -138,7 +150,7 @@ export function PaymentMethodSection({ {formState.contaId && (() => { - const selectedOption = contaOptions.find( + const selectedOption = filteredContaOptions.find( (opt) => opt.value === formState.contaId ); return selectedOption ? ( @@ -152,14 +164,14 @@ export function PaymentMethodSection({ - {contaOptions.length === 0 ? ( + {filteredContaOptions.length === 0 ? (

    Nenhuma conta cadastrada

    ) : ( - contaOptions.map((option) => ( + filteredContaOptions.map((option) => ( {formState.contaId && (() => { - const selectedOption = contaOptions.find( + const selectedOption = filteredContaOptions.find( (opt) => opt.value === formState.contaId ); return selectedOption ? ( @@ -260,14 +272,14 @@ export function PaymentMethodSection({ - {contaOptions.length === 0 ? ( + {filteredContaOptions.length === 0 ? (

    Nenhuma conta cadastrada

    ) : ( - contaOptions.map((option) => ( + filteredContaOptions.map((option) => ( createMonthOptions(selectedPeriod, 3), - [selectedPeriod] + () => + createMonthOptions( + selectedPeriod, + periodPreferences.monthsBefore, + periodPreferences.monthsAfter + ), + [selectedPeriod, periodPreferences.monthsBefore, periodPreferences.monthsAfter] ); // Categorias agrupadas e filtradas por tipo de transação diff --git a/components/lancamentos/page/lancamentos-page.tsx b/components/lancamentos/page/lancamentos-page.tsx index 2b9b7ab..8a5e4d8 100644 --- a/components/lancamentos/page/lancamentos-page.tsx +++ b/components/lancamentos/page/lancamentos-page.tsx @@ -25,6 +25,7 @@ import type { LancamentoItem, SelectOption, } from "../types"; +import type { PeriodPreferences } from "@/lib/user-preferences/period"; interface LancamentosPageProps { lancamentos: LancamentoItem[]; @@ -39,6 +40,7 @@ interface LancamentosPageProps { contaCartaoFilterOptions: ContaCartaoFilterOption[]; selectedPeriod: string; estabelecimentos: string[]; + periodPreferences: PeriodPreferences; allowCreate?: boolean; defaultCartaoId?: string | null; defaultPaymentMethod?: string | null; @@ -59,6 +61,7 @@ export function LancamentosPage({ contaCartaoFilterOptions, selectedPeriod, estabelecimentos, + periodPreferences, allowCreate = true, defaultCartaoId, defaultPaymentMethod, @@ -114,7 +117,7 @@ export function LancamentosPage({ return; } - const supportedMethods = ["Pix", "Boleto", "Dinheiro", "Cartão de débito"]; + const supportedMethods = ["Pix", "Boleto", "Dinheiro", "Cartão de débito", "Pré-Pago | VR/VA", "Transferência bancária"]; if (!supportedMethods.includes(item.paymentMethod)) { return; } @@ -354,6 +357,7 @@ export function LancamentosPage({ categoriaOptions={categoriaOptions} estabelecimentos={estabelecimentos} defaultPeriod={selectedPeriod} + periodPreferences={periodPreferences} defaultCartaoId={defaultCartaoId} defaultPaymentMethod={defaultPaymentMethod} lockCartaoSelection={lockCartaoSelection} @@ -379,6 +383,7 @@ export function LancamentosPage({ estabelecimentos={estabelecimentos} lancamento={lancamentoToCopy ?? undefined} defaultPeriod={selectedPeriod} + periodPreferences={periodPreferences} /> @@ -473,6 +479,7 @@ export function LancamentosPage({ categoriaOptions={categoriaOptions} estabelecimentos={estabelecimentos} selectedPeriod={selectedPeriod} + periodPreferences={periodPreferences} defaultPagadorId={defaultPagadorId} /> ) : null} @@ -508,6 +515,7 @@ export function LancamentosPage({ name: p.label, }))} defaultPeriod={selectedPeriod} + periodPreferences={periodPreferences} /> )} diff --git a/components/lancamentos/shared/anticipation-card.tsx b/components/lancamentos/shared/anticipation-card.tsx index 3917c6e..5c09d8c 100644 --- a/components/lancamentos/shared/anticipation-card.tsx +++ b/components/lancamentos/shared/anticipation-card.tsx @@ -13,6 +13,7 @@ import { CardTitle, } from "@/components/ui/card"; import type { InstallmentAnticipationWithRelations } from "@/lib/installments/anticipation-types"; +import { displayPeriod } from "@/lib/utils/period"; import { RiCalendarCheckLine, RiCloseLine, RiEyeLine } from "@remixicon/react"; import { format } from "date-fns"; import { ptBR } from "date-fns/locale"; @@ -26,24 +27,6 @@ interface AnticipationCardProps { onCanceled?: () => void; } -const monthFormatter = new Intl.DateTimeFormat("pt-BR", { - month: "long", - year: "numeric", -}); - -const formatPeriodLabel = (period: string) => { - const [year, month] = period.split("-").map(Number); - if (!year || !month) { - return period; - } - const date = new Date(year, month - 1, 1); - if (Number.isNaN(date.getTime())) { - return period; - } - const label = monthFormatter.format(date); - return label.charAt(0).toUpperCase() + label.slice(1); -}; - export function AnticipationCard({ anticipation, onViewLancamento, @@ -93,7 +76,7 @@ export function AnticipationCard({
    - {formatPeriodLabel(anticipation.anticipationPeriod)} + {displayPeriod(anticipation.anticipationPeriod)} diff --git a/components/lancamentos/table/lancamentos-table.tsx b/components/lancamentos/table/lancamentos-table.tsx index b617dd8..7552b5a 100644 --- a/components/lancamentos/table/lancamentos-table.tsx +++ b/components/lancamentos/table/lancamentos-table.tsx @@ -288,16 +288,20 @@ const buildColumns = ({ { accessorKey: "transactionType", header: "Transação", - cell: ({ row }) => ( - - ), + cell: ({ row }) => { + const type = + row.original.categoriaName === "Saldo inicial" + ? "Saldo inicial" + : row.original.transactionType; + + return ( + + ); + }, }, { accessorKey: "amount", diff --git a/components/lancamentos/types.ts b/components/lancamentos/types.ts index 871386d..7dd176d 100644 --- a/components/lancamentos/types.ts +++ b/components/lancamentos/types.ts @@ -43,6 +43,7 @@ export type SelectOption = { avatarUrl?: string | null; logo?: string | null; icon?: string | null; + accountType?: string | null; }; export type LancamentoFilterOption = { diff --git a/components/magnet-lines.tsx b/components/magnet-lines.tsx index ac13efc..2081336 100644 --- a/components/magnet-lines.tsx +++ b/components/magnet-lines.tsx @@ -1,3 +1,5 @@ +"use client"; + import React, { CSSProperties, useEffect, useRef } from "react"; interface MagnetLinesProps { @@ -10,6 +12,7 @@ interface MagnetLinesProps { baseAngle?: number; className?: string; style?: CSSProperties; + disabled?: boolean; } const MagnetLines: React.FC = ({ @@ -22,9 +25,15 @@ const MagnetLines: React.FC = ({ baseAngle = -10, className = "", style = {}, + disabled = false, }) => { const containerRef = useRef(null); + // Se magnetlines estiver desabilitado, não renderiza nada + if (disabled) { + return null; + } + useEffect(() => { const container = containerRef.current; if (!container) return; diff --git a/components/orcamentos/budget-dialog.tsx b/components/orcamentos/budget-dialog.tsx index f98d2d9..c799da7 100644 --- a/components/orcamentos/budget-dialog.tsx +++ b/components/orcamentos/budget-dialog.tsx @@ -26,6 +26,8 @@ import { import { Label } from "@/components/ui/label"; import { useControlledState } from "@/hooks/use-controlled-state"; import { useFormState } from "@/hooks/use-form-state"; +import type { PeriodPreferences } from "@/lib/user-preferences/period"; +import { createMonthOptions } from "@/lib/utils/period"; import { useCallback, useEffect, @@ -43,64 +45,11 @@ interface BudgetDialogProps { budget?: Budget; categories: BudgetCategory[]; defaultPeriod: string; + periodPreferences: PeriodPreferences; open?: boolean; onOpenChange?: (open: boolean) => void; } -type SelectOption = { - value: string; - label: string; -}; - -const monthFormatter = new Intl.DateTimeFormat("pt-BR", { - month: "long", - year: "numeric", -}); - -const formatPeriodLabel = (period: string) => { - const [year, month] = period.split("-").map(Number); - if (!year || !month) { - return period; - } - const date = new Date(year, month - 1, 1); - if (Number.isNaN(date.getTime())) { - return period; - } - const label = monthFormatter.format(date); - return label.charAt(0).toUpperCase() + label.slice(1); -}; - -const buildPeriodOptions = (currentValue?: string): SelectOption[] => { - const now = new Date(); - const options: SelectOption[] = []; - - for (let offset = -3; offset <= 3; offset += 1) { - const date = new Date(now.getFullYear(), now.getMonth() + offset, 1); - const value = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart( - 2, - "0" - )}`; - options.push({ value, label: formatPeriodLabel(value) }); - } - - if ( - currentValue && - !options.some((option) => option.value === currentValue) - ) { - options.push({ - value: currentValue, - label: formatPeriodLabel(currentValue), - }); - } - - return options - .sort((a, b) => a.value.localeCompare(b.value)) - .map((option) => ({ - value: option.value, - label: option.label, - })); -}; - const buildInitialValues = ({ budget, defaultPeriod, @@ -119,6 +68,7 @@ export function BudgetDialog({ budget, categories, defaultPeriod, + periodPreferences, open, onOpenChange, }: BudgetDialogProps) { @@ -161,8 +111,13 @@ export function BudgetDialog({ }, [dialogOpen]); const periodOptions = useMemo( - () => buildPeriodOptions(formState.period), - [formState.period] + () => + createMonthOptions( + formState.period, + periodPreferences.monthsBefore, + periodPreferences.monthsAfter + ), + [formState.period, periodPreferences.monthsBefore, periodPreferences.monthsAfter] ); const handleSubmit = useCallback( diff --git a/components/orcamentos/budgets-page.tsx b/components/orcamentos/budgets-page.tsx index 06b7e69..2fd00a0 100644 --- a/components/orcamentos/budgets-page.tsx +++ b/components/orcamentos/budgets-page.tsx @@ -4,6 +4,7 @@ import { deleteBudgetAction } from "@/app/(dashboard)/orcamentos/actions"; import { ConfirmActionDialog } from "@/components/confirm-action-dialog"; import { EmptyState } from "@/components/empty-state"; import { Button } from "@/components/ui/button"; +import type { PeriodPreferences } from "@/lib/user-preferences/period"; import { RiAddCircleLine, RiFundsLine } from "@remixicon/react"; import { useCallback, useState } from "react"; import { toast } from "sonner"; @@ -17,6 +18,7 @@ interface BudgetsPageProps { categories: BudgetCategory[]; selectedPeriod: string; periodLabel: string; + periodPreferences: PeriodPreferences; } export function BudgetsPage({ @@ -24,6 +26,7 @@ export function BudgetsPage({ categories, selectedPeriod, periodLabel, + periodPreferences, }: BudgetsPageProps) { const [editOpen, setEditOpen] = useState(false); const [selectedBudget, setSelectedBudget] = useState(null); @@ -91,6 +94,7 @@ export function BudgetsPage({ mode="create" categories={categories} defaultPeriod={selectedPeriod} + periodPreferences={periodPreferences} trigger={