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:
@@ -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
|
||||
|
||||
12
README.md
12
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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<RiAlertLine className="size-5 text-destructive mt-0.5" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<h3 className="font-medium text-destructive">
|
||||
Remoção definitiva de conta
|
||||
</h3>
|
||||
<p className="text-sm text-foreground">
|
||||
Ao prosseguir, sua conta e todos os dados associados serão
|
||||
excluídos de forma irreversível.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-6">
|
||||
<div className="space-y-4 max-w-md">
|
||||
<ul className="list-disc list-inside text-sm text-destructive space-y-1">
|
||||
<li>Lançamentos, orçamentos e anotações</li>
|
||||
<li>Contas, cartões e categorias</li>
|
||||
<li>Pagadores (incluindo o pagador padrão)</li>
|
||||
<li>Preferências e configurações</li>
|
||||
<li className="font-bold">
|
||||
Resumindo tudo, sua conta será permanentemente removida
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 pl-8">
|
||||
<li>Lançamentos, anexos e notas</li>
|
||||
<li>Contas, cartões, orçamentos e categorias</li>
|
||||
<li>Pagadores (incluindo o pagador padrão)</li>
|
||||
<li>Preferências e configurações</li>
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleOpenModal}
|
||||
disabled={isPending}
|
||||
>
|
||||
Deletar conta
|
||||
</Button>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleOpenModal}
|
||||
disabled={isPending}
|
||||
className="w-fit"
|
||||
>
|
||||
Deletar conta
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
|
||||
138
components/ajustes/preferences-form.tsx
Normal file
138
components/ajustes/preferences-form.tsx
Normal file
@@ -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<HTMLFormElement>) => {
|
||||
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 (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col space-y-6"
|
||||
>
|
||||
<div className="space-y-4 max-w-md">
|
||||
<div className="flex items-center justify-between rounded-lg border border-dashed p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="magnetlines" className="text-base">
|
||||
Desabilitar Magnetlines
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Remove o recurso de linhas magnéticas do sistema. Essa mudança
|
||||
afeta a interface e interações visuais.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="magnetlines"
|
||||
checked={magnetlinesDisabled}
|
||||
onCheckedChange={setMagnetlinesDisabled}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-lg border border-dashed p-4">
|
||||
<div>
|
||||
<h3 className="text-base font-medium mb-2">
|
||||
Seleção de Período
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Configure quantos meses antes e depois do mês atual serão exibidos
|
||||
nos seletores de período.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="monthsBefore" className="text-sm">
|
||||
Meses anteriores
|
||||
</Label>
|
||||
<Input
|
||||
id="monthsBefore"
|
||||
type="number"
|
||||
min={1}
|
||||
max={24}
|
||||
value={monthsBefore}
|
||||
onChange={(e) => setMonthsBefore(Number(e.target.value))}
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
1 a 24 meses
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="monthsAfter" className="text-sm">
|
||||
Meses posteriores
|
||||
</Label>
|
||||
<Input
|
||||
id="monthsAfter"
|
||||
type="number"
|
||||
min={1}
|
||||
max={24}
|
||||
value={monthsAfter}
|
||||
onChange={(e) => setMonthsAfter(Number(e.target.value))}
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
1 a 24 meses
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending} className="w-fit">
|
||||
{isPending ? "Salvando..." : "Salvar preferências"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -68,148 +68,153 @@ export function UpdateEmailForm({ currentEmail, authProvider }: UpdateEmailFormP
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* E-mail atual (apenas informativo) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currentEmail">E-mail atual</Label>
|
||||
<Input
|
||||
id="currentEmail"
|
||||
type="email"
|
||||
value={currentEmail}
|
||||
disabled
|
||||
className="bg-muted cursor-not-allowed"
|
||||
aria-describedby="current-email-help"
|
||||
/>
|
||||
<p id="current-email-help" className="text-xs text-muted-foreground">
|
||||
Este é seu e-mail atual cadastrado
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Senha de confirmação (apenas para usuários com login por e-mail/senha) */}
|
||||
{!isGoogleAuth && (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
|
||||
<div className="space-y-4 max-w-md">
|
||||
{/* E-mail atual (apenas informativo) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
Senha atual <span className="text-destructive">*</span>
|
||||
<Label htmlFor="currentEmail">E-mail atual</Label>
|
||||
<Input
|
||||
id="currentEmail"
|
||||
type="email"
|
||||
value={currentEmail}
|
||||
disabled
|
||||
className="bg-muted cursor-not-allowed"
|
||||
aria-describedby="current-email-help"
|
||||
/>
|
||||
<p id="current-email-help" className="text-xs text-muted-foreground">
|
||||
Este é seu e-mail atual cadastrado
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Senha de confirmação (apenas para usuários com login por e-mail/senha) */}
|
||||
{!isGoogleAuth && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
Senha atual <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Digite sua senha para confirmar"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-describedby="password-help"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showPassword ? "Ocultar senha" : "Mostrar senha"}
|
||||
>
|
||||
{showPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p id="password-help" className="text-xs text-muted-foreground">
|
||||
Por segurança, confirme sua senha antes de alterar seu e-mail
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Novo e-mail */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newEmail">
|
||||
Novo e-mail <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="newEmail"
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => 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 && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
O novo e-mail deve ser diferente do atual
|
||||
</p>
|
||||
)}
|
||||
{!newEmail && (
|
||||
<p id="new-email-help" className="text-xs text-muted-foreground">
|
||||
Digite o novo endereço de e-mail para sua conta
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirmar novo e-mail */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmEmail">
|
||||
Confirmar novo e-mail <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => 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"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showPassword ? "Ocultar senha" : "Mostrar senha"}
|
||||
>
|
||||
{showPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
{/* Indicador visual de match */}
|
||||
{emailsMatch !== null && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{emailsMatch ? (
|
||||
<RiCheckLine className="h-5 w-5 text-green-500" aria-label="Os e-mails coincidem" />
|
||||
) : (
|
||||
<RiCloseLine className="h-5 w-5 text-red-500" aria-label="Os e-mails não coincidem" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p id="password-help" className="text-xs text-muted-foreground">
|
||||
Por segurança, confirme sua senha antes de alterar seu e-mail
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Novo e-mail */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newEmail">
|
||||
Novo e-mail <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="newEmail"
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => 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 && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
O novo e-mail deve ser diferente do atual
|
||||
</p>
|
||||
)}
|
||||
{!newEmail && (
|
||||
<p id="new-email-help" className="text-xs text-muted-foreground">
|
||||
Digite o novo endereço de e-mail para sua conta
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirmar novo e-mail */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmEmail">
|
||||
Confirmar novo e-mail <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmEmail"
|
||||
type="email"
|
||||
value={confirmEmail}
|
||||
onChange={(e) => 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 && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{emailsMatch ? (
|
||||
<RiCheckLine className="h-5 w-5 text-green-500" aria-label="Os e-mails coincidem" />
|
||||
) : (
|
||||
<RiCloseLine className="h-5 w-5 text-red-500" aria-label="Os e-mails não coincidem" />
|
||||
)}
|
||||
</div>
|
||||
{/* Mensagem de erro em tempo real */}
|
||||
{emailsMatch === false && (
|
||||
<p id="confirm-email-help" className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
Os e-mails não coincidem
|
||||
</p>
|
||||
)}
|
||||
{emailsMatch === true && (
|
||||
<p id="confirm-email-help" className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||
<RiCheckLine className="h-3.5 w-3.5" />
|
||||
Os e-mails coincidem
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Mensagem de erro em tempo real */}
|
||||
{emailsMatch === false && (
|
||||
<p id="confirm-email-help" className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
Os e-mails não coincidem
|
||||
</p>
|
||||
)}
|
||||
{emailsMatch === true && (
|
||||
<p id="confirm-email-help" className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||
<RiCheckLine className="h-3.5 w-3.5" />
|
||||
Os e-mails coincidem
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || emailsMatch === false || !isEmailDifferent}
|
||||
>
|
||||
{isPending ? "Atualizando..." : "Atualizar e-mail"}
|
||||
</Button>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || emailsMatch === false || !isEmailDifferent}
|
||||
className="w-fit"
|
||||
>
|
||||
{isPending ? "Atualizando..." : "Atualizar e-mail"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,32 +40,36 @@ export function UpdateNameForm({ currentName }: UpdateNameFormProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName">Primeiro nome</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
disabled={isPending}
|
||||
required
|
||||
/>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
|
||||
<div className="space-y-4 max-w-md">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName">Primeiro nome</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
disabled={isPending}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName">Sobrenome</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
disabled={isPending}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName">Sobrenome</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
disabled={isPending}
|
||||
required
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending} className="w-fit">
|
||||
{isPending ? "Atualizando..." : "Atualizar nome"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Atualizando..." : "Atualizar nome"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -133,15 +133,16 @@ export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/20">
|
||||
<div className="flex gap-3">
|
||||
<RiAlertLine className="h-5 w-5 text-amber-600 dark:text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<RiAlertLine className="h-5 w-5 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-amber-900 dark:text-amber-400">
|
||||
Alteração de senha não disponível
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-amber-800 dark:text-amber-500">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,173 +151,213 @@ export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Senha atual */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currentPassword">
|
||||
Senha atual <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type={showCurrentPassword ? "text" : "password"}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Digite sua senha atual"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-describedby="current-password-help"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showCurrentPassword ? "Ocultar senha atual" : "Mostrar senha atual"}
|
||||
>
|
||||
{showCurrentPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p id="current-password-help" className="text-xs text-muted-foreground">
|
||||
Por segurança, confirme sua senha atual antes de alterá-la
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Nova senha */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newPassword">
|
||||
Nova senha <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="newPassword"
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
value={newPassword}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showNewPassword ? "Ocultar nova senha" : "Mostrar nova senha"}
|
||||
>
|
||||
{showNewPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{/* Indicadores de requisitos da senha */}
|
||||
{newPassword.length > 0 && (
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasMinLength}
|
||||
label="Mínimo 7 caracteres"
|
||||
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
|
||||
<div className="space-y-4 max-w-md">
|
||||
{/* Senha atual */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currentPassword">
|
||||
Senha atual <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type={showCurrentPassword ? "text" : "password"}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Digite sua senha atual"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-describedby="current-password-help"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasMaxLength}
|
||||
label="Máximo 23 caracteres"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasLowercase}
|
||||
label="Letra minúscula"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasUppercase}
|
||||
label="Letra maiúscula"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasNumber}
|
||||
label="Número"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasSpecial}
|
||||
label="Caractere especial"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirmar nova senha */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">
|
||||
Confirmar nova senha <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => 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"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-10 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showConfirmPassword ? "Ocultar confirmação de senha" : "Mostrar confirmação de senha"}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
{/* Indicador visual de match */}
|
||||
{passwordsMatch !== null && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{passwordsMatch ? (
|
||||
<RiCheckLine className="h-5 w-5 text-green-500" aria-label="As senhas coincidem" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
showCurrentPassword
|
||||
? "Ocultar senha atual"
|
||||
: "Mostrar senha atual"
|
||||
}
|
||||
>
|
||||
{showCurrentPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiCloseLine className="h-5 w-5 text-red-500" aria-label="As senhas não coincidem" />
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
id="current-password-help"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
Por segurança, confirme sua senha atual antes de alterá-la
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Nova senha */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newPassword">
|
||||
Nova senha <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="newPassword"
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
value={newPassword}
|
||||
onChange={(e) => 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
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
showNewPassword ? "Ocultar nova senha" : "Mostrar nova senha"
|
||||
}
|
||||
>
|
||||
{showNewPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{/* Indicadores de requisitos da senha */}
|
||||
{newPassword.length > 0 && (
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasMinLength}
|
||||
label="Mínimo 7 caracteres"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasMaxLength}
|
||||
label="Máximo 23 caracteres"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasLowercase}
|
||||
label="Letra minúscula"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasUppercase}
|
||||
label="Letra maiúscula"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasNumber}
|
||||
label="Número"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasSpecial}
|
||||
label="Caractere especial"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Mensagem de erro em tempo real */}
|
||||
{passwordsMatch === false && (
|
||||
<p id="confirm-password-help" className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
As senhas não coincidem
|
||||
</p>
|
||||
)}
|
||||
{passwordsMatch === true && (
|
||||
<p id="confirm-password-help" className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||
<RiCheckLine className="h-3.5 w-3.5" />
|
||||
As senhas coincidem
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Confirmar nova senha */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">
|
||||
Confirmar nova senha <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => 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"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-10 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
showConfirmPassword
|
||||
? "Ocultar confirmação de senha"
|
||||
: "Mostrar confirmação de senha"
|
||||
}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
{/* Indicador visual de match */}
|
||||
{passwordsMatch !== null && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{passwordsMatch ? (
|
||||
<RiCheckLine
|
||||
className="h-5 w-5 text-green-500"
|
||||
aria-label="As senhas coincidem"
|
||||
/>
|
||||
) : (
|
||||
<RiCloseLine
|
||||
className="h-5 w-5 text-red-500"
|
||||
aria-label="As senhas não coincidem"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Mensagem de erro em tempo real */}
|
||||
{passwordsMatch === false && (
|
||||
<p
|
||||
id="confirm-password-help"
|
||||
className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1"
|
||||
role="alert"
|
||||
>
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
As senhas não coincidem
|
||||
</p>
|
||||
)}
|
||||
{passwordsMatch === true && (
|
||||
<p
|
||||
id="confirm-password-help"
|
||||
className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1"
|
||||
>
|
||||
<RiCheckLine className="h-3.5 w-3.5" />
|
||||
As senhas coincidem
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isPending || passwordsMatch === false || (newPassword.length > 0 && !passwordValidation.isValid)}>
|
||||
{isPending ? "Atualizando..." : "Atualizar senha"}
|
||||
</Button>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isPending ||
|
||||
passwordsMatch === false ||
|
||||
(newPassword.length > 0 && !passwordValidation.isValid)
|
||||
}
|
||||
className="w-fit"
|
||||
>
|
||||
{isPending ? "Atualizando..." : "Atualizar senha"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
200
components/changelog/changelog-list.tsx
Normal file
200
components/changelog/changelog-list.tsx
Normal file
@@ -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<string, string> = {
|
||||
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 (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">
|
||||
Nenhum commit encontrado no repositório
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{commits.map((commit) => (
|
||||
<CommitCard key={commit.hash} commit={commit} repoUrl={repoUrl} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card className="hover:shadow-sm transition-shadow">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${getCommitTypeColor(parsed.type)} py-1`}
|
||||
>
|
||||
{parsed.type}
|
||||
</Badge>
|
||||
{parsed.scope && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-muted-foreground border-muted-foreground/30 text-xs py-0"
|
||||
>
|
||||
{parsed.scope}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="font-bold text-lg flex-1 min-w-0 first-letter:uppercase">
|
||||
{parsed.description}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 flex-wrap text-xs text-muted-foreground">
|
||||
{commitUrl ? (
|
||||
<a
|
||||
href={commitUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground underline transition-colors font-mono flex items-center gap-1"
|
||||
>
|
||||
<RiGitCommitLine className="size-4" />
|
||||
{commit.shortHash}
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-mono flex items-center gap-1">
|
||||
<RiGitCommitLine className="size-3" />
|
||||
{commit.shortHash}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<RiUserLine className="size-3" />
|
||||
{commit.author}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<RiCalendarLine className="size-3" />
|
||||
{relativeTime}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{commit.body && (
|
||||
<CardContent className="text-muted-foreground leading-relaxed">
|
||||
{commit.body}
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{commit.filesChanged.length > 0 && (
|
||||
<CardContent>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="files-changed" className="border-0">
|
||||
<AccordionTrigger className="py-0 text-xs text-muted-foreground hover:text-foreground hover:no-underline">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RiFileList2Line className="size-3.5" />
|
||||
<span>
|
||||
{commit.filesChanged.length} arquivo
|
||||
{commit.filesChanged.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pt-2 pb-0">
|
||||
<ul className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{commit.filesChanged.map((file, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="text-xs font-mono bg-muted rounded px-2 py-1 text-muted-foreground break-all"
|
||||
>
|
||||
{file}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "ghost", size: "icon-sm" }),
|
||||
"group relative text-muted-foreground transition-all duration-200",
|
||||
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
|
||||
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground border"
|
||||
)}
|
||||
>
|
||||
<RiMegaphoneLine className="h-5 w-5" />
|
||||
{unreadCount > 0 && (
|
||||
<Badge
|
||||
className="absolute -top-1 -right-1 h-5 w-5 rounded-full p-0 flex items-center justify-center text-xs"
|
||||
variant="info"
|
||||
>
|
||||
{unreadCount > 9 ? "9+" : unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Novidades</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-96 p-0" align="end">
|
||||
<div className="flex items-center justify-between p-4 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<RiMegaphoneLine className="h-5 w-5" />
|
||||
<h3 className="font-semibold">Novidades</h3>
|
||||
</div>
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleMarkAllAsRead}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
Marcar todas como lida
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<ScrollArea className="h-[400px]">
|
||||
<div className="p-4 space-y-4">
|
||||
{Object.entries(grouped).map(([category, categoryEntries]) => (
|
||||
<div key={category} className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
{getCategoryLabel(category)}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{categoryEntries.map((entry) => (
|
||||
<div key={entry.id} className="space-y-1">
|
||||
<div className="flex items-start gap-2 border-b pb-2 border-dashed">
|
||||
<span className="text-lg mt-0.5">{entry.icon}</span>
|
||||
<div className="flex-1 space-y-1">
|
||||
<code className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
#{entry.id.substring(0, 7)}
|
||||
</code>
|
||||
<p className="text-sm leading-tight flex-1 first-letter:capitalize">
|
||||
{entry.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(parseSafariCompatibleDate(entry.date), {
|
||||
addSuffix: true,
|
||||
locale: ptBR,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{entries.length === 0 && (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||
Nenhuma atualização recente
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{accountName}
|
||||
</h2>
|
||||
{excludeFromBalance ? (
|
||||
<div
|
||||
className="flex items-center gap-1 text-muted-foreground"
|
||||
title="Excluída do saldo geral"
|
||||
>
|
||||
<RiEyeOffLine className="size-4" aria-hidden />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RiInformationLine className="size-5 text-muted-foreground hover:text-foreground transition-colors cursor-help" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs">
|
||||
<div className="space-y-1">
|
||||
{excludeFromBalance && (
|
||||
<p className="text-xs">
|
||||
<strong>Desconsiderado do saldo total:</strong> Esta conta
|
||||
não é incluída no cálculo do saldo total geral.
|
||||
</p>
|
||||
)}
|
||||
{excludeInitialBalanceFromIncome && (
|
||||
<p className="text-xs">
|
||||
<strong>
|
||||
Saldo inicial desconsiderado das receitas:
|
||||
</strong>{" "}
|
||||
O saldo inicial desta conta não é contabilizado como
|
||||
receita nas métricas.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Saldo</p>
|
||||
<p className="text-3xl text-foreground">
|
||||
<MoneyValues amount={balance} className="text-3xl" />
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<MoneyValues amount={balance} className="text-3xl" />
|
||||
<p className="text-sm text-muted-foreground">{accountType}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -106,17 +106,39 @@ export function AccountFormFields({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 sm:col-span-2">
|
||||
<Checkbox
|
||||
id="exclude-from-balance"
|
||||
checked={values.excludeFromBalance}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("excludeFromBalance", checked ? "true" : "false")
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="exclude-from-balance" className="cursor-pointer text-sm font-normal">
|
||||
Excluir do saldo total (útil para contas de investimento ou reserva)
|
||||
</Label>
|
||||
<div className="flex flex-col gap-3 sm:col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="exclude-from-balance"
|
||||
checked={values.excludeFromBalance === true || values.excludeFromBalance === "true"}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("excludeFromBalance", !!checked ? "true" : "false")
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="exclude-from-balance"
|
||||
className="cursor-pointer text-sm font-normal leading-tight"
|
||||
>
|
||||
Desconsiderar do saldo total (útil para contas de investimento ou
|
||||
reserva)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="exclude-initial-balance-from-income"
|
||||
checked={values.excludeInitialBalanceFromIncome === true || values.excludeInitialBalanceFromIncome === "true"}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("excludeInitialBalanceFromIncome", !!checked ? "true" : "false")
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="exclude-initial-balance-from-income"
|
||||
className="cursor-pointer text-sm font-normal leading-tight"
|
||||
>
|
||||
Desconsiderar o saldo inicial ao calcular o total de receitas
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -137,10 +137,13 @@ export function AccountsPage({ accounts, logoOptions }: AccountsPageProps) {
|
||||
<AccountCard
|
||||
key={account.id}
|
||||
accountName={account.name}
|
||||
accountType={`${account.accountType} - ${account.status}`}
|
||||
accountType={`${account.accountType}`}
|
||||
balance={account.balance ?? account.initialBalance ?? 0}
|
||||
status={account.status}
|
||||
excludeFromBalance={account.excludeFromBalance}
|
||||
excludeInitialBalanceFromIncome={
|
||||
account.excludeInitialBalanceFromIncome
|
||||
}
|
||||
icon={
|
||||
logoSrc ? (
|
||||
<Image
|
||||
|
||||
@@ -8,6 +8,7 @@ export type Account = {
|
||||
initialBalance: number;
|
||||
balance?: number | null;
|
||||
excludeFromBalance?: boolean;
|
||||
excludeInitialBalanceFromIncome?: boolean;
|
||||
};
|
||||
|
||||
export type AccountFormValues = {
|
||||
@@ -18,4 +19,5 @@ export type AccountFormValues = {
|
||||
logo: string;
|
||||
initialBalance: string;
|
||||
excludeFromBalance: boolean;
|
||||
excludeInitialBalanceFromIncome: boolean;
|
||||
};
|
||||
|
||||
@@ -343,7 +343,7 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
|
||||
<RiBarChartBoxLine className="size-6 text-muted-foreground" />
|
||||
}
|
||||
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."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative tracking-tight text-welcome-banner-foreground">
|
||||
|
||||
@@ -76,7 +76,7 @@ export function MyAccountsWidget({
|
||||
return (
|
||||
<li
|
||||
key={account.id}
|
||||
className="flex items-center justify-between gap-2 border-b border-dashed py-1"
|
||||
className="flex items-center justify-between gap-2 border-b border-dashed py-2 last:border-0"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{logoSrc ? (
|
||||
|
||||
@@ -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 (
|
||||
<header className="border-b flex h-12 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||
@@ -31,10 +28,6 @@ export async function SiteHeader({ notificationsSnapshot }: SiteHeaderProps) {
|
||||
<PrivacyModeToggle />
|
||||
<AnimatedThemeToggler />
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<ChangelogNotification
|
||||
unreadCount={unreadCount}
|
||||
entries={allEntries}
|
||||
/>
|
||||
<FeedbackDialog />
|
||||
<LogoutButton />
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -110,10 +110,14 @@ export function LancamentoDetailsDialog({
|
||||
<span className="capitalize">
|
||||
<Badge
|
||||
variant={getTransactionBadgeVariant(
|
||||
lancamento.transactionType
|
||||
lancamento.categoriaName === "Saldo inicial"
|
||||
? "Saldo inicial"
|
||||
: lancamento.transactionType
|
||||
)}
|
||||
>
|
||||
{lancamento.transactionType}
|
||||
{lancamento.categoriaName === "Saldo inicial"
|
||||
? "Saldo Inicial"
|
||||
: lancamento.transactionType}
|
||||
</Badge>
|
||||
</span>
|
||||
</li>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
>
|
||||
<SelectValue placeholder="Selecione" className="w-full">
|
||||
{formState.paymentMethod && (
|
||||
<PaymentMethodSelectContent label={formState.paymentMethod} />
|
||||
<PaymentMethodSelectContent
|
||||
label={formState.paymentMethod}
|
||||
/>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
@@ -138,7 +150,7 @@ export function PaymentMethodSection({
|
||||
<SelectValue placeholder="Selecione">
|
||||
{formState.contaId &&
|
||||
(() => {
|
||||
const selectedOption = contaOptions.find(
|
||||
const selectedOption = filteredContaOptions.find(
|
||||
(opt) => opt.value === formState.contaId
|
||||
);
|
||||
return selectedOption ? (
|
||||
@@ -152,14 +164,14 @@ export function PaymentMethodSection({
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{contaOptions.length === 0 ? (
|
||||
{filteredContaOptions.length === 0 ? (
|
||||
<div className="px-2 py-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhuma conta cadastrada
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
contaOptions.map((option) => (
|
||||
filteredContaOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<ContaCartaoSelectContent
|
||||
label={option.label}
|
||||
@@ -246,7 +258,7 @@ export function PaymentMethodSection({
|
||||
<SelectValue placeholder="Selecione">
|
||||
{formState.contaId &&
|
||||
(() => {
|
||||
const selectedOption = contaOptions.find(
|
||||
const selectedOption = filteredContaOptions.find(
|
||||
(opt) => opt.value === formState.contaId
|
||||
);
|
||||
return selectedOption ? (
|
||||
@@ -260,14 +272,14 @@ export function PaymentMethodSection({
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{contaOptions.length === 0 ? (
|
||||
{filteredContaOptions.length === 0 ? (
|
||||
<div className="px-2 py-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhuma conta cadastrada
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
contaOptions.map((option) => (
|
||||
filteredContaOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<ContaCartaoSelectContent
|
||||
label={option.label}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { groupAndSortCategorias } from "@/lib/lancamentos/categoria-helpers";
|
||||
import { LANCAMENTO_PAYMENT_METHODS } from "@/lib/lancamentos/constants";
|
||||
import { getTodayDateString } from "@/lib/utils/date";
|
||||
import { createMonthOptions } from "@/lib/utils/period";
|
||||
import type { PeriodPreferences } from "@/lib/user-preferences/period";
|
||||
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -51,6 +52,7 @@ interface MassAddDialogProps {
|
||||
categoriaOptions: SelectOption[];
|
||||
estabelecimentos: string[];
|
||||
selectedPeriod: string;
|
||||
periodPreferences: PeriodPreferences;
|
||||
defaultPagadorId?: string | null;
|
||||
}
|
||||
|
||||
@@ -91,6 +93,7 @@ export function MassAddDialog({
|
||||
categoriaOptions,
|
||||
estabelecimentos,
|
||||
selectedPeriod,
|
||||
periodPreferences,
|
||||
defaultPagadorId,
|
||||
}: MassAddDialogProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -119,8 +122,13 @@ export function MassAddDialog({
|
||||
|
||||
// Period options
|
||||
const periodOptions = useMemo(
|
||||
() => createMonthOptions(selectedPeriod, 3),
|
||||
[selectedPeriod]
|
||||
() =>
|
||||
createMonthOptions(
|
||||
selectedPeriod,
|
||||
periodPreferences.monthsBefore,
|
||||
periodPreferences.monthsAfter
|
||||
),
|
||||
[selectedPeriod, periodPreferences.monthsBefore, periodPreferences.monthsAfter]
|
||||
);
|
||||
|
||||
// Categorias agrupadas e filtradas por tipo de transação
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
<LancamentoDialog
|
||||
@@ -394,6 +399,7 @@ export function LancamentosPage({
|
||||
estabelecimentos={estabelecimentos}
|
||||
lancamento={selectedLancamento ?? undefined}
|
||||
defaultPeriod={selectedPeriod}
|
||||
periodPreferences={periodPreferences}
|
||||
onBulkEditRequest={handleBulkEditRequest}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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({
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{formatPeriodLabel(anticipation.anticipationPeriod)}
|
||||
{displayPeriod(anticipation.anticipationPeriod)}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
|
||||
|
||||
@@ -288,16 +288,20 @@ const buildColumns = ({
|
||||
{
|
||||
accessorKey: "transactionType",
|
||||
header: "Transação",
|
||||
cell: ({ row }) => (
|
||||
<TypeBadge
|
||||
type={
|
||||
row.original.transactionType as
|
||||
| "Despesa"
|
||||
| "Receita"
|
||||
| "Transferência"
|
||||
}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const type =
|
||||
row.original.categoriaName === "Saldo inicial"
|
||||
? "Saldo inicial"
|
||||
: row.original.transactionType;
|
||||
|
||||
return (
|
||||
<TypeBadge
|
||||
type={
|
||||
type as "Despesa" | "Receita" | "Transferência" | "Saldo inicial"
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "amount",
|
||||
|
||||
@@ -43,6 +43,7 @@ export type SelectOption = {
|
||||
avatarUrl?: string | null;
|
||||
logo?: string | null;
|
||||
icon?: string | null;
|
||||
accountType?: string | null;
|
||||
};
|
||||
|
||||
export type LancamentoFilterOption = {
|
||||
|
||||
@@ -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<MagnetLinesProps> = ({
|
||||
@@ -22,9 +25,15 @@ const MagnetLines: React.FC<MagnetLinesProps> = ({
|
||||
baseAngle = -10,
|
||||
className = "",
|
||||
style = {},
|
||||
disabled = false,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Se magnetlines estiver desabilitado, não renderiza nada
|
||||
if (disabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<Budget | null>(null);
|
||||
@@ -91,6 +94,7 @@ export function BudgetsPage({
|
||||
mode="create"
|
||||
categories={categories}
|
||||
defaultPeriod={selectedPeriod}
|
||||
periodPreferences={periodPreferences}
|
||||
trigger={
|
||||
<Button disabled={categories.length === 0}>
|
||||
<RiAddCircleLine className="size-4" />
|
||||
@@ -128,6 +132,7 @@ export function BudgetsPage({
|
||||
budget={selectedBudget ?? undefined}
|
||||
categories={categories}
|
||||
defaultPeriod={selectedPeriod}
|
||||
periodPreferences={periodPreferences}
|
||||
open={editOpen && !!selectedBudget}
|
||||
onOpenChange={handleEditOpenChange}
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
RiBankLine,
|
||||
RiCalendarEventLine,
|
||||
RiDashboardLine,
|
||||
RiGitCommitLine,
|
||||
RiFundsLine,
|
||||
RiGroupLine,
|
||||
RiLineChartLine,
|
||||
@@ -161,6 +162,11 @@ export function createSidebarNavData(pagadores: PagadorLike[]): SidebarNavData {
|
||||
},
|
||||
],
|
||||
navSecondary: [
|
||||
{
|
||||
title: "Changelog",
|
||||
url: "/changelog",
|
||||
icon: RiGitCommitLine,
|
||||
},
|
||||
{
|
||||
title: "Ajustes",
|
||||
url: "/ajustes",
|
||||
|
||||
@@ -8,10 +8,12 @@ type TypeBadgeType =
|
||||
| "Receita"
|
||||
| "Despesa"
|
||||
| "Transferência"
|
||||
| "transferência";
|
||||
| "transferência"
|
||||
| "Saldo inicial"
|
||||
| "Saldo Inicial";
|
||||
|
||||
interface TypeBadgeProps {
|
||||
type: TypeBadgeType;
|
||||
type: TypeBadgeType | string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -22,23 +24,26 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
Despesa: "Despesa",
|
||||
Transferência: "Transferência",
|
||||
transferência: "Transferência",
|
||||
"Saldo inicial": "Saldo Inicial",
|
||||
"Saldo Inicial": "Saldo Inicial",
|
||||
};
|
||||
|
||||
export function TypeBadge({ type, className }: TypeBadgeProps) {
|
||||
const normalizedType = type.toLowerCase();
|
||||
const isReceita = normalizedType === "receita";
|
||||
const isTransferencia = normalizedType === "transferência";
|
||||
const isSaldoInicial = normalizedType === "saldo inicial";
|
||||
const label = TYPE_LABELS[type] || type;
|
||||
|
||||
const colorClass = isTransferencia
|
||||
? "text-blue-700 dark:text-blue-400"
|
||||
: isReceita
|
||||
: (isReceita || isSaldoInicial)
|
||||
? "text-green-700 dark:text-green-400"
|
||||
: "text-red-700 dark:text-red-400";
|
||||
|
||||
const dotColor = isTransferencia
|
||||
? "bg-blue-700 dark:bg-blue-400"
|
||||
: isReceita
|
||||
: (isReceita || isSaldoInicial)
|
||||
? "bg-green-600 dark:bg-green-400"
|
||||
: "bg-red-600 dark:bg-red-400";
|
||||
|
||||
|
||||
65
components/ui/accordion.tsx
Normal file
65
components/ui/accordion.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RiArrowDownBoxFill } from "@remixicon/react";
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<RiArrowDownBoxFill className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
62
db/schema.ts
62
db/schema.ts
@@ -100,6 +100,31 @@ export const verification = pgTable("verification", {
|
||||
}),
|
||||
});
|
||||
|
||||
export const userPreferences = pgTable("user_preferences", {
|
||||
id: uuid("id")
|
||||
.primaryKey()
|
||||
.default(sql`gen_random_uuid()`),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
disableMagnetlines: boolean("disable_magnetlines").notNull().default(false),
|
||||
periodMonthsBefore: integer("period_months_before").notNull().default(3),
|
||||
periodMonthsAfter: integer("period_months_after").notNull().default(3),
|
||||
createdAt: timestamp("created_at", {
|
||||
mode: "date",
|
||||
withTimezone: true,
|
||||
})
|
||||
.notNull()
|
||||
.default(sql`now()`),
|
||||
updatedAt: timestamp("updated_at", {
|
||||
mode: "date",
|
||||
withTimezone: true,
|
||||
})
|
||||
.notNull()
|
||||
.default(sql`now()`),
|
||||
});
|
||||
|
||||
// ===================== PUBLIC TABLES =====================
|
||||
|
||||
export const contas = pgTable(
|
||||
@@ -119,6 +144,9 @@ export const contas = pgTable(
|
||||
excludeFromBalance: boolean("excluir_do_saldo")
|
||||
.notNull()
|
||||
.default(false),
|
||||
excludeInitialBalanceFromIncome: boolean("excluir_saldo_inicial_receitas")
|
||||
.notNull()
|
||||
.default(false),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
@@ -359,30 +387,6 @@ export const anotacoes = pgTable("anotacoes", {
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const userUpdateLog = pgTable(
|
||||
"user_update_log",
|
||||
{
|
||||
id: uuid("id")
|
||||
.primaryKey()
|
||||
.default(sql`gen_random_uuid()`),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
updateId: text("update_id").notNull(), // commit hash
|
||||
readAt: timestamp("read_at", {
|
||||
mode: "date",
|
||||
withTimezone: true,
|
||||
})
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
userIdUpdateIdIdx: uniqueIndex("user_update_log_user_update_idx").on(
|
||||
table.userId,
|
||||
table.updateId
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
export const savedInsights = pgTable(
|
||||
"saved_insights",
|
||||
@@ -556,7 +560,6 @@ export const userRelations = relations(user, ({ many, one }) => ({
|
||||
orcamentos: many(orcamentos),
|
||||
pagadores: many(pagadores),
|
||||
installmentAnticipations: many(installmentAnticipations),
|
||||
updateLogs: many(userUpdateLog),
|
||||
}));
|
||||
|
||||
export const accountRelations = relations(account, ({ one }) => ({
|
||||
@@ -664,12 +667,6 @@ export const savedInsightsRelations = relations(savedInsights, ({ one }) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
export const userUpdateLogRelations = relations(userUpdateLog, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [userUpdateLog.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const lancamentosRelations = relations(lancamentos, ({ one }) => ({
|
||||
user: one(user, {
|
||||
@@ -725,6 +722,8 @@ export type NewUser = typeof user.$inferInsert;
|
||||
export type Account = typeof account.$inferSelect;
|
||||
export type Session = typeof session.$inferSelect;
|
||||
export type Verification = typeof verification.$inferSelect;
|
||||
export type UserPreferences = typeof userPreferences.$inferSelect;
|
||||
export type NewUserPreferences = typeof userPreferences.$inferInsert;
|
||||
export type Conta = typeof contas.$inferSelect;
|
||||
export type Categoria = typeof categorias.$inferSelect;
|
||||
export type Pagador = typeof pagadores.$inferSelect;
|
||||
@@ -736,4 +735,3 @@ export type SavedInsight = typeof savedInsights.$inferSelect;
|
||||
export type Lancamento = typeof lancamentos.$inferSelect;
|
||||
export type InstallmentAnticipation =
|
||||
typeof installmentAnticipations.$inferSelect;
|
||||
export type UserUpdateLog = typeof userUpdateLog.$inferSelect;
|
||||
|
||||
@@ -3,12 +3,11 @@ name: opensheets
|
||||
|
||||
# MODOS DE USO:
|
||||
# 1. Banco LOCAL (PostgreSQL em container):
|
||||
# - Configure DB_PROVIDER=local no .env
|
||||
# - Configure DATABASE_URL com host "db" no .env
|
||||
# - Execute: docker compose up --build
|
||||
#
|
||||
# 2. Banco REMOTO (ex: Supabase):
|
||||
# - Configure DB_PROVIDER=remote no .env
|
||||
# - Configure DATABASE_URL com a URL do banco remoto
|
||||
# - Configure DATABASE_URL com a URL do banco remoto no .env
|
||||
# - Execute: docker compose up app --build (apenas o serviço app)
|
||||
#
|
||||
# 3. Para parar todos os serviços:
|
||||
@@ -81,9 +80,9 @@ services:
|
||||
# Variáveis de ambiente da aplicação
|
||||
NODE_ENV: production
|
||||
|
||||
# DATABASE_URL será definida dinamicamente baseada em DB_PROVIDER
|
||||
# Se DB_PROVIDER=local, usa o serviço 'db'
|
||||
# Se DB_PROVIDER=remote, usa a DATABASE_URL do .env
|
||||
# DATABASE_URL do .env
|
||||
# Banco local: use host "db" (serviço Docker)
|
||||
# Banco remoto: use a URL completa do provider
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
|
||||
# Outras variáveis de ambiente necessárias
|
||||
|
||||
1
drizzle/0003_green_korg.sql
Normal file
1
drizzle/0003_green_korg.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "anotacoes" ADD COLUMN "arquivada" boolean DEFAULT false NOT NULL;
|
||||
1
drizzle/0004_acoustic_mach_iv.sql
Normal file
1
drizzle/0004_acoustic_mach_iv.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "contas" ADD COLUMN "excluir_saldo_inicial_receitas" boolean DEFAULT false NOT NULL;
|
||||
1
drizzle/0005_adorable_bruce_banner.sql
Normal file
1
drizzle/0005_adorable_bruce_banner.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user" ADD COLUMN "disable_magnetlines" boolean DEFAULT false NOT NULL;
|
||||
17
drizzle/0006_youthful_mister_fear.sql
Normal file
17
drizzle/0006_youthful_mister_fear.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE "user_preferences" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"disable_magnetlines" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "user_preferences_user_id_unique" UNIQUE("user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user_preferences" ADD CONSTRAINT "user_preferences_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
-- Migrate existing data from user table to user_preferences
|
||||
INSERT INTO "user_preferences" ("user_id", "disable_magnetlines", "created_at", "updated_at")
|
||||
SELECT "id", COALESCE("disable_magnetlines", false), now(), now()
|
||||
FROM "user"
|
||||
WHERE "disable_magnetlines" IS NOT NULL OR "disable_magnetlines" = true;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "disable_magnetlines";
|
||||
1
drizzle/0007_sturdy_kate_bishop.sql
Normal file
1
drizzle/0007_sturdy_kate_bishop.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE "user_update_log" CASCADE;
|
||||
2
drizzle/0008_fat_stick.sql
Normal file
2
drizzle/0008_fat_stick.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "user_preferences" ADD COLUMN "period_months_before" integer DEFAULT 3 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "user_preferences" ADD COLUMN "period_months_after" integer DEFAULT 3 NOT NULL;
|
||||
1951
drizzle/meta/0003_snapshot.json
Normal file
1951
drizzle/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1958
drizzle/meta/0004_snapshot.json
Normal file
1958
drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1965
drizzle/meta/0005_snapshot.json
Normal file
1965
drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2027
drizzle/meta/0006_snapshot.json
Normal file
2027
drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1952
drizzle/meta/0007_snapshot.json
Normal file
1952
drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1966
drizzle/meta/0008_snapshot.json
Normal file
1966
drizzle/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,48 @@
|
||||
"when": 1765200545692,
|
||||
"tag": "0002_slimy_flatman",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1767102605526,
|
||||
"tag": "0003_green_korg",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1767104066872,
|
||||
"tag": "0004_acoustic_mach_iv",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1767106121811,
|
||||
"tag": "0005_adorable_bruce_banner",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1767107487318,
|
||||
"tag": "0006_youthful_mister_fear",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1767118780033,
|
||||
"tag": "0007_sturdy_kate_bishop",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1767125796314,
|
||||
"tag": "0008_fat_stick",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { userUpdateLog } from "@/db/schema";
|
||||
import { successResult, type ActionResult } from "@/lib/actions/types";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { handleActionError } from "../actions/helpers";
|
||||
|
||||
export async function markUpdateAsRead(
|
||||
updateId: string
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
|
||||
// Check if already marked as read
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(userUpdateLog)
|
||||
.where(
|
||||
and(
|
||||
eq(userUpdateLog.userId, user.id),
|
||||
eq(userUpdateLog.updateId, updateId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
return successResult("Já marcado como lido");
|
||||
}
|
||||
|
||||
await db.insert(userUpdateLog).values({
|
||||
userId: user.id,
|
||||
updateId,
|
||||
});
|
||||
|
||||
return successResult("Marcado como lido");
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function markAllUpdatesAsRead(
|
||||
updateIds: string[]
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
|
||||
// Get existing read updates
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(userUpdateLog)
|
||||
.where(eq(userUpdateLog.userId, user.id));
|
||||
|
||||
const existingIds = new Set(existing.map((log) => log.updateId));
|
||||
|
||||
// Filter out already read updates
|
||||
const newUpdateIds = updateIds.filter((id) => !existingIds.has(id));
|
||||
|
||||
if (newUpdateIds.length === 0) {
|
||||
return successResult("Todos já marcados como lidos");
|
||||
}
|
||||
|
||||
// Insert new read logs
|
||||
await db.insert(userUpdateLog).values(
|
||||
newUpdateIds.map((updateId) => ({
|
||||
userId: user.id,
|
||||
updateId,
|
||||
}))
|
||||
);
|
||||
|
||||
return successResult("Todas as atualizações marcadas como lidas");
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { userUpdateLog } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export interface ChangelogEntry {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
date: string;
|
||||
icon: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface Changelog {
|
||||
version: string;
|
||||
generatedAt: string;
|
||||
entries: ChangelogEntry[];
|
||||
}
|
||||
|
||||
export function getChangelog(): Changelog {
|
||||
try {
|
||||
const changelogPath = path.join(process.cwd(), "public", "changelog.json");
|
||||
|
||||
if (!fs.existsSync(changelogPath)) {
|
||||
return {
|
||||
version: "1.0.0",
|
||||
generatedAt: new Date().toISOString(),
|
||||
entries: [],
|
||||
};
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(changelogPath, "utf-8");
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
console.error("Error reading changelog:", error);
|
||||
return {
|
||||
version: "1.0.0",
|
||||
generatedAt: new Date().toISOString(),
|
||||
entries: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUnreadUpdates(userId: string) {
|
||||
const changelog = getChangelog();
|
||||
|
||||
if (changelog.entries.length === 0) {
|
||||
return {
|
||||
unreadCount: 0,
|
||||
unreadEntries: [],
|
||||
allEntries: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Get read updates from database
|
||||
const readLogs = await db
|
||||
.select()
|
||||
.from(userUpdateLog)
|
||||
.where(eq(userUpdateLog.userId, userId));
|
||||
|
||||
const readUpdateIds = new Set(readLogs.map((log) => log.updateId));
|
||||
|
||||
// Filter unread entries
|
||||
const unreadEntries = changelog.entries.filter(
|
||||
(entry) => !readUpdateIds.has(entry.id)
|
||||
);
|
||||
|
||||
return {
|
||||
unreadCount: unreadEntries.length,
|
||||
unreadEntries,
|
||||
allEntries: changelog.entries,
|
||||
};
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { ChangelogEntry } from "./data";
|
||||
|
||||
/**
|
||||
* Converte uma string de data para um formato compatível com Safari.
|
||||
* Safari não aceita "YYYY-MM-DD HH:mm:ss ±HHMM", requer "YYYY-MM-DDTHH:mm:ss±HHMM"
|
||||
*
|
||||
* @param dateString - String de data no formato "YYYY-MM-DD HH:mm:ss ±HHMM"
|
||||
* @returns Date object válido
|
||||
*/
|
||||
export function parseSafariCompatibleDate(dateString: string): Date {
|
||||
// Substitui o espaço entre data e hora por "T" (formato ISO 8601)
|
||||
// Exemplo: "2025-12-09 17:26:08 +0000" → "2025-12-09T17:26:08+0000"
|
||||
const isoString = dateString.replace(/(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s+/, "$1T$2");
|
||||
return new Date(isoString);
|
||||
}
|
||||
|
||||
export function getCategoryLabel(category: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
feature: "Novidades",
|
||||
bugfix: "Correções",
|
||||
performance: "Performance",
|
||||
documentation: "Documentação",
|
||||
style: "Interface",
|
||||
refactor: "Melhorias",
|
||||
test: "Testes",
|
||||
chore: "Manutenção",
|
||||
other: "Outros",
|
||||
};
|
||||
return labels[category] || "Outros";
|
||||
}
|
||||
|
||||
export function groupEntriesByCategory(entries: ChangelogEntry[]) {
|
||||
return entries.reduce(
|
||||
(acc, entry) => {
|
||||
if (!acc[entry.category]) {
|
||||
acc[entry.category] = [];
|
||||
}
|
||||
acc[entry.category].push(entry);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, ChangelogEntry[]>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { categorias, lancamentos, pagadores } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
|
||||
import { categorias, lancamentos, pagadores, contas } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
|
||||
import type { CategoryType } from "@/lib/categorias/constants";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { db } from "@/lib/db";
|
||||
import { mapLancamentosData } from "@/lib/lancamentos/page-helpers";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { getPreviousPeriod } from "@/lib/utils/period";
|
||||
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, isNull, or, sql, ne } from "drizzle-orm";
|
||||
|
||||
type MappedLancamentos = ReturnType<typeof mapLancamentosData>;
|
||||
|
||||
@@ -81,7 +81,17 @@ export async function fetchCategoryDetails(
|
||||
});
|
||||
|
||||
const filteredRows = currentRows.filter(
|
||||
(row) => row.pagador?.role === PAGADOR_ROLE_ADMIN
|
||||
(row) => {
|
||||
// Filtrar apenas pagadores admin
|
||||
if (row.pagador?.role !== PAGADOR_ROLE_ADMIN) return false;
|
||||
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
if (row.note === INITIAL_BALANCE_NOTE && row.conta?.excludeInitialBalanceFromIncome) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
const transactions = mapLancamentosData(filteredRows);
|
||||
@@ -97,6 +107,7 @@ export async function fetchCategoryDetails(
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
@@ -104,7 +115,13 @@ export async function fetchCategoryDetails(
|
||||
eq(lancamentos.transactionType, transactionType),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
sanitizedNote,
|
||||
eq(lancamentos.period, previousPeriod)
|
||||
eq(lancamentos.period, previousPeriod),
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
or(
|
||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
||||
isNull(contas.excludeInitialBalanceFromIncome),
|
||||
eq(contas.excludeInitialBalanceFromIncome, false)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -65,14 +65,15 @@ export async function fetchCategoryHistory(
|
||||
userId: string,
|
||||
currentPeriod: string
|
||||
): Promise<CategoryHistoryData> {
|
||||
// Generate last 6 months including current
|
||||
// Generate last 8 months, current month, and next month (10 total)
|
||||
const periods: string[] = [];
|
||||
const monthLabels: string[] = [];
|
||||
|
||||
const [year, month] = currentPeriod.split("-").map(Number);
|
||||
const currentDate = new Date(year, month - 1, 1);
|
||||
|
||||
for (let i = 8; i >= 0; i--) {
|
||||
// Generate months from -8 to +1 (relative to current)
|
||||
for (let i = 8; i >= -1; i--) {
|
||||
const date = addMonths(currentDate, -i);
|
||||
const period = format(date, "yyyy-MM");
|
||||
const label = format(date, "MMM", { locale: ptBR }).toUpperCase();
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { categorias, lancamentos, orcamentos, pagadores } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
|
||||
import { categorias, lancamentos, orcamentos, pagadores, contas } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { getPreviousPeriod } from "@/lib/utils/period";
|
||||
import { calculatePercentageChange } from "@/lib/utils/math";
|
||||
import { safeToNumber } from "@/lib/utils/number";
|
||||
import { and, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { and, eq, isNull, or, sql, ne } from "drizzle-orm";
|
||||
|
||||
export type CategoryIncomeItem = {
|
||||
categoryId: string;
|
||||
@@ -43,6 +43,7 @@ export async function fetchIncomeByCategory(
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.leftJoin(
|
||||
orcamentos,
|
||||
and(
|
||||
@@ -61,6 +62,12 @@ export async function fetchIncomeByCategory(
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
),
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
or(
|
||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
||||
isNull(contas.excludeInitialBalanceFromIncome),
|
||||
eq(contas.excludeInitialBalanceFromIncome, false)
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -75,6 +82,7 @@ export async function fetchIncomeByCategory(
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
@@ -85,6 +93,12 @@ export async function fetchIncomeByCategory(
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
),
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
or(
|
||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
||||
isNull(contas.excludeInitialBalanceFromIncome),
|
||||
eq(contas.excludeInitialBalanceFromIncome, false)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { lancamentos, pagadores } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
|
||||
import { lancamentos, pagadores, contas } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { and, eq, sql, or, isNull, ne } from "drizzle-orm";
|
||||
|
||||
export type MonthData = {
|
||||
month: string;
|
||||
@@ -79,6 +79,7 @@ export async function fetchIncomeExpenseBalance(
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
@@ -87,7 +88,13 @@ export async function fetchIncomeExpenseBalance(
|
||||
eq(pagadores.role, "admin"),
|
||||
sql`(${lancamentos.note} IS NULL OR ${
|
||||
lancamentos.note
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`,
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
or(
|
||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
||||
isNull(contas.excludeInitialBalanceFromIncome),
|
||||
eq(contas.excludeInitialBalanceFromIncome, false)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { lancamentos, pagadores } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
|
||||
import { lancamentos, pagadores, contas } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import {
|
||||
@@ -74,6 +74,7 @@ export async function fetchDashboardCardMetrics(
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
@@ -88,6 +89,12 @@ export async function fetchDashboardCardMetrics(
|
||||
`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`
|
||||
)
|
||||
)
|
||||
),
|
||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
||||
or(
|
||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
||||
isNull(contas.excludeInitialBalanceFromIncome),
|
||||
eq(contas.excludeInitialBalanceFromIncome, false)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -12,4 +12,6 @@ export const LANCAMENTO_PAYMENT_METHODS = [
|
||||
"Pix",
|
||||
"Dinheiro",
|
||||
"Boleto",
|
||||
"Pré-Pago | VR/VA",
|
||||
"Transferência bancária",
|
||||
] as const;
|
||||
|
||||
@@ -110,7 +110,7 @@ export function buildLancamentoInitialState(
|
||||
isSettled:
|
||||
paymentMethod === "Cartão de crédito"
|
||||
? null
|
||||
: lancamento?.isSettled ?? false,
|
||||
: lancamento?.isSettled ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ export function applyFieldDependencies(
|
||||
updates.isSettled = null;
|
||||
} else {
|
||||
updates.cartaoId = undefined;
|
||||
updates.isSettled = currentState.isSettled ?? false;
|
||||
updates.isSettled = currentState.isSettled ?? true;
|
||||
}
|
||||
|
||||
// Clear boleto-specific fields if not boleto
|
||||
|
||||
@@ -80,7 +80,10 @@ export function formatCondition(value?: string | null): string {
|
||||
*/
|
||||
export function getTransactionBadgeVariant(type?: string | null): "default" | "destructive" | "secondary" {
|
||||
if (!type) return "secondary";
|
||||
return type.toLowerCase() === "receita" ? "default" : "destructive";
|
||||
const normalized = type.toLowerCase();
|
||||
return normalized === "receita" || normalized === "saldo inicial"
|
||||
? "default"
|
||||
: "destructive";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -55,6 +55,7 @@ type CategoriaSluggedOption = BaseSluggedOption & {
|
||||
type ContaSluggedOption = BaseSluggedOption & {
|
||||
kind: "conta";
|
||||
logo: string | null;
|
||||
accountType: string | null;
|
||||
};
|
||||
|
||||
type CartaoSluggedOption = BaseSluggedOption & {
|
||||
@@ -154,7 +155,8 @@ export const toOption = (
|
||||
slug?: string | null,
|
||||
avatarUrl?: string | null,
|
||||
logo?: string | null,
|
||||
icon?: string | null
|
||||
icon?: string | null,
|
||||
accountType?: string | null
|
||||
): SelectOption => ({
|
||||
value,
|
||||
label: normalizeLabel(label),
|
||||
@@ -164,6 +166,7 @@ export const toOption = (
|
||||
avatarUrl: avatarUrl ?? null,
|
||||
logo: logo ?? null,
|
||||
icon: icon ?? null,
|
||||
accountType: accountType ?? null,
|
||||
});
|
||||
|
||||
export const fetchLancamentoFilterSources = async (userId: string) => {
|
||||
@@ -234,6 +237,7 @@ export const buildSluggedFilters = ({
|
||||
slug: contaCartaoSlugger(label),
|
||||
kind: "conta" as const,
|
||||
logo: conta.logo ?? null,
|
||||
accountType: conta.accountType ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -468,8 +472,8 @@ export const buildOptionSets = ({
|
||||
: contaFiltersRaw;
|
||||
|
||||
const contaOptions = sortByLabel(
|
||||
contaOptionsSource.map(({ id, label, slug, logo }) =>
|
||||
toOption(id, label, undefined, undefined, slug, undefined, logo)
|
||||
contaOptionsSource.map(({ id, label, slug, logo, accountType }) =>
|
||||
toOption(id, label, undefined, undefined, slug, undefined, logo, undefined, accountType)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
32
lib/user-preferences/period.ts
Normal file
32
lib/user-preferences/period.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { db, schema } from "@/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export type PeriodPreferences = {
|
||||
monthsBefore: number;
|
||||
monthsAfter: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches period preferences for a user
|
||||
* @param userId - User ID
|
||||
* @returns Period preferences with defaults if not found
|
||||
*/
|
||||
export async function fetchUserPeriodPreferences(
|
||||
userId: string
|
||||
): Promise<PeriodPreferences> {
|
||||
const result = await db
|
||||
.select({
|
||||
periodMonthsBefore: schema.userPreferences.periodMonthsBefore,
|
||||
periodMonthsAfter: schema.userPreferences.periodMonthsAfter,
|
||||
})
|
||||
.from(schema.userPreferences)
|
||||
.where(eq(schema.userPreferences.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
const preferences = result[0];
|
||||
|
||||
return {
|
||||
monthsBefore: preferences?.periodMonthsBefore ?? 3,
|
||||
monthsAfter: preferences?.periodMonthsAfter ?? 3,
|
||||
};
|
||||
}
|
||||
@@ -55,6 +55,8 @@ export const getPaymentMethodIcon = (paymentMethod: string): ReactNode => {
|
||||
<RemixIcons.RiBankCardLine className={ICON_CLASS} aria-hidden />
|
||||
),
|
||||
debito: <RemixIcons.RiBankCardLine className={ICON_CLASS} aria-hidden />,
|
||||
prepagovrva: <RemixIcons.RiCouponLine className={ICON_CLASS} aria-hidden />,
|
||||
transferenciabancaria: <RemixIcons.RiExchangeLine className={ICON_CLASS} aria-hidden />,
|
||||
};
|
||||
|
||||
return registry[key] ?? null;
|
||||
|
||||
@@ -367,17 +367,24 @@ export type SelectOption = {
|
||||
/**
|
||||
* Creates month options for a select dropdown, centered around current month
|
||||
* @param currentValue - Current period value to ensure it's included in options
|
||||
* @param offsetRange - Number of months before/after current month (default: 3)
|
||||
* @param monthsBefore - Number of months before current month (default: 3)
|
||||
* @param monthsAfter - Number of months after current month (default: same as monthsBefore)
|
||||
* @returns Array of select options with formatted labels
|
||||
* @example
|
||||
* createMonthOptions() // -3 to +3
|
||||
* createMonthOptions(undefined, 3) // -3 to +3
|
||||
* createMonthOptions(undefined, 3, 6) // -3 to +6
|
||||
*/
|
||||
export function createMonthOptions(
|
||||
currentValue?: string,
|
||||
offsetRange: number = 3
|
||||
monthsBefore: number = 3,
|
||||
monthsAfter?: number
|
||||
): SelectOption[] {
|
||||
const now = new Date();
|
||||
const options: SelectOption[] = [];
|
||||
const after = monthsAfter ?? monthsBefore; // If not specified, use same as before
|
||||
|
||||
for (let offset = -offsetRange; offset <= offsetRange; offset += 1) {
|
||||
for (let offset = -monthsBefore; offset <= after; offset += 1) {
|
||||
const date = new Date(now.getFullYear(), now.getMonth() + offset, 1);
|
||||
const value = formatPeriod(date.getFullYear(), date.getMonth() + 1);
|
||||
options.push({ value, label: displayPeriod(value) });
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"dev-env": "tsx scripts/dev.ts",
|
||||
"prebuild": "tsx scripts/generate-changelog.ts",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
@@ -32,6 +31,7 @@
|
||||
"@ai-sdk/google": "^3.0.1",
|
||||
"@ai-sdk/openai": "^3.0.1",
|
||||
"@openrouter/ai-sdk-provider": "^1.5.4",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "1.1.15",
|
||||
"@radix-ui/react-avatar": "1.1.11",
|
||||
"@radix-ui/react-checkbox": "1.3.3",
|
||||
@@ -52,7 +52,7 @@
|
||||
"@radix-ui/react-toggle": "1.1.10",
|
||||
"@radix-ui/react-toggle-group": "1.1.11",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@remixicon/react": "4.7.0",
|
||||
"@remixicon/react": "4.8.0",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"@vercel/speed-insights": "^1.3.1",
|
||||
|
||||
43
pnpm-lock.yaml
generated
43
pnpm-lock.yaml
generated
@@ -20,6 +20,9 @@ importers:
|
||||
'@openrouter/ai-sdk-provider':
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4(ai@6.0.3(zod@4.2.1))(zod@4.2.1)
|
||||
'@radix-ui/react-accordion':
|
||||
specifier: ^1.2.12
|
||||
version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-alert-dialog':
|
||||
specifier: 1.1.15
|
||||
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -81,8 +84,8 @@ importers:
|
||||
specifier: 1.2.8
|
||||
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@remixicon/react':
|
||||
specifier: 4.7.0
|
||||
version: 4.7.0(react@19.2.3)
|
||||
specifier: 4.8.0
|
||||
version: 4.8.0(react@19.2.3)
|
||||
'@tanstack/react-table':
|
||||
specifier: 8.21.3
|
||||
version: 8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -1120,6 +1123,19 @@ packages:
|
||||
'@radix-ui/primitive@1.1.3':
|
||||
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
||||
|
||||
'@radix-ui/react-accordion@1.2.12':
|
||||
resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-alert-dialog@1.1.15':
|
||||
resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==}
|
||||
peerDependencies:
|
||||
@@ -1690,8 +1706,8 @@ packages:
|
||||
react-redux:
|
||||
optional: true
|
||||
|
||||
'@remixicon/react@4.7.0':
|
||||
resolution: {integrity: sha512-ODBQjdbOjnFguCqctYkpDjERXOInNaBnRPDKfZOBvbzExBAwr2BaH/6AHFTg/UAFzBDkwtylfMT8iKPAkLwPLQ==}
|
||||
'@remixicon/react@4.8.0':
|
||||
resolution: {integrity: sha512-cbzR04GKWa3zWdgn0C2i+u/avb167iWeu9gqFO00UGu84meARPAm3oKowDZTU6dlk/WS3UHo6k//LMRM1l7CRw==}
|
||||
peerDependencies:
|
||||
react: '>=18.2.0'
|
||||
|
||||
@@ -4773,6 +4789,23 @@ snapshots:
|
||||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
|
||||
'@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.7
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||
|
||||
'@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
@@ -5357,7 +5390,7 @@ snapshots:
|
||||
react: 19.2.3
|
||||
react-redux: 9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1)
|
||||
|
||||
'@remixicon/react@4.7.0(react@19.2.3)':
|
||||
'@remixicon/react@4.8.0(react@19.2.3)':
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"generatedAt": "2025-12-13T13:52:07.921Z",
|
||||
"entries": [
|
||||
{
|
||||
"id": "0767636eed5085a211e08de52577beed658f05cf",
|
||||
"type": "feat",
|
||||
"title": "ajustar layout e estilos",
|
||||
"date": "2025-12-11 17:43:33 +0000",
|
||||
"icon": "✨",
|
||||
"category": "feature"
|
||||
},
|
||||
{
|
||||
"id": "0744991edd717748fce24e148907eafd7222c64e",
|
||||
"type": "chore",
|
||||
"title": "remove unused code and clean up imports",
|
||||
"date": "2025-12-10 16:53:19 +0000",
|
||||
"icon": "🔧",
|
||||
"category": "chore"
|
||||
},
|
||||
{
|
||||
"id": "b767bd959955854e54b41270e9c220fe0b546947",
|
||||
"type": "feat",
|
||||
"title": "adicionar widgets de despesas e receitas com gráfico - Adiciona o widget de despesas por categoria com gráfico. - Adiciona o widget de receitas por categoria com gráfico. - Atualiza a configuração dos widgets para incluir novos componentes. - Ajusta estilos e tamanhos de elementos nos widgets existentes.",
|
||||
"date": "2025-12-10 16:51:45 +0000",
|
||||
"icon": "✨",
|
||||
"category": "feature"
|
||||
},
|
||||
{
|
||||
"id": "89765d4373b820a3e7c8e4fa40479dd2673558b0",
|
||||
"type": "chore",
|
||||
"title": "remover arquivo PLAN.md",
|
||||
"date": "2025-12-09 17:26:08 +0000",
|
||||
"icon": "🔧",
|
||||
"category": "chore"
|
||||
},
|
||||
{
|
||||
"id": "95d6a45a95c1a383dfa532aa72f764fcd4bff64e",
|
||||
"type": "feat",
|
||||
"title": "adicionar análise e sugestões para OpenSheets",
|
||||
"date": "2025-12-09 17:24:07 +0000",
|
||||
"icon": "✨",
|
||||
"category": "feature"
|
||||
},
|
||||
{
|
||||
"id": "0c445ee4a5a70dbeee834ba511ace1ea79471ada",
|
||||
"type": "feat",
|
||||
"title": "adicionar alerta de privacidade e ajustar estilos",
|
||||
"date": "2025-12-09 17:23:45 +0000",
|
||||
"icon": "✨",
|
||||
"category": "feature"
|
||||
},
|
||||
{
|
||||
"id": "ed2b7070ebd14c3274dcd515613d5eebbd990b24",
|
||||
"type": "feat",
|
||||
"title": "adicionar funcionalidades de leitura de atualizações",
|
||||
"date": "2025-12-08 15:17:10 +0000",
|
||||
"icon": "✨",
|
||||
"category": "feature"
|
||||
},
|
||||
{
|
||||
"id": "b7fcba77b7ed0f887ba26e2b0ceae19904e140cd",
|
||||
"type": "feat",
|
||||
"title": "implementar funcionalidades de leitura de atualizações - Adiciona funções para marcar atualizações como lidas - Implementa a lógica para marcar todas as atualizações como lidas - Adiciona suporte a logs de atualizações lidas no banco de dados - Cria funções utilitárias para manipulação de changelog - Gera changelog a partir de commits do Git - Salva changelog em formato JSON na pasta pública perf: adicionar índices de banco de dados para otimização de queries - Cria 14 índices compostos em tabelas principais (lancamentos, contas, etc) - Adiciona índice user_id + period em lancamentos, faturas e orçamentos - Adiciona índices para séries de parcelas e transferências",
|
||||
"date": "2025-12-08 14:56:50 +0000",
|
||||
"icon": "✨",
|
||||
"category": "feature"
|
||||
},
|
||||
{
|
||||
"id": "7a4a947e3fa4f78f174d1042906828045cbf6eaf",
|
||||
"type": "fix",
|
||||
"title": "atualizar dependências do projeto",
|
||||
"date": "2025-12-07 18:50:00 +0000",
|
||||
"icon": "🐛",
|
||||
"category": "bugfix"
|
||||
},
|
||||
{
|
||||
"id": "244534921b9b10fbff79777a024da17a45722bce",
|
||||
"type": "fix",
|
||||
"title": "replace session cookie validation with actual session check in proxy middleware",
|
||||
"date": "2025-12-07 09:50:55 -0300",
|
||||
"icon": "🐛",
|
||||
"category": "bugfix"
|
||||
},
|
||||
{
|
||||
"id": "de3d99a3b1a398ae01eec0f65f03309648cbe24d",
|
||||
"type": "fix",
|
||||
"title": "add error handling for internal server error in login form",
|
||||
"date": "2025-12-06 07:35:25 -0300",
|
||||
"icon": "🐛",
|
||||
"category": "bugfix"
|
||||
},
|
||||
{
|
||||
"id": "9d03387079d9ff867d0309522d5cb8989075bc2f",
|
||||
"type": "fix",
|
||||
"title": "adjust padding and layout in various dashboard widgets for improved UI consistency",
|
||||
"date": "2025-12-02 13:54:13 +0000",
|
||||
"icon": "🐛",
|
||||
"category": "bugfix"
|
||||
},
|
||||
{
|
||||
"id": "c834648d395e58a6fb62c620a0c5e2ee4d1b8a4f",
|
||||
"type": "fix",
|
||||
"title": "corrige condição de análise de gastos parcelados",
|
||||
"date": "2025-12-01 00:16:50 +0000",
|
||||
"icon": "🐛",
|
||||
"category": "bugfix"
|
||||
},
|
||||
{
|
||||
"id": "47038ae687e5c6d611009171a5730f3c1477aa78",
|
||||
"type": "fix",
|
||||
"title": "corrige timezone e seleção de parcelas na análise de parcelas",
|
||||
"date": "2025-11-29 18:26:28 +0000",
|
||||
"icon": "🐛",
|
||||
"category": "bugfix"
|
||||
},
|
||||
{
|
||||
"id": "cf5a0b7745bf2ade4970e7e15c29bdb643955878",
|
||||
"type": "feat",
|
||||
"title": "implement category history widget and loading state for category history page",
|
||||
"date": "2025-11-28 13:42:21 +0000",
|
||||
"icon": "✨",
|
||||
"category": "feature"
|
||||
},
|
||||
{
|
||||
"id": "bf1a310c286e39664908ca989ffda0d3cea4ef3c",
|
||||
"type": "feat",
|
||||
"title": "add AI coding assistant instructions and update Node.js version requirement in README",
|
||||
"date": "2025-11-28 01:30:09 -0300",
|
||||
"icon": "✨",
|
||||
"category": "feature"
|
||||
},
|
||||
{
|
||||
"id": "2d8d677bcc85d863b2aee58b0c9144a62588173a",
|
||||
"type": "fix",
|
||||
"title": "update dependencies to latest versions",
|
||||
"date": "2025-11-25 14:17:58 +0000",
|
||||
"icon": "🐛",
|
||||
"category": "bugfix"
|
||||
},
|
||||
{
|
||||
"id": "a34d92f3bd7ceb96285bc32f1f2ff2eb79052170",
|
||||
"type": "feat",
|
||||
"title": "aprimora a exibição do cartão de parcelas e ajusta a lógica de busca",
|
||||
"date": "2025-11-23 14:52:22 -0300",
|
||||
"icon": "✨",
|
||||
"category": "feature"
|
||||
},
|
||||
{
|
||||
"id": "e8a343a6dd1f2426d484afe2902b05cfc65ea32d",
|
||||
"type": "feat",
|
||||
"title": "adiciona integração com Speed Insights",
|
||||
"date": "2025-11-23 12:32:38 -0300",
|
||||
"icon": "✨",
|
||||
"category": "feature"
|
||||
},
|
||||
{
|
||||
"id": "9fbe722d00aa0105fc3a37e0d19555e1aaf27928",
|
||||
"type": "feat",
|
||||
"title": "adicionar estrutura para gerenciamento de mudanças de código",
|
||||
"date": "2025-11-23 12:26:05 -0300",
|
||||
"icon": "✨",
|
||||
"category": "feature"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import { execSync } from "child_process";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
interface ChangelogEntry {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
date: string;
|
||||
icon: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
function getIcon(type: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
feat: "✨",
|
||||
fix: "🐛",
|
||||
perf: "🚀",
|
||||
docs: "📝",
|
||||
style: "🎨",
|
||||
refactor: "♻️",
|
||||
test: "🧪",
|
||||
chore: "🔧",
|
||||
};
|
||||
return icons[type] || "📦";
|
||||
}
|
||||
|
||||
function getCategory(type: string): string {
|
||||
const categories: Record<string, string> = {
|
||||
feat: "feature",
|
||||
fix: "bugfix",
|
||||
perf: "performance",
|
||||
docs: "documentation",
|
||||
style: "style",
|
||||
refactor: "refactor",
|
||||
test: "test",
|
||||
chore: "chore",
|
||||
};
|
||||
return categories[type] || "other";
|
||||
}
|
||||
|
||||
function getCategoryLabel(category: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
feature: "Novidades",
|
||||
bugfix: "Correções",
|
||||
performance: "Performance",
|
||||
documentation: "Documentação",
|
||||
style: "Interface",
|
||||
refactor: "Melhorias",
|
||||
test: "Testes",
|
||||
chore: "Manutenção",
|
||||
other: "Outros",
|
||||
};
|
||||
return labels[category] || "Outros";
|
||||
}
|
||||
|
||||
function generateChangelog() {
|
||||
try {
|
||||
console.log("🔍 Gerando changelog dos últimos commits...\n");
|
||||
|
||||
// Pega commits dos últimos 30 dias
|
||||
const gitCommand =
|
||||
'git log --since="30 days ago" --pretty=format:"%H|%s|%ai" --no-merges';
|
||||
|
||||
let output: string;
|
||||
try {
|
||||
output = execSync(gitCommand, { encoding: "utf-8" });
|
||||
} catch (error) {
|
||||
console.warn("⚠️ Não foi possível acessar o Git. Gerando changelog vazio.");
|
||||
output = "";
|
||||
}
|
||||
|
||||
if (!output.trim()) {
|
||||
console.log("ℹ️ Nenhum commit encontrado nos últimos 30 dias.");
|
||||
const emptyChangelog = {
|
||||
version: "1.0.0",
|
||||
generatedAt: new Date().toISOString(),
|
||||
entries: [],
|
||||
};
|
||||
|
||||
const publicDir = path.join(process.cwd(), "public");
|
||||
if (!fs.existsSync(publicDir)) {
|
||||
fs.mkdirSync(publicDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(publicDir, "changelog.json"),
|
||||
JSON.stringify(emptyChangelog, null, 2)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const commits = output
|
||||
.split("\n")
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => {
|
||||
const [hash, message, date] = line.split("|");
|
||||
return { hash, message, date };
|
||||
});
|
||||
|
||||
console.log(`📝 Processando ${commits.length} commits...\n`);
|
||||
|
||||
// Parseia conventional commits
|
||||
const entries: ChangelogEntry[] = commits
|
||||
.map((commit) => {
|
||||
// Match conventional commit format: type: message or type(scope): message
|
||||
const match = commit.message.match(
|
||||
/^(feat|fix|perf|docs|style|refactor|test|chore)(\(.+\))?:\s*(.+)$/
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
// Ignora commits que não seguem o padrão
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, type, , title] = match;
|
||||
|
||||
return {
|
||||
id: commit.hash,
|
||||
type,
|
||||
title: title.trim(),
|
||||
date: commit.date,
|
||||
icon: getIcon(type),
|
||||
category: getCategory(type),
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is ChangelogEntry => entry !== null);
|
||||
|
||||
console.log(`✅ ${entries.length} commits válidos encontrados\n`);
|
||||
|
||||
// Agrupa por categoria
|
||||
const grouped = entries.reduce(
|
||||
(acc, entry) => {
|
||||
if (!acc[entry.category]) {
|
||||
acc[entry.category] = [];
|
||||
}
|
||||
acc[entry.category].push(entry);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, ChangelogEntry[]>
|
||||
);
|
||||
|
||||
// Mostra resumo
|
||||
Object.entries(grouped).forEach(([category, items]) => {
|
||||
console.log(
|
||||
`${getIcon(items[0].type)} ${getCategoryLabel(category)}: ${items.length}`
|
||||
);
|
||||
});
|
||||
|
||||
// Pega versão do package.json
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(process.cwd(), "package.json"), "utf-8")
|
||||
);
|
||||
|
||||
const changelog = {
|
||||
version: packageJson.version || "1.0.0",
|
||||
generatedAt: new Date().toISOString(),
|
||||
entries: entries.slice(0, 20), // Limita a 20 mais recentes
|
||||
};
|
||||
|
||||
// Salva em public/changelog.json
|
||||
const publicDir = path.join(process.cwd(), "public");
|
||||
if (!fs.existsSync(publicDir)) {
|
||||
fs.mkdirSync(publicDir, { recursive: true });
|
||||
}
|
||||
|
||||
const changelogPath = path.join(publicDir, "changelog.json");
|
||||
fs.writeFileSync(changelogPath, JSON.stringify(changelog, null, 2));
|
||||
|
||||
console.log(`\n✅ Changelog gerado com sucesso em: ${changelogPath}`);
|
||||
} catch (error) {
|
||||
console.error("❌ Erro ao gerar changelog:", error);
|
||||
// Não falha o build, apenas avisa
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
generateChangelog();
|
||||
Reference in New Issue
Block a user