feat: implementar sistema de preferências do usuário e refatorar changelog
Adiciona sistema completo de preferências de usuário: - Cria tabela userPreferences no schema com campos disableMagnetlines, periodMonthsBefore e periodMonthsAfter - Implementa página de Ajustes com abas (Preferências, Alterar nome, Senha, E-mail, Deletar conta) - Adiciona componente PreferencesForm para configuração de magnetlines e períodos de exibição - Propaga periodPreferences para todos os componentes de lançamentos e calendário Refatora sistema de changelog: - Remove implementação anterior baseada em JSON estático - Adiciona nova página de changelog dinâmica em app/(dashboard)/changelog - Adiciona componente changelog-list.tsx - Remove arquivos obsoletos (changelog-notification, actions, data, utils, scripts) Adiciona controle de saldo inicial em contas: - Novo campo excludeInitialBalanceFromIncome em contas - Permite excluir saldo inicial do cálculo de receitas - Atualiza queries de lançamentos para respeitar esta configuração Melhorias adicionais: - Adiciona componente ui/accordion.tsx do shadcn/ui - Refatora formatPeriodLabel para displayPeriod centralizado - Propaga estabelecimentos para componentes de lançamentos - Remove variável DB_PROVIDER obsoleta do .env.example e documentação - Adiciona 6 migrações de banco de dados (0003-0008)
This commit is contained in:
@@ -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<typeof updatePreferencesSchema>
|
||||
): Promise<ActionResponse> {
|
||||
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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="max-w-3xl">
|
||||
<Tabs defaultValue="nome" className="w-full">
|
||||
<TabsList className="w-full grid grid-cols-4 mb-2">
|
||||
<TabsTrigger value="nome">Altere seu nome</TabsTrigger>
|
||||
<div className="w-full">
|
||||
<Tabs defaultValue="preferencias" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="preferencias">Preferências</TabsTrigger>
|
||||
<TabsTrigger value="nome">Alterar nome</TabsTrigger>
|
||||
<TabsTrigger value="senha">Alterar senha</TabsTrigger>
|
||||
<TabsTrigger value="email">Alterar e-mail</TabsTrigger>
|
||||
<TabsTrigger value="deletar" className="text-destructive">
|
||||
@@ -42,56 +57,92 @@ export default async function Page() {
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Card className="p-6">
|
||||
<TabsContent value="nome" className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium mb-1">Alterar nome</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Atualize como seu nome aparece no Opensheets. Esse nome pode ser
|
||||
exibido em diferentes seções do app e em comunicações.
|
||||
</p>
|
||||
<TabsContent value="preferencias" className="mt-4">
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-1">Preferências</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Personalize sua experiência no Opensheets ajustando as
|
||||
configurações de acordo com suas necessidades.
|
||||
</p>
|
||||
</div>
|
||||
<PreferencesForm
|
||||
disableMagnetlines={
|
||||
userPreferences?.disableMagnetlines ?? false
|
||||
}
|
||||
periodMonthsBefore={userPreferences?.periodMonthsBefore ?? 3}
|
||||
periodMonthsAfter={userPreferences?.periodMonthsAfter ?? 3}
|
||||
/>
|
||||
</div>
|
||||
<UpdateNameForm currentName={userName} />
|
||||
</TabsContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="senha" className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium mb-1">Alterar senha</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Defina uma nova senha para sua conta. Guarde-a em local seguro.
|
||||
</p>
|
||||
<TabsContent value="nome" className="mt-4">
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-1">Alterar nome</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Atualize como seu nome aparece no Opensheets. Esse nome pode
|
||||
ser exibido em diferentes seções do app e em comunicações.
|
||||
</p>
|
||||
</div>
|
||||
<UpdateNameForm currentName={userName} />
|
||||
</div>
|
||||
<UpdatePasswordForm authProvider={authProvider} />
|
||||
</TabsContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="email" className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium mb-1">Alterar e-mail</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
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.
|
||||
</p>
|
||||
<TabsContent value="senha" className="mt-4">
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-1">Alterar senha</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Defina uma nova senha para sua conta. Guarde-a em local
|
||||
seguro.
|
||||
</p>
|
||||
</div>
|
||||
<UpdatePasswordForm authProvider={authProvider} />
|
||||
</div>
|
||||
<UpdateEmailForm
|
||||
currentEmail={userEmail}
|
||||
authProvider={authProvider}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="deletar" className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium mb-1 text-destructive">
|
||||
Deletar conta
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Ao prosseguir, sua conta e todos os dados associados serão
|
||||
excluídos de forma irreversível.
|
||||
</p>
|
||||
<TabsContent value="email" className="mt-4">
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-1">Alterar e-mail</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<UpdateEmailForm
|
||||
currentEmail={userEmail}
|
||||
authProvider={authProvider}
|
||||
/>
|
||||
</div>
|
||||
<DeleteAccountForm />
|
||||
</TabsContent>
|
||||
</Card>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="deletar" className="mt-4">
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-1 text-destructive">
|
||||
Deletar conta
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Ao prosseguir, sua conta e todos os dados associados serão
|
||||
excluídos de forma irreversível.
|
||||
</p>
|
||||
</div>
|
||||
<DeleteAccountForm />
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<Record<string, string | string[] | undefined>>;
|
||||
@@ -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 (
|
||||
<main className="flex flex-col gap-6">
|
||||
@@ -108,6 +92,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
||||
selectedPeriod={detail.period}
|
||||
estabelecimentos={estabelecimentos}
|
||||
periodPreferences={periodPreferences}
|
||||
allowCreate={true}
|
||||
/>
|
||||
</main>
|
||||
|
||||
23
app/(dashboard)/changelog/layout.tsx
Normal file
23
app/(dashboard)/changelog/layout.tsx
Normal file
@@ -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 (
|
||||
<section className="space-y-6 px-6">
|
||||
<PageDescription
|
||||
icon={<RiGitCommitLine />}
|
||||
title="Changelog"
|
||||
subtitle="Histórico completo de alterações e atualizações do projeto."
|
||||
/>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
31
app/(dashboard)/changelog/loading.tsx
Normal file
31
app/(dashboard)/changelog/loading.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<div className="mb-8">
|
||||
<Skeleton className="h-9 w-48 mb-2" />
|
||||
<Skeleton className="h-5 w-96" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-3">
|
||||
<Skeleton className="h-6 w-3/4 mb-2" />
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
102
app/(dashboard)/changelog/page.tsx
Normal file
102
app/(dashboard)/changelog/page.tsx
Normal file
@@ -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 (
|
||||
<main>
|
||||
<ChangelogList commits={commits} repoUrl={repoUrl} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<number>`
|
||||
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 };
|
||||
|
||||
@@ -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<Record<string, string | string[] | undefined>>;
|
||||
|
||||
@@ -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 (
|
||||
<main className="flex flex-col gap-4 px-6">
|
||||
<DashboardWelcome name={user.name} />
|
||||
<DashboardWelcome name={user.name} disableMagnetlines={disableMagnetlines} />
|
||||
<MonthPicker />
|
||||
<SectionCards metrics={data.metrics} />
|
||||
<DashboardGrid data={data} period={selectedPeriod} />
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<main className="flex flex-col gap-6">
|
||||
@@ -48,6 +49,7 @@ export default async function Page({ searchParams }: PageProps) {
|
||||
categories={categoriesOptions}
|
||||
selectedPeriod={selectedPeriod}
|
||||
periodLabel={periodLabel}
|
||||
periodPreferences={periodPreferences}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user