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_PASSWORD=opensheets_dev_password
|
||||||
POSTGRES_DB=opensheets_db
|
POSTGRES_DB=opensheets_db
|
||||||
|
|
||||||
# Provider: "local" para Docker, "remote" para Supabase/Neon/etc
|
|
||||||
DB_PROVIDER=local
|
|
||||||
|
|
||||||
# === Better Auth ===
|
# === Better Auth ===
|
||||||
# Gere com: openssl rand -base64 32
|
# Gere com: openssl rand -base64 32
|
||||||
BETTER_AUTH_SECRET=your-secret-key-here-change-this
|
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
|
```env
|
||||||
# Banco de dados (usando Docker)
|
# Banco de dados (usando Docker)
|
||||||
DATABASE_URL=postgresql://opensheets:opensheets_dev_password@localhost:5432/opensheets_db
|
DATABASE_URL=postgresql://opensheets:opensheets_dev_password@localhost:5432/opensheets_db
|
||||||
DB_PROVIDER=local
|
|
||||||
|
|
||||||
# Better Auth (gere com: openssl rand -base64 32)
|
# Better Auth (gere com: openssl rand -base64 32)
|
||||||
BETTER_AUTH_SECRET=seu-secret-aqui
|
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
|
```env
|
||||||
# Use o host "db" (nome do serviço Docker)
|
# Use o host "db" (nome do serviço Docker)
|
||||||
DATABASE_URL=postgresql://opensheets:opensheets_dev_password@db:5432/opensheets_db
|
DATABASE_URL=postgresql://opensheets:opensheets_dev_password@db:5432/opensheets_db
|
||||||
DB_PROVIDER=local
|
|
||||||
|
|
||||||
# Better Auth
|
# Better Auth
|
||||||
BETTER_AUTH_SECRET=seu-secret-aqui
|
BETTER_AUTH_SECRET=seu-secret-aqui
|
||||||
@@ -365,7 +363,6 @@ Se você já tem PostgreSQL no **Supabase**, **Neon**, **Railway**, etc.
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
DATABASE_URL=postgresql://user:password@host.region.provider.com:5432/database?sslmode=require
|
DATABASE_URL=postgresql://user:password@host.region.provider.com:5432/database?sslmode=require
|
||||||
DB_PROVIDER=remote
|
|
||||||
|
|
||||||
BETTER_AUTH_SECRET=seu-secret-aqui
|
BETTER_AUTH_SECRET=seu-secret-aqui
|
||||||
BETTER_AUTH_URL=http://localhost:3000
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
@@ -586,7 +583,6 @@ Copie o `.env.example` para `.env` e configure:
|
|||||||
```env
|
```env
|
||||||
# === Database ===
|
# === Database ===
|
||||||
DATABASE_URL=postgresql://opensheets:opensheets_dev_password@localhost:5432/opensheets_db
|
DATABASE_URL=postgresql://opensheets:opensheets_dev_password@localhost:5432/opensheets_db
|
||||||
DB_PROVIDER=local # ou "remote"
|
|
||||||
|
|
||||||
# === Better Auth ===
|
# === Better Auth ===
|
||||||
# Gere com: openssl rand -base64 32
|
# Gere com: openssl rand -base64 32
|
||||||
@@ -653,10 +649,10 @@ pnpm env:setup
|
|||||||
|
|
||||||
### Escolhendo entre Local e Remoto
|
### Escolhendo entre Local e Remoto
|
||||||
|
|
||||||
| Modo | Quando usar | Como configurar |
|
| Modo | Quando usar | Como configurar |
|
||||||
| ---------- | ------------------------------------- | -------------------------------------- |
|
| ---------- | ------------------------------------- | --------------------------------------------- |
|
||||||
| **Local** | Desenvolvimento, testes, prototipagem | `DB_PROVIDER=local` + Docker |
|
| **Local** | Desenvolvimento, testes, prototipagem | `DATABASE_URL` com host "db" ou "localhost" |
|
||||||
| **Remoto** | Produção, deploy, banco gerenciado | `DB_PROVIDER=remote` + URL do provider |
|
| **Remoto** | Produção, deploy, banco gerenciado | `DATABASE_URL` com URL completa do provider |
|
||||||
|
|
||||||
### Drizzle ORM
|
### 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
|
// Actions
|
||||||
|
|
||||||
export async function updateNameAction(
|
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 { UpdateEmailForm } from "@/components/ajustes/update-email-form";
|
||||||
import { UpdateNameForm } from "@/components/ajustes/update-name-form";
|
import { UpdateNameForm } from "@/components/ajustes/update-name-form";
|
||||||
import { UpdatePasswordForm } from "@/components/ajustes/update-password-form";
|
import { UpdatePasswordForm } from "@/components/ajustes/update-password-form";
|
||||||
|
import { PreferencesForm } from "@/components/ajustes/preferences-form";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { auth } from "@/lib/auth/config";
|
import { auth } from "@/lib/auth/config";
|
||||||
@@ -27,14 +28,28 @@ export default async function Page() {
|
|||||||
where: eq(schema.account.userId, session.user.id),
|
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
|
// Se o providerId for "google", o usuário usa Google OAuth
|
||||||
const authProvider = userAccount?.providerId || "credential";
|
const authProvider = userAccount?.providerId || "credential";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl">
|
<div className="w-full">
|
||||||
<Tabs defaultValue="nome" className="w-full">
|
<Tabs defaultValue="preferencias" className="w-full">
|
||||||
<TabsList className="w-full grid grid-cols-4 mb-2">
|
<TabsList>
|
||||||
<TabsTrigger value="nome">Altere seu nome</TabsTrigger>
|
<TabsTrigger value="preferencias">Preferências</TabsTrigger>
|
||||||
|
<TabsTrigger value="nome">Alterar nome</TabsTrigger>
|
||||||
<TabsTrigger value="senha">Alterar senha</TabsTrigger>
|
<TabsTrigger value="senha">Alterar senha</TabsTrigger>
|
||||||
<TabsTrigger value="email">Alterar e-mail</TabsTrigger>
|
<TabsTrigger value="email">Alterar e-mail</TabsTrigger>
|
||||||
<TabsTrigger value="deletar" className="text-destructive">
|
<TabsTrigger value="deletar" className="text-destructive">
|
||||||
@@ -42,56 +57,92 @@ export default async function Page() {
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<Card className="p-6">
|
<TabsContent value="preferencias" className="mt-4">
|
||||||
<TabsContent value="nome" className="space-y-4">
|
<Card className="p-6">
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<h2 className="text-lg font-medium mb-1">Alterar nome</h2>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<h2 className="text-lg font-bold mb-1">Preferências</h2>
|
||||||
Atualize como seu nome aparece no Opensheets. Esse nome pode ser
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
exibido em diferentes seções do app e em comunicações.
|
Personalize sua experiência no Opensheets ajustando as
|
||||||
</p>
|
configurações de acordo com suas necessidades.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<PreferencesForm
|
||||||
|
disableMagnetlines={
|
||||||
|
userPreferences?.disableMagnetlines ?? false
|
||||||
|
}
|
||||||
|
periodMonthsBefore={userPreferences?.periodMonthsBefore ?? 3}
|
||||||
|
periodMonthsAfter={userPreferences?.periodMonthsAfter ?? 3}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<UpdateNameForm currentName={userName} />
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="senha" className="space-y-4">
|
<TabsContent value="nome" className="mt-4">
|
||||||
<div>
|
<Card className="p-6">
|
||||||
<h2 className="text-lg font-medium mb-1">Alterar senha</h2>
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<div>
|
||||||
Defina uma nova senha para sua conta. Guarde-a em local seguro.
|
<h2 className="text-lg font-bold mb-1">Alterar nome</h2>
|
||||||
</p>
|
<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>
|
</div>
|
||||||
<UpdatePasswordForm authProvider={authProvider} />
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="email" className="space-y-4">
|
<TabsContent value="senha" className="mt-4">
|
||||||
<div>
|
<Card className="p-6">
|
||||||
<h2 className="text-lg font-medium mb-1">Alterar e-mail</h2>
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<div>
|
||||||
Atualize o e-mail associado à sua conta. Você precisará
|
<h2 className="text-lg font-bold mb-1">Alterar senha</h2>
|
||||||
confirmar os links enviados para o novo e também para o e-mail
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
atual (quando aplicável) para concluir a alteração.
|
Defina uma nova senha para sua conta. Guarde-a em local
|
||||||
</p>
|
seguro.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<UpdatePasswordForm authProvider={authProvider} />
|
||||||
</div>
|
</div>
|
||||||
<UpdateEmailForm
|
</Card>
|
||||||
currentEmail={userEmail}
|
</TabsContent>
|
||||||
authProvider={authProvider}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="deletar" className="space-y-4">
|
<TabsContent value="email" className="mt-4">
|
||||||
<div>
|
<Card className="p-6">
|
||||||
<h2 className="text-lg font-medium mb-1 text-destructive">
|
<div className="space-y-4">
|
||||||
Deletar conta
|
<div>
|
||||||
</h2>
|
<h2 className="text-lg font-bold mb-1">Alterar e-mail</h2>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
Ao prosseguir, sua conta e todos os dados associados serão
|
Atualize o e-mail associado à sua conta. Você precisará
|
||||||
excluídos de forma irreversível.
|
confirmar os links enviados para o novo e também para o e-mail
|
||||||
</p>
|
atual (quando aplicável) para concluir a alteração.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<UpdateEmailForm
|
||||||
|
currentEmail={userEmail}
|
||||||
|
authProvider={authProvider}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DeleteAccountForm />
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Card>
|
|
||||||
|
<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>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
fetchLancamentoFilterSources,
|
fetchLancamentoFilterSources,
|
||||||
mapLancamentosData,
|
mapLancamentosData,
|
||||||
} from "@/lib/lancamentos/page-helpers";
|
} from "@/lib/lancamentos/page-helpers";
|
||||||
|
import { fetchUserPeriodPreferences } from "@/lib/user-preferences/period";
|
||||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||||
import { and, eq, gte, lte, ne, or } from "drizzle-orm";
|
import { and, eq, gte, lte, ne, or } from "drizzle-orm";
|
||||||
|
|
||||||
@@ -59,42 +60,44 @@ export const fetchCalendarData = async ({
|
|||||||
const rangeStartKey = toDateKey(rangeStart);
|
const rangeStartKey = toDateKey(rangeStart);
|
||||||
const rangeEndKey = toDateKey(rangeEnd);
|
const rangeEndKey = toDateKey(rangeEnd);
|
||||||
|
|
||||||
const [lancamentoRows, cardRows, filterSources] = await Promise.all([
|
const [lancamentoRows, cardRows, filterSources, periodPreferences] =
|
||||||
db.query.lancamentos.findMany({
|
await Promise.all([
|
||||||
where: and(
|
db.query.lancamentos.findMany({
|
||||||
eq(lancamentos.userId, userId),
|
where: and(
|
||||||
ne(lancamentos.transactionType, TRANSACTION_TYPE_TRANSFERENCIA),
|
eq(lancamentos.userId, userId),
|
||||||
or(
|
ne(lancamentos.transactionType, TRANSACTION_TYPE_TRANSFERENCIA),
|
||||||
// Lançamentos cuja data de compra esteja no período do calendário
|
or(
|
||||||
and(
|
// Lançamentos cuja data de compra esteja no período do calendário
|
||||||
gte(lancamentos.purchaseDate, rangeStart),
|
and(
|
||||||
lte(lancamentos.purchaseDate, rangeEnd)
|
gte(lancamentos.purchaseDate, rangeStart),
|
||||||
),
|
lte(lancamentos.purchaseDate, rangeEnd)
|
||||||
// Boletos cuja data de vencimento esteja no período do calendário
|
),
|
||||||
and(
|
// Boletos cuja data de vencimento esteja no período do calendário
|
||||||
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
|
and(
|
||||||
gte(lancamentos.dueDate, rangeStart),
|
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||||
lte(lancamentos.dueDate, rangeEnd)
|
gte(lancamentos.dueDate, rangeStart),
|
||||||
),
|
lte(lancamentos.dueDate, rangeEnd)
|
||||||
// Lançamentos de cartão do período (para calcular totais de vencimento)
|
),
|
||||||
and(
|
// Lançamentos de cartão do período (para calcular totais de vencimento)
|
||||||
eq(lancamentos.period, period),
|
and(
|
||||||
ne(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO)
|
eq(lancamentos.period, period),
|
||||||
|
ne(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
),
|
||||||
),
|
with: {
|
||||||
with: {
|
pagador: true,
|
||||||
pagador: true,
|
conta: true,
|
||||||
conta: true,
|
cartao: true,
|
||||||
cartao: true,
|
categoria: true,
|
||||||
categoria: true,
|
},
|
||||||
},
|
}),
|
||||||
}),
|
db.query.cartoes.findMany({
|
||||||
db.query.cartoes.findMany({
|
where: eq(cartoes.userId, userId),
|
||||||
where: eq(cartoes.userId, userId),
|
}),
|
||||||
}),
|
fetchLancamentoFilterSources(userId),
|
||||||
fetchLancamentoFilterSources(userId),
|
fetchUserPeriodPreferences(userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const lancamentosData = mapLancamentosData(lancamentoRows);
|
const lancamentosData = mapLancamentosData(lancamentoRows);
|
||||||
const events: CalendarEvent[] = [];
|
const events: CalendarEvent[] = [];
|
||||||
@@ -214,6 +217,7 @@ export const fetchCalendarData = async ({
|
|||||||
cartaoOptions: optionSets.cartaoOptions,
|
cartaoOptions: optionSets.cartaoOptions,
|
||||||
categoriaOptions: optionSets.categoriaOptions,
|
categoriaOptions: optionSets.categoriaOptions,
|
||||||
estabelecimentos,
|
estabelecimentos,
|
||||||
|
periodPreferences,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
||||||
import { CardDialog } from "@/components/cartoes/card-dialog";
|
import { CardDialog } from "@/components/cartoes/card-dialog";
|
||||||
import type { Card } from "@/components/cartoes/types";
|
import type { Card } from "@/components/cartoes/types";
|
||||||
import { InvoiceSummaryCard } from "@/components/faturas/invoice-summary-card";
|
import { InvoiceSummaryCard } from "@/components/faturas/invoice-summary-card";
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
type ResolvedSearchParams,
|
type ResolvedSearchParams,
|
||||||
} from "@/lib/lancamentos/page-helpers";
|
} from "@/lib/lancamentos/page-helpers";
|
||||||
import { loadLogoOptions } from "@/lib/logo/options";
|
import { loadLogoOptions } from "@/lib/logo/options";
|
||||||
|
import { fetchUserPeriodPreferences } from "@/lib/user-preferences/period";
|
||||||
import { parsePeriodParam } from "@/lib/utils/period";
|
import { parsePeriodParam } from "@/lib/utils/period";
|
||||||
import { RiPencilLine } from "@remixicon/react";
|
import { RiPencilLine } from "@remixicon/react";
|
||||||
import { and, desc } from "drizzle-orm";
|
import { and, desc } from "drizzle-orm";
|
||||||
@@ -52,10 +54,18 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const [filterSources, logoOptions, invoiceData] = await Promise.all([
|
const [
|
||||||
|
filterSources,
|
||||||
|
logoOptions,
|
||||||
|
invoiceData,
|
||||||
|
estabelecimentos,
|
||||||
|
periodPreferences,
|
||||||
|
] = await Promise.all([
|
||||||
fetchLancamentoFilterSources(userId),
|
fetchLancamentoFilterSources(userId),
|
||||||
loadLogoOptions(),
|
loadLogoOptions(),
|
||||||
fetchInvoiceData(userId, cartaoId, selectedPeriod),
|
fetchInvoiceData(userId, cartaoId, selectedPeriod),
|
||||||
|
getRecentEstablishmentsAction(),
|
||||||
|
fetchUserPeriodPreferences(userId),
|
||||||
]);
|
]);
|
||||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||||
const slugMaps = buildSlugMaps(sluggedFilters);
|
const slugMaps = buildSlugMaps(sluggedFilters);
|
||||||
@@ -187,6 +197,8 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
categoriaFilterOptions={categoriaFilterOptions}
|
categoriaFilterOptions={categoriaFilterOptions}
|
||||||
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
||||||
selectedPeriod={selectedPeriod}
|
selectedPeriod={selectedPeriod}
|
||||||
|
estabelecimentos={estabelecimentos}
|
||||||
|
periodPreferences={periodPreferences}
|
||||||
allowCreate
|
allowCreate
|
||||||
defaultCartaoId={card.id}
|
defaultCartaoId={card.id}
|
||||||
defaultPaymentMethod="Cartão de crédito"
|
defaultPaymentMethod="Cartão de crédito"
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
buildSluggedFilters,
|
buildSluggedFilters,
|
||||||
fetchLancamentoFilterSources,
|
fetchLancamentoFilterSources,
|
||||||
} from "@/lib/lancamentos/page-helpers";
|
} 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";
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||||
@@ -28,24 +29,6 @@ const getSingleParam = (
|
|||||||
return Array.isArray(value) ? value[0] ?? null : value;
|
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) {
|
export default async function Page({ params, searchParams }: PageProps) {
|
||||||
const { categoryId } = await params;
|
const { categoryId } = await params;
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
@@ -54,10 +37,11 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||||
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
|
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
|
||||||
|
|
||||||
const [detail, filterSources, estabelecimentos] = await Promise.all([
|
const [detail, filterSources, estabelecimentos, periodPreferences] = await Promise.all([
|
||||||
fetchCategoryDetails(userId, categoryId, selectedPeriod),
|
fetchCategoryDetails(userId, categoryId, selectedPeriod),
|
||||||
fetchLancamentoFilterSources(userId),
|
fetchLancamentoFilterSources(userId),
|
||||||
getRecentEstablishmentsAction(),
|
getRecentEstablishmentsAction(),
|
||||||
|
fetchUserPeriodPreferences(userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!detail) {
|
if (!detail) {
|
||||||
@@ -80,8 +64,8 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
pagadorRows: filterSources.pagadorRows,
|
pagadorRows: filterSources.pagadorRows,
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentPeriodLabel = formatPeriodLabel(detail.period);
|
const currentPeriodLabel = displayPeriod(detail.period);
|
||||||
const previousPeriodLabel = formatPeriodLabel(detail.previousPeriod);
|
const previousPeriodLabel = displayPeriod(detail.previousPeriod);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-6">
|
<main className="flex flex-col gap-6">
|
||||||
@@ -108,6 +92,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
||||||
selectedPeriod={detail.period}
|
selectedPeriod={detail.period}
|
||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
|
periodPreferences={periodPreferences}
|
||||||
allowCreate={true}
|
allowCreate={true}
|
||||||
/>
|
/>
|
||||||
</main>
|
</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 { AccountDialog } from "@/components/contas/account-dialog";
|
||||||
import { AccountStatementCard } from "@/components/contas/account-statement-card";
|
import { AccountStatementCard } from "@/components/contas/account-statement-card";
|
||||||
import type { Account } from "@/components/contas/types";
|
import type { Account } from "@/components/contas/types";
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
type ResolvedSearchParams,
|
type ResolvedSearchParams,
|
||||||
} from "@/lib/lancamentos/page-helpers";
|
} from "@/lib/lancamentos/page-helpers";
|
||||||
import { loadLogoOptions } from "@/lib/logo/options";
|
import { loadLogoOptions } from "@/lib/logo/options";
|
||||||
|
import { fetchUserPeriodPreferences } from "@/lib/user-preferences/period";
|
||||||
import { parsePeriodParam } from "@/lib/utils/period";
|
import { parsePeriodParam } from "@/lib/utils/period";
|
||||||
import { RiPencilLine } from "@remixicon/react";
|
import { RiPencilLine } from "@remixicon/react";
|
||||||
import { and, desc, eq } from "drizzle-orm";
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
@@ -55,10 +57,18 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const [filterSources, logoOptions, accountSummary] = await Promise.all([
|
const [
|
||||||
|
filterSources,
|
||||||
|
logoOptions,
|
||||||
|
accountSummary,
|
||||||
|
estabelecimentos,
|
||||||
|
periodPreferences,
|
||||||
|
] = await Promise.all([
|
||||||
fetchLancamentoFilterSources(userId),
|
fetchLancamentoFilterSources(userId),
|
||||||
loadLogoOptions(),
|
loadLogoOptions(),
|
||||||
fetchAccountSummary(userId, contaId, selectedPeriod),
|
fetchAccountSummary(userId, contaId, selectedPeriod),
|
||||||
|
getRecentEstablishmentsAction(),
|
||||||
|
fetchUserPeriodPreferences(userId),
|
||||||
]);
|
]);
|
||||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||||
const slugMaps = buildSlugMaps(sluggedFilters);
|
const slugMaps = buildSlugMaps(sluggedFilters);
|
||||||
@@ -165,6 +175,8 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
categoriaFilterOptions={categoriaFilterOptions}
|
categoriaFilterOptions={categoriaFilterOptions}
|
||||||
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
||||||
selectedPeriod={selectedPeriod}
|
selectedPeriod={selectedPeriod}
|
||||||
|
estabelecimentos={estabelecimentos}
|
||||||
|
periodPreferences={periodPreferences}
|
||||||
allowCreate={false}
|
allowCreate={false}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ const accountBaseSchema = z.object({
|
|||||||
excludeFromBalance: z
|
excludeFromBalance: z
|
||||||
.union([z.boolean(), z.string()])
|
.union([z.boolean(), z.string()])
|
||||||
.transform((value) => value === true || value === "true"),
|
.transform((value) => value === true || value === "true"),
|
||||||
|
excludeInitialBalanceFromIncome: z
|
||||||
|
.union([z.boolean(), z.string()])
|
||||||
|
.transform((value) => value === true || value === "true"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createAccountSchema = accountBaseSchema;
|
const createAccountSchema = accountBaseSchema;
|
||||||
@@ -93,6 +96,7 @@ export async function createAccountAction(
|
|||||||
logo: logoFile,
|
logo: logoFile,
|
||||||
initialBalance: formatDecimalForDbRequired(data.initialBalance),
|
initialBalance: formatDecimalForDbRequired(data.initialBalance),
|
||||||
excludeFromBalance: data.excludeFromBalance,
|
excludeFromBalance: data.excludeFromBalance,
|
||||||
|
excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
})
|
})
|
||||||
.returning({ id: contas.id, name: contas.name });
|
.returning({ id: contas.id, name: contas.name });
|
||||||
@@ -183,6 +187,7 @@ export async function updateAccountAction(
|
|||||||
logo: logoFile,
|
logo: logoFile,
|
||||||
initialBalance: formatDecimalForDbRequired(data.initialBalance),
|
initialBalance: formatDecimalForDbRequired(data.initialBalance),
|
||||||
excludeFromBalance: data.excludeFromBalance,
|
excludeFromBalance: data.excludeFromBalance,
|
||||||
|
excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome,
|
||||||
})
|
})
|
||||||
.where(and(eq(contas.id, data.id), eq(contas.userId, user.id)))
|
.where(and(eq(contas.id, data.id), eq(contas.userId, user.id)))
|
||||||
.returning();
|
.returning();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type AccountData = {
|
|||||||
initialBalance: number;
|
initialBalance: number;
|
||||||
balance: number;
|
balance: number;
|
||||||
excludeFromBalance: boolean;
|
excludeFromBalance: boolean;
|
||||||
|
excludeInitialBalanceFromIncome: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchAccountsForUser(
|
export async function fetchAccountsForUser(
|
||||||
@@ -31,6 +32,7 @@ export async function fetchAccountsForUser(
|
|||||||
logo: contas.logo,
|
logo: contas.logo,
|
||||||
initialBalance: contas.initialBalance,
|
initialBalance: contas.initialBalance,
|
||||||
excludeFromBalance: contas.excludeFromBalance,
|
excludeFromBalance: contas.excludeFromBalance,
|
||||||
|
excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome,
|
||||||
balanceMovements: sql<number>`
|
balanceMovements: sql<number>`
|
||||||
coalesce(
|
coalesce(
|
||||||
sum(
|
sum(
|
||||||
@@ -67,7 +69,8 @@ export async function fetchAccountsForUser(
|
|||||||
contas.note,
|
contas.note,
|
||||||
contas.logo,
|
contas.logo,
|
||||||
contas.initialBalance,
|
contas.initialBalance,
|
||||||
contas.excludeFromBalance
|
contas.excludeFromBalance,
|
||||||
|
contas.excludeInitialBalanceFromIncome
|
||||||
),
|
),
|
||||||
loadLogoOptions(),
|
loadLogoOptions(),
|
||||||
]);
|
]);
|
||||||
@@ -84,6 +87,7 @@ export async function fetchAccountsForUser(
|
|||||||
Number(account.initialBalance ?? 0) +
|
Number(account.initialBalance ?? 0) +
|
||||||
Number(account.balanceMovements ?? 0),
|
Number(account.balanceMovements ?? 0),
|
||||||
excludeFromBalance: account.excludeFromBalance,
|
excludeFromBalance: account.excludeFromBalance,
|
||||||
|
excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { accounts, logoOptions };
|
return { accounts, logoOptions };
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import MonthPicker from "@/components/month-picker/month-picker";
|
|||||||
import { getUser } from "@/lib/auth/server";
|
import { getUser } from "@/lib/auth/server";
|
||||||
import { fetchDashboardData } from "@/lib/dashboard/fetch-dashboard-data";
|
import { fetchDashboardData } from "@/lib/dashboard/fetch-dashboard-data";
|
||||||
import { parsePeriodParam } from "@/lib/utils/period";
|
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>>;
|
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);
|
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 (
|
return (
|
||||||
<main className="flex flex-col gap-4 px-6">
|
<main className="flex flex-col gap-4 px-6">
|
||||||
<DashboardWelcome name={user.name} />
|
<DashboardWelcome name={user.name} disableMagnetlines={disableMagnetlines} />
|
||||||
<MonthPicker />
|
<MonthPicker />
|
||||||
<SectionCards metrics={data.metrics} />
|
<SectionCards metrics={data.metrics} />
|
||||||
<DashboardGrid data={data} period={selectedPeriod} />
|
<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 { 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[]) {
|
export async function fetchLancamentos(filters: SQL[]) {
|
||||||
const lancamentoRows = await db.query.lancamentos.findMany({
|
const lancamentoRows = await db
|
||||||
where: and(...filters),
|
.select({
|
||||||
with: {
|
lancamento: lancamentos,
|
||||||
pagador: true,
|
pagador: pagadores,
|
||||||
conta: true,
|
conta: contas,
|
||||||
cartao: true,
|
cartao: cartoes,
|
||||||
categoria: true,
|
categoria: categorias,
|
||||||
},
|
})
|
||||||
orderBy: [desc(lancamentos.purchaseDate), desc(lancamentos.createdAt)],
|
.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,
|
mapLancamentosData,
|
||||||
type ResolvedSearchParams,
|
type ResolvedSearchParams,
|
||||||
} from "@/lib/lancamentos/page-helpers";
|
} from "@/lib/lancamentos/page-helpers";
|
||||||
|
import { fetchUserPeriodPreferences } from "@/lib/user-preferences/period";
|
||||||
import { parsePeriodParam } from "@/lib/utils/period";
|
import { parsePeriodParam } from "@/lib/utils/period";
|
||||||
import { fetchLancamentos } from "./data";
|
import { fetchLancamentos } from "./data";
|
||||||
import { getRecentEstablishmentsAction } from "./actions";
|
import { getRecentEstablishmentsAction } from "./actions";
|
||||||
@@ -31,7 +32,11 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
|
|
||||||
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
|
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
|
||||||
|
|
||||||
const filterSources = await fetchLancamentoFilterSources(userId);
|
const [filterSources, periodPreferences] = await Promise.all([
|
||||||
|
fetchLancamentoFilterSources(userId),
|
||||||
|
fetchUserPeriodPreferences(userId),
|
||||||
|
]);
|
||||||
|
|
||||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||||
const slugMaps = buildSlugMaps(sluggedFilters);
|
const slugMaps = buildSlugMaps(sluggedFilters);
|
||||||
|
|
||||||
@@ -78,6 +83,7 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
||||||
selectedPeriod={selectedPeriod}
|
selectedPeriod={selectedPeriod}
|
||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
|
periodPreferences={periodPreferences}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import MonthPicker from "@/components/month-picker/month-picker";
|
import MonthPicker from "@/components/month-picker/month-picker";
|
||||||
import { BudgetsPage } from "@/components/orcamentos/budgets-page";
|
import { BudgetsPage } from "@/components/orcamentos/budgets-page";
|
||||||
import { getUserId } from "@/lib/auth/server";
|
import { getUserId } from "@/lib/auth/server";
|
||||||
|
import { fetchUserPeriodPreferences } from "@/lib/user-preferences/period";
|
||||||
import { parsePeriodParam } from "@/lib/utils/period";
|
import { parsePeriodParam } from "@/lib/utils/period";
|
||||||
import { fetchBudgetsForUser } from "./data";
|
import { fetchBudgetsForUser } from "./data";
|
||||||
|
|
||||||
@@ -35,10 +36,10 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
|
|
||||||
const periodLabel = `${capitalize(rawMonthName)} ${year}`;
|
const periodLabel = `${capitalize(rawMonthName)} ${year}`;
|
||||||
|
|
||||||
const { budgets, categoriesOptions } = await fetchBudgetsForUser(
|
const [{ budgets, categoriesOptions }, periodPreferences] = await Promise.all([
|
||||||
userId,
|
fetchBudgetsForUser(userId, selectedPeriod),
|
||||||
selectedPeriod
|
fetchUserPeriodPreferences(userId),
|
||||||
);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-6">
|
<main className="flex flex-col gap-6">
|
||||||
@@ -48,6 +49,7 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
categories={categoriesOptions}
|
categories={categoriesOptions}
|
||||||
selectedPeriod={selectedPeriod}
|
selectedPeriod={selectedPeriod}
|
||||||
periodLabel={periodLabel}
|
periodLabel={periodLabel}
|
||||||
|
periodPreferences={periodPreferences}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
fetchPagadorHistory,
|
fetchPagadorHistory,
|
||||||
fetchPagadorMonthlyBreakdown,
|
fetchPagadorMonthlyBreakdown,
|
||||||
} from "@/lib/pagadores/details";
|
} from "@/lib/pagadores/details";
|
||||||
|
import { displayPeriod } from "@/lib/utils/period";
|
||||||
import { and, desc, eq } from "drizzle-orm";
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
@@ -32,17 +33,6 @@ const formatCurrency = (value: number) =>
|
|||||||
maximumFractionDigits: 2,
|
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) => {
|
const formatDate = (value: Date | null | undefined) => {
|
||||||
if (!value) return "—";
|
if (!value) return "—";
|
||||||
return value.toLocaleDateString("pt-BR", {
|
return value.toLocaleDateString("pt-BR", {
|
||||||
@@ -560,7 +550,7 @@ export async function sendPagadorSummaryAction(
|
|||||||
|
|
||||||
const html = buildSummaryHtml({
|
const html = buildSummaryHtml({
|
||||||
pagadorName: pagadorRow.name,
|
pagadorName: pagadorRow.name,
|
||||||
periodLabel: formatPeriodLabel(period),
|
periodLabel: displayPeriod(period),
|
||||||
monthlyBreakdown,
|
monthlyBreakdown,
|
||||||
historyData,
|
historyData,
|
||||||
cardUsage,
|
cardUsage,
|
||||||
@@ -573,7 +563,7 @@ export async function sendPagadorSummaryAction(
|
|||||||
await resend.emails.send({
|
await resend.emails.send({
|
||||||
from: resendFrom,
|
from: resendFrom,
|
||||||
to: pagadorRow.email,
|
to: pagadorRow.email,
|
||||||
subject: `Resumo Financeiro | ${formatPeriodLabel(period)}`,
|
subject: `Resumo Financeiro | ${displayPeriod(period)}`,
|
||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
||||||
import { PagadorCardUsageCard } from "@/components/pagadores/details/pagador-card-usage-card";
|
import { PagadorCardUsageCard } from "@/components/pagadores/details/pagador-card-usage-card";
|
||||||
import { PagadorHistoryCard } from "@/components/pagadores/details/pagador-history-card";
|
import { PagadorHistoryCard } from "@/components/pagadores/details/pagador-history-card";
|
||||||
import { PagadorInfoCard } from "@/components/pagadores/details/pagador-info-card";
|
import { PagadorInfoCard } from "@/components/pagadores/details/pagador-info-card";
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
type SlugMaps,
|
type SlugMaps,
|
||||||
type SluggedFilters,
|
type SluggedFilters,
|
||||||
} from "@/lib/lancamentos/page-helpers";
|
} from "@/lib/lancamentos/page-helpers";
|
||||||
|
import { fetchUserPeriodPreferences } from "@/lib/user-preferences/period";
|
||||||
import { parsePeriodParam } from "@/lib/utils/period";
|
import { parsePeriodParam } from "@/lib/utils/period";
|
||||||
import { getPagadorAccess } from "@/lib/pagadores/access";
|
import { getPagadorAccess } from "@/lib/pagadores/access";
|
||||||
import {
|
import {
|
||||||
@@ -134,6 +136,8 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
cardUsage,
|
cardUsage,
|
||||||
boletoStats,
|
boletoStats,
|
||||||
shareRows,
|
shareRows,
|
||||||
|
estabelecimentos,
|
||||||
|
periodPreferences,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
fetchPagadorLancamentos(filters),
|
fetchPagadorLancamentos(filters),
|
||||||
fetchPagadorMonthlyBreakdown({
|
fetchPagadorMonthlyBreakdown({
|
||||||
@@ -157,6 +161,8 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
period: selectedPeriod,
|
period: selectedPeriod,
|
||||||
}),
|
}),
|
||||||
sharesPromise,
|
sharesPromise,
|
||||||
|
getRecentEstablishmentsAction(),
|
||||||
|
fetchUserPeriodPreferences(dataOwnerId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const mappedLancamentos = mapLancamentosData(lancamentoRows);
|
const mappedLancamentos = mapLancamentosData(lancamentoRows);
|
||||||
@@ -289,6 +295,8 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
categoriaFilterOptions={optionSets.categoriaFilterOptions}
|
categoriaFilterOptions={optionSets.categoriaFilterOptions}
|
||||||
contaCartaoFilterOptions={optionSets.contaCartaoFilterOptions}
|
contaCartaoFilterOptions={optionSets.contaCartaoFilterOptions}
|
||||||
selectedPeriod={selectedPeriod}
|
selectedPeriod={selectedPeriod}
|
||||||
|
estabelecimentos={estabelecimentos}
|
||||||
|
periodPreferences={periodPreferences}
|
||||||
allowCreate={canEdit}
|
allowCreate={canEdit}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { deleteAccountAction } from "@/app/(dashboard)/ajustes/actions";
|
import { deleteAccountAction } from "@/app/(dashboard)/ajustes/actions";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +12,6 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { authClient } from "@/lib/auth/client";
|
import { authClient } from "@/lib/auth/client";
|
||||||
import { RiAlertLine } from "@remixicon/react";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -54,34 +52,29 @@ export function DeleteAccountForm() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 space-y-4">
|
<div className="flex flex-col space-y-6">
|
||||||
<div className="flex items-start gap-3">
|
<div className="space-y-4 max-w-md">
|
||||||
<RiAlertLine className="size-5 text-destructive mt-0.5" />
|
<ul className="list-disc list-inside text-sm text-destructive space-y-1">
|
||||||
<div className="flex-1 space-y-1">
|
<li>Lançamentos, orçamentos e anotações</li>
|
||||||
<h3 className="font-medium text-destructive">
|
<li>Contas, cartões e categorias</li>
|
||||||
Remoção definitiva de conta
|
<li>Pagadores (incluindo o pagador padrão)</li>
|
||||||
</h3>
|
<li>Preferências e configurações</li>
|
||||||
<p className="text-sm text-foreground">
|
<li className="font-bold">
|
||||||
Ao prosseguir, sua conta e todos os dados associados serão
|
Resumindo tudo, sua conta será permanentemente removida
|
||||||
excluídos de forma irreversível.
|
</li>
|
||||||
</p>
|
</ul>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 pl-8">
|
<div className="flex justify-end">
|
||||||
<li>Lançamentos, anexos e notas</li>
|
<Button
|
||||||
<li>Contas, cartões, orçamentos e categorias</li>
|
variant="destructive"
|
||||||
<li>Pagadores (incluindo o pagador padrão)</li>
|
onClick={handleOpenModal}
|
||||||
<li>Preferências e configurações</li>
|
disabled={isPending}
|
||||||
</ul>
|
className="w-fit"
|
||||||
|
>
|
||||||
<Button
|
Deletar conta
|
||||||
variant="destructive"
|
</Button>
|
||||||
onClick={handleOpenModal}
|
</div>
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
Deletar conta
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
<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 (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
|
||||||
{/* E-mail atual (apenas informativo) */}
|
<div className="space-y-4 max-w-md">
|
||||||
<div className="space-y-2">
|
{/* E-mail atual (apenas informativo) */}
|
||||||
<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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">
|
<Label htmlFor="currentEmail">E-mail atual</Label>
|
||||||
Senha atual <span className="text-destructive">*</span>
|
<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>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="confirmEmail"
|
||||||
type={showPassword ? "text" : "password"}
|
type="email"
|
||||||
value={password}
|
value={confirmEmail}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setConfirmEmail(e.target.value)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
placeholder="Digite sua senha para confirmar"
|
placeholder="Repita o novo e-mail"
|
||||||
required
|
required
|
||||||
aria-required="true"
|
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
|
{/* Indicador visual de match */}
|
||||||
type="button"
|
{emailsMatch !== null && (
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
{emailsMatch ? (
|
||||||
aria-label={showPassword ? "Ocultar senha" : "Mostrar senha"}
|
<RiCheckLine className="h-5 w-5 text-green-500" aria-label="Os e-mails coincidem" />
|
||||||
>
|
) : (
|
||||||
{showPassword ? (
|
<RiCloseLine className="h-5 w-5 text-red-500" aria-label="Os e-mails não coincidem" />
|
||||||
<RiEyeOffLine size={20} />
|
)}
|
||||||
) : (
|
</div>
|
||||||
<RiEyeLine size={20} />
|
)}
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="password-help" className="text-xs text-muted-foreground">
|
{/* Mensagem de erro em tempo real */}
|
||||||
Por segurança, confirme sua senha antes de alterar seu e-mail
|
{emailsMatch === false && (
|
||||||
</p>
|
<p id="confirm-email-help" className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
|
||||||
</div>
|
<RiCloseLine className="h-3.5 w-3.5" />
|
||||||
)}
|
Os e-mails não coincidem
|
||||||
|
</p>
|
||||||
{/* Novo e-mail */}
|
)}
|
||||||
<div className="space-y-2">
|
{emailsMatch === true && (
|
||||||
<Label htmlFor="newEmail">
|
<p id="confirm-email-help" className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||||
Novo e-mail <span className="text-destructive">*</span>
|
<RiCheckLine className="h-3.5 w-3.5" />
|
||||||
</Label>
|
Os e-mails coincidem
|
||||||
<Input
|
</p>
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Button
|
<div className="flex justify-end">
|
||||||
type="submit"
|
<Button
|
||||||
disabled={isPending || emailsMatch === false || !isEmailDifferent}
|
type="submit"
|
||||||
>
|
disabled={isPending || emailsMatch === false || !isEmailDifferent}
|
||||||
{isPending ? "Atualizando..." : "Atualizar e-mail"}
|
className="w-fit"
|
||||||
</Button>
|
>
|
||||||
|
{isPending ? "Atualizando..." : "Atualizar e-mail"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,32 +40,36 @@ export function UpdateNameForm({ currentName }: UpdateNameFormProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-4 max-w-md">
|
||||||
<Label htmlFor="firstName">Primeiro nome</Label>
|
<div className="space-y-2">
|
||||||
<Input
|
<Label htmlFor="firstName">Primeiro nome</Label>
|
||||||
id="firstName"
|
<Input
|
||||||
value={firstName}
|
id="firstName"
|
||||||
onChange={(e) => setFirstName(e.target.value)}
|
value={firstName}
|
||||||
disabled={isPending}
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
required
|
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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="flex justify-end">
|
||||||
<Label htmlFor="lastName">Sobrenome</Label>
|
<Button type="submit" disabled={isPending} className="w-fit">
|
||||||
<Input
|
{isPending ? "Atualizando..." : "Atualizar nome"}
|
||||||
id="lastName"
|
</Button>
|
||||||
value={lastName}
|
|
||||||
onChange={(e) => setLastName(e.target.value)}
|
|
||||||
disabled={isPending}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" disabled={isPending}>
|
|
||||||
{isPending ? "Atualizando..." : "Atualizar nome"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { cn } from "@/lib/utils/ui";
|
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 { useState, useTransition, useMemo } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -44,13 +50,7 @@ function validatePassword(password: string): PasswordValidation {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function PasswordRequirement({
|
function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
|
||||||
met,
|
|
||||||
label,
|
|
||||||
}: {
|
|
||||||
met: boolean;
|
|
||||||
label: string;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -133,15 +133,16 @@ export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) {
|
|||||||
return (
|
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="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">
|
<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>
|
<div>
|
||||||
<h3 className="font-medium text-amber-900 dark:text-amber-400">
|
<h3 className="font-medium text-amber-900 dark:text-amber-400">
|
||||||
Alteração de senha não disponível
|
Alteração de senha não disponível
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-sm text-amber-800 dark:text-amber-500">
|
<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
|
Você fez login usando sua conta do Google. A senha é gerenciada
|
||||||
e não pode ser alterada aqui. Para modificar sua senha, acesse as configurações de
|
diretamente pelo Google e não pode ser alterada aqui. Para
|
||||||
segurança da sua conta Google.
|
modificar sua senha, acesse as configurações de segurança da sua
|
||||||
|
conta Google.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,173 +151,213 @@ export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
|
||||||
{/* Senha atual */}
|
<div className="space-y-4 max-w-md">
|
||||||
<div className="space-y-2">
|
{/* Senha atual */}
|
||||||
<Label htmlFor="currentPassword">
|
<div className="space-y-2">
|
||||||
Senha atual <span className="text-destructive">*</span>
|
<Label htmlFor="currentPassword">
|
||||||
</Label>
|
Senha atual <span className="text-destructive">*</span>
|
||||||
<div className="relative">
|
</Label>
|
||||||
<Input
|
<div className="relative">
|
||||||
id="currentPassword"
|
<Input
|
||||||
type={showCurrentPassword ? "text" : "password"}
|
id="currentPassword"
|
||||||
value={currentPassword}
|
type={showCurrentPassword ? "text" : "password"}
|
||||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
value={currentPassword}
|
||||||
disabled={isPending}
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
placeholder="Digite sua senha atual"
|
disabled={isPending}
|
||||||
required
|
placeholder="Digite sua senha atual"
|
||||||
aria-required="true"
|
required
|
||||||
aria-describedby="current-password-help"
|
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"
|
|
||||||
/>
|
/>
|
||||||
<PasswordRequirement
|
<button
|
||||||
met={passwordValidation.hasMaxLength}
|
type="button"
|
||||||
label="Máximo 23 caracteres"
|
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||||
/>
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
<PasswordRequirement
|
aria-label={
|
||||||
met={passwordValidation.hasLowercase}
|
showCurrentPassword
|
||||||
label="Letra minúscula"
|
? "Ocultar senha atual"
|
||||||
/>
|
: "Mostrar senha atual"
|
||||||
<PasswordRequirement
|
}
|
||||||
met={passwordValidation.hasUppercase}
|
>
|
||||||
label="Letra maiúscula"
|
{showCurrentPassword ? (
|
||||||
/>
|
<RiEyeOffLine size={20} />
|
||||||
<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" />
|
|
||||||
) : (
|
) : (
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Mensagem de erro em tempo real */}
|
|
||||||
{passwordsMatch === false && (
|
{/* Confirmar nova senha */}
|
||||||
<p id="confirm-password-help" className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
|
<div className="space-y-2">
|
||||||
<RiCloseLine className="h-3.5 w-3.5" />
|
<Label htmlFor="confirmPassword">
|
||||||
As senhas não coincidem
|
Confirmar nova senha <span className="text-destructive">*</span>
|
||||||
</p>
|
</Label>
|
||||||
)}
|
<div className="relative">
|
||||||
{passwordsMatch === true && (
|
<Input
|
||||||
<p id="confirm-password-help" className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
|
id="confirmPassword"
|
||||||
<RiCheckLine className="h-3.5 w-3.5" />
|
type={showConfirmPassword ? "text" : "password"}
|
||||||
As senhas coincidem
|
value={confirmPassword}
|
||||||
</p>
|
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>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" disabled={isPending || passwordsMatch === false || (newPassword.length > 0 && !passwordValidation.isValid)}>
|
<div className="flex justify-end">
|
||||||
{isPending ? "Atualizando..." : "Atualizar senha"}
|
<Button
|
||||||
</Button>
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
isPending ||
|
||||||
|
passwordsMatch === false ||
|
||||||
|
(newPassword.length > 0 && !passwordValidation.isValid)
|
||||||
|
}
|
||||||
|
className="w-fit"
|
||||||
|
>
|
||||||
|
{isPending ? "Atualizando..." : "Atualizar senha"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export function MonthlyCalendar({
|
|||||||
cartaoOptions={formOptions.cartaoOptions}
|
cartaoOptions={formOptions.cartaoOptions}
|
||||||
categoriaOptions={formOptions.categoriaOptions}
|
categoriaOptions={formOptions.categoriaOptions}
|
||||||
estabelecimentos={formOptions.estabelecimentos}
|
estabelecimentos={formOptions.estabelecimentos}
|
||||||
|
periodPreferences={formOptions.periodPreferences}
|
||||||
defaultPeriod={period.period}
|
defaultPeriod={period.period}
|
||||||
defaultPurchaseDate={createDate ?? undefined}
|
defaultPurchaseDate={createDate ?? undefined}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { LancamentoItem, SelectOption } from "@/components/lancamentos/types";
|
import type { LancamentoItem, SelectOption } from "@/components/lancamentos/types";
|
||||||
|
import type { PeriodPreferences } from "@/lib/user-preferences/period";
|
||||||
|
|
||||||
export type CalendarEventType = "lancamento" | "boleto" | "cartao";
|
export type CalendarEventType = "lancamento" | "boleto" | "cartao";
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ export type CalendarFormOptions = {
|
|||||||
cartaoOptions: SelectOption[];
|
cartaoOptions: SelectOption[];
|
||||||
categoriaOptions: SelectOption[];
|
categoriaOptions: SelectOption[];
|
||||||
estabelecimentos: string[];
|
estabelecimentos: string[];
|
||||||
|
periodPreferences: PeriodPreferences;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CalendarData = {
|
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";
|
"use client";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils/ui";
|
import { cn } from "@/lib/utils/ui";
|
||||||
import {
|
import {
|
||||||
RiArrowLeftRightLine,
|
RiArrowLeftRightLine,
|
||||||
RiDeleteBin5Line,
|
RiDeleteBin5Line,
|
||||||
RiEyeOffLine,
|
|
||||||
RiFileList2Line,
|
RiFileList2Line,
|
||||||
RiPencilLine,
|
RiPencilLine,
|
||||||
|
RiInformationLine,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import MoneyValues from "../money-values";
|
import MoneyValues from "../money-values";
|
||||||
import { Card, CardContent, CardFooter } from "../ui/card";
|
import { Card, CardContent, CardFooter } from "../ui/card";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
|
|
||||||
interface AccountCardProps {
|
interface AccountCardProps {
|
||||||
accountName: string;
|
accountName: string;
|
||||||
@@ -19,6 +19,7 @@ interface AccountCardProps {
|
|||||||
status?: string;
|
status?: string;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
excludeFromBalance?: boolean;
|
excludeFromBalance?: boolean;
|
||||||
|
excludeInitialBalanceFromIncome?: boolean;
|
||||||
onViewStatement?: () => void;
|
onViewStatement?: () => void;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
onRemove?: () => void;
|
onRemove?: () => void;
|
||||||
@@ -33,6 +34,7 @@ export function AccountCard({
|
|||||||
status,
|
status,
|
||||||
icon,
|
icon,
|
||||||
excludeFromBalance,
|
excludeFromBalance,
|
||||||
|
excludeInitialBalanceFromIncome,
|
||||||
onViewStatement,
|
onViewStatement,
|
||||||
onEdit,
|
onEdit,
|
||||||
onRemove,
|
onRemove,
|
||||||
@@ -85,21 +87,39 @@ export function AccountCard({
|
|||||||
<h2 className="text-lg font-semibold text-foreground">
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
{accountName}
|
{accountName}
|
||||||
</h2>
|
</h2>
|
||||||
{excludeFromBalance ? (
|
|
||||||
<div
|
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
|
||||||
className="flex items-center gap-1 text-muted-foreground"
|
<Tooltip>
|
||||||
title="Excluída do saldo geral"
|
<TooltipTrigger asChild>
|
||||||
>
|
<div className="flex items-center">
|
||||||
<RiEyeOffLine className="size-4" aria-hidden />
|
<RiInformationLine className="size-5 text-muted-foreground hover:text-foreground transition-colors cursor-help" />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</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>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
<p className="text-sm text-muted-foreground">Saldo</p>
|
<MoneyValues amount={balance} className="text-3xl" />
|
||||||
<p className="text-3xl text-foreground">
|
|
||||||
<MoneyValues amount={balance} className="text-3xl" />
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{accountType}</p>
|
<p className="text-sm text-muted-foreground">{accountType}</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const DEFAULT_ACCOUNT_TYPES = [
|
|||||||
"Conta Poupança",
|
"Conta Poupança",
|
||||||
"Carteira Digital",
|
"Carteira Digital",
|
||||||
"Conta Investimento",
|
"Conta Investimento",
|
||||||
"Cartão Pré-pago",
|
"Pré-Pago | VR/VA",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const DEFAULT_ACCOUNT_STATUS = ["Ativa", "Inativa"] as const;
|
const DEFAULT_ACCOUNT_STATUS = ["Ativa", "Inativa"] as const;
|
||||||
@@ -75,6 +75,8 @@ const buildInitialValues = ({
|
|||||||
logo: selectedLogo,
|
logo: selectedLogo,
|
||||||
initialBalance: formatInitialBalanceInput(account?.initialBalance ?? 0),
|
initialBalance: formatInitialBalanceInput(account?.initialBalance ?? 0),
|
||||||
excludeFromBalance: account?.excludeFromBalance ?? false,
|
excludeFromBalance: account?.excludeFromBalance ?? false,
|
||||||
|
excludeInitialBalanceFromIncome:
|
||||||
|
account?.excludeInitialBalanceFromIncome ?? false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -106,17 +106,39 @@ export function AccountFormFields({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 sm:col-span-2">
|
<div className="flex flex-col gap-3 sm:col-span-2">
|
||||||
<Checkbox
|
<div className="flex items-center gap-2">
|
||||||
id="exclude-from-balance"
|
<Checkbox
|
||||||
checked={values.excludeFromBalance}
|
id="exclude-from-balance"
|
||||||
onCheckedChange={(checked) =>
|
checked={values.excludeFromBalance === true || values.excludeFromBalance === "true"}
|
||||||
onChange("excludeFromBalance", checked ? "true" : "false")
|
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
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -137,10 +137,13 @@ export function AccountsPage({ accounts, logoOptions }: AccountsPageProps) {
|
|||||||
<AccountCard
|
<AccountCard
|
||||||
key={account.id}
|
key={account.id}
|
||||||
accountName={account.name}
|
accountName={account.name}
|
||||||
accountType={`${account.accountType} - ${account.status}`}
|
accountType={`${account.accountType}`}
|
||||||
balance={account.balance ?? account.initialBalance ?? 0}
|
balance={account.balance ?? account.initialBalance ?? 0}
|
||||||
status={account.status}
|
status={account.status}
|
||||||
excludeFromBalance={account.excludeFromBalance}
|
excludeFromBalance={account.excludeFromBalance}
|
||||||
|
excludeInitialBalanceFromIncome={
|
||||||
|
account.excludeInitialBalanceFromIncome
|
||||||
|
}
|
||||||
icon={
|
icon={
|
||||||
logoSrc ? (
|
logoSrc ? (
|
||||||
<Image
|
<Image
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type Account = {
|
|||||||
initialBalance: number;
|
initialBalance: number;
|
||||||
balance?: number | null;
|
balance?: number | null;
|
||||||
excludeFromBalance?: boolean;
|
excludeFromBalance?: boolean;
|
||||||
|
excludeInitialBalanceFromIncome?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AccountFormValues = {
|
export type AccountFormValues = {
|
||||||
@@ -18,4 +19,5 @@ export type AccountFormValues = {
|
|||||||
logo: string;
|
logo: string;
|
||||||
initialBalance: string;
|
initialBalance: string;
|
||||||
excludeFromBalance: boolean;
|
excludeFromBalance: boolean;
|
||||||
|
excludeInitialBalanceFromIncome: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -343,7 +343,7 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
|
|||||||
<RiBarChartBoxLine className="size-6 text-muted-foreground" />
|
<RiBarChartBoxLine className="size-6 text-muted-foreground" />
|
||||||
}
|
}
|
||||||
title="Selecione categorias para visualizar"
|
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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Card } from "../ui/card";
|
|||||||
|
|
||||||
type DashboardWelcomeProps = {
|
type DashboardWelcomeProps = {
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
|
disableMagnetlines?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const capitalizeFirstLetter = (value: string) =>
|
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 displayName = name && name.trim().length > 0 ? name : "Administrador";
|
||||||
const formattedDate = formatCurrentDate();
|
const formattedDate = formatCurrentDate();
|
||||||
const greeting = getGreeting();
|
const greeting = getGreeting();
|
||||||
@@ -63,6 +64,7 @@ export function DashboardWelcome({ name }: DashboardWelcomeProps) {
|
|||||||
lineHeight="5vmin"
|
lineHeight="5vmin"
|
||||||
baseAngle={0}
|
baseAngle={0}
|
||||||
className="text-welcome-banner-foreground"
|
className="text-welcome-banner-foreground"
|
||||||
|
disabled={disableMagnetlines}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative tracking-tight text-welcome-banner-foreground">
|
<div className="relative tracking-tight text-welcome-banner-foreground">
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export function MyAccountsWidget({
|
|||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={account.id}
|
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">
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
{logoSrc ? (
|
{logoSrc ? (
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { ChangelogNotification } from "@/components/changelog/changelog-notification";
|
|
||||||
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
|
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
|
||||||
import { NotificationBell } from "@/components/notificacoes/notification-bell";
|
import { NotificationBell } from "@/components/notificacoes/notification-bell";
|
||||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import { getUser } from "@/lib/auth/server";
|
import { getUser } from "@/lib/auth/server";
|
||||||
import { getUnreadUpdates } from "@/lib/changelog/data";
|
|
||||||
import type { DashboardNotificationsSnapshot } from "@/lib/dashboard/notifications";
|
import type { DashboardNotificationsSnapshot } from "@/lib/dashboard/notifications";
|
||||||
import { AnimatedThemeToggler } from "./animated-theme-toggler";
|
import { AnimatedThemeToggler } from "./animated-theme-toggler";
|
||||||
import LogoutButton from "./auth/logout-button";
|
import LogoutButton from "./auth/logout-button";
|
||||||
@@ -16,7 +14,6 @@ type SiteHeaderProps = {
|
|||||||
|
|
||||||
export async function SiteHeader({ notificationsSnapshot }: SiteHeaderProps) {
|
export async function SiteHeader({ notificationsSnapshot }: SiteHeaderProps) {
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
const { unreadCount, allEntries } = await getUnreadUpdates(user.id);
|
|
||||||
|
|
||||||
return (
|
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">
|
<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 />
|
<PrivacyModeToggle />
|
||||||
<AnimatedThemeToggler />
|
<AnimatedThemeToggler />
|
||||||
<span className="text-muted-foreground">|</span>
|
<span className="text-muted-foreground">|</span>
|
||||||
<ChangelogNotification
|
|
||||||
unreadCount={unreadCount}
|
|
||||||
entries={allEntries}
|
|
||||||
/>
|
|
||||||
<FeedbackDialog />
|
<FeedbackDialog />
|
||||||
<LogoutButton />
|
<LogoutButton />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||||
import { useFormState } from "@/hooks/use-form-state";
|
import { useFormState } from "@/hooks/use-form-state";
|
||||||
import type { EligibleInstallment } from "@/lib/installments/anticipation-types";
|
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 { RiLoader4Line } from "@remixicon/react";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -53,6 +55,7 @@ interface AnticipateInstallmentsDialogProps {
|
|||||||
categorias: Array<{ id: string; name: string; icon: string | null }>;
|
categorias: Array<{ id: string; name: string; icon: string | null }>;
|
||||||
pagadores: Array<{ id: string; name: string }>;
|
pagadores: Array<{ id: string; name: string }>;
|
||||||
defaultPeriod: string;
|
defaultPeriod: string;
|
||||||
|
periodPreferences: PeriodPreferences;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
@@ -65,57 +68,6 @@ type AnticipationFormValues = {
|
|||||||
note: string;
|
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({
|
export function AnticipateInstallmentsDialog({
|
||||||
trigger,
|
trigger,
|
||||||
seriesId,
|
seriesId,
|
||||||
@@ -123,6 +75,7 @@ export function AnticipateInstallmentsDialog({
|
|||||||
categorias,
|
categorias,
|
||||||
pagadores,
|
pagadores,
|
||||||
defaultPeriod,
|
defaultPeriod,
|
||||||
|
periodPreferences,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: AnticipateInstallmentsDialogProps) {
|
}: AnticipateInstallmentsDialogProps) {
|
||||||
@@ -152,8 +105,13 @@ export function AnticipateInstallmentsDialog({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const periodOptions = useMemo(
|
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
|
// Buscar parcelas elegíveis ao abrir o dialog
|
||||||
|
|||||||
@@ -110,10 +110,14 @@ export function LancamentoDetailsDialog({
|
|||||||
<span className="capitalize">
|
<span className="capitalize">
|
||||||
<Badge
|
<Badge
|
||||||
variant={getTransactionBadgeVariant(
|
variant={getTransactionBadgeVariant(
|
||||||
lancamento.transactionType
|
lancamento.categoriaName === "Saldo inicial"
|
||||||
|
? "Saldo inicial"
|
||||||
|
: lancamento.transactionType
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{lancamento.transactionType}
|
{lancamento.categoriaName === "Saldo inicial"
|
||||||
|
? "Saldo Inicial"
|
||||||
|
: lancamento.transactionType}
|
||||||
</Badge>
|
</Badge>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { LancamentoFormState } from "@/lib/lancamentos/form-helpers";
|
import type { LancamentoFormState } from "@/lib/lancamentos/form-helpers";
|
||||||
|
import type { PeriodPreferences } from "@/lib/user-preferences/period";
|
||||||
import type { LancamentoItem, SelectOption } from "../../types";
|
import type { LancamentoItem, SelectOption } from "../../types";
|
||||||
|
|
||||||
export type FormState = LancamentoFormState;
|
export type FormState = LancamentoFormState;
|
||||||
@@ -17,6 +18,7 @@ export interface LancamentoDialogProps {
|
|||||||
estabelecimentos: string[];
|
estabelecimentos: string[];
|
||||||
lancamento?: LancamentoItem;
|
lancamento?: LancamentoItem;
|
||||||
defaultPeriod?: string;
|
defaultPeriod?: string;
|
||||||
|
periodPreferences: PeriodPreferences;
|
||||||
defaultCartaoId?: string | null;
|
defaultCartaoId?: string | null;
|
||||||
defaultPaymentMethod?: string | null;
|
defaultPaymentMethod?: string | null;
|
||||||
defaultPurchaseDate?: string | null;
|
defaultPurchaseDate?: string | null;
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export function LancamentoDialog({
|
|||||||
estabelecimentos,
|
estabelecimentos,
|
||||||
lancamento,
|
lancamento,
|
||||||
defaultPeriod,
|
defaultPeriod,
|
||||||
|
periodPreferences,
|
||||||
defaultCartaoId,
|
defaultCartaoId,
|
||||||
defaultPaymentMethod,
|
defaultPaymentMethod,
|
||||||
defaultPurchaseDate,
|
defaultPurchaseDate,
|
||||||
@@ -125,8 +126,13 @@ export function LancamentoDialog({
|
|||||||
}, [categoriaOptions, formState.transactionType]);
|
}, [categoriaOptions, formState.transactionType]);
|
||||||
|
|
||||||
const monthOptions = useMemo(
|
const monthOptions = useMemo(
|
||||||
() => createMonthOptions(formState.period),
|
() =>
|
||||||
[formState.period]
|
createMonthOptions(
|
||||||
|
formState.period,
|
||||||
|
periodPreferences.monthsBefore,
|
||||||
|
periodPreferences.monthsAfter
|
||||||
|
),
|
||||||
|
[formState.period, periodPreferences.monthsBefore, periodPreferences.monthsAfter]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFieldChange = useCallback(
|
const handleFieldChange = useCallback(
|
||||||
|
|||||||
@@ -31,8 +31,18 @@ export function PaymentMethodSection({
|
|||||||
"Dinheiro",
|
"Dinheiro",
|
||||||
"Boleto",
|
"Boleto",
|
||||||
"Cartão de débito",
|
"Cartão de débito",
|
||||||
|
"Pré-Pago | VR/VA",
|
||||||
|
"Transferência bancária",
|
||||||
].includes(formState.paymentMethod);
|
].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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{!isUpdateMode ? (
|
{!isUpdateMode ? (
|
||||||
@@ -56,7 +66,9 @@ export function PaymentMethodSection({
|
|||||||
>
|
>
|
||||||
<SelectValue placeholder="Selecione" className="w-full">
|
<SelectValue placeholder="Selecione" className="w-full">
|
||||||
{formState.paymentMethod && (
|
{formState.paymentMethod && (
|
||||||
<PaymentMethodSelectContent label={formState.paymentMethod} />
|
<PaymentMethodSelectContent
|
||||||
|
label={formState.paymentMethod}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -138,7 +150,7 @@ export function PaymentMethodSection({
|
|||||||
<SelectValue placeholder="Selecione">
|
<SelectValue placeholder="Selecione">
|
||||||
{formState.contaId &&
|
{formState.contaId &&
|
||||||
(() => {
|
(() => {
|
||||||
const selectedOption = contaOptions.find(
|
const selectedOption = filteredContaOptions.find(
|
||||||
(opt) => opt.value === formState.contaId
|
(opt) => opt.value === formState.contaId
|
||||||
);
|
);
|
||||||
return selectedOption ? (
|
return selectedOption ? (
|
||||||
@@ -152,14 +164,14 @@ export function PaymentMethodSection({
|
|||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{contaOptions.length === 0 ? (
|
{filteredContaOptions.length === 0 ? (
|
||||||
<div className="px-2 py-6 text-center">
|
<div className="px-2 py-6 text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Nenhuma conta cadastrada
|
Nenhuma conta cadastrada
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
contaOptions.map((option) => (
|
filteredContaOptions.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
<ContaCartaoSelectContent
|
<ContaCartaoSelectContent
|
||||||
label={option.label}
|
label={option.label}
|
||||||
@@ -246,7 +258,7 @@ export function PaymentMethodSection({
|
|||||||
<SelectValue placeholder="Selecione">
|
<SelectValue placeholder="Selecione">
|
||||||
{formState.contaId &&
|
{formState.contaId &&
|
||||||
(() => {
|
(() => {
|
||||||
const selectedOption = contaOptions.find(
|
const selectedOption = filteredContaOptions.find(
|
||||||
(opt) => opt.value === formState.contaId
|
(opt) => opt.value === formState.contaId
|
||||||
);
|
);
|
||||||
return selectedOption ? (
|
return selectedOption ? (
|
||||||
@@ -260,14 +272,14 @@ export function PaymentMethodSection({
|
|||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{contaOptions.length === 0 ? (
|
{filteredContaOptions.length === 0 ? (
|
||||||
<div className="px-2 py-6 text-center">
|
<div className="px-2 py-6 text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Nenhuma conta cadastrada
|
Nenhuma conta cadastrada
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
contaOptions.map((option) => (
|
filteredContaOptions.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
<ContaCartaoSelectContent
|
<ContaCartaoSelectContent
|
||||||
label={option.label}
|
label={option.label}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { groupAndSortCategorias } from "@/lib/lancamentos/categoria-helpers";
|
|||||||
import { LANCAMENTO_PAYMENT_METHODS } from "@/lib/lancamentos/constants";
|
import { LANCAMENTO_PAYMENT_METHODS } from "@/lib/lancamentos/constants";
|
||||||
import { getTodayDateString } from "@/lib/utils/date";
|
import { getTodayDateString } from "@/lib/utils/date";
|
||||||
import { createMonthOptions } from "@/lib/utils/period";
|
import { createMonthOptions } from "@/lib/utils/period";
|
||||||
|
import type { PeriodPreferences } from "@/lib/user-preferences/period";
|
||||||
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react";
|
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -51,6 +52,7 @@ interface MassAddDialogProps {
|
|||||||
categoriaOptions: SelectOption[];
|
categoriaOptions: SelectOption[];
|
||||||
estabelecimentos: string[];
|
estabelecimentos: string[];
|
||||||
selectedPeriod: string;
|
selectedPeriod: string;
|
||||||
|
periodPreferences: PeriodPreferences;
|
||||||
defaultPagadorId?: string | null;
|
defaultPagadorId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +93,7 @@ export function MassAddDialog({
|
|||||||
categoriaOptions,
|
categoriaOptions,
|
||||||
estabelecimentos,
|
estabelecimentos,
|
||||||
selectedPeriod,
|
selectedPeriod,
|
||||||
|
periodPreferences,
|
||||||
defaultPagadorId,
|
defaultPagadorId,
|
||||||
}: MassAddDialogProps) {
|
}: MassAddDialogProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -119,8 +122,13 @@ export function MassAddDialog({
|
|||||||
|
|
||||||
// Period options
|
// Period options
|
||||||
const periodOptions = useMemo(
|
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
|
// Categorias agrupadas e filtradas por tipo de transação
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import type {
|
|||||||
LancamentoItem,
|
LancamentoItem,
|
||||||
SelectOption,
|
SelectOption,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import type { PeriodPreferences } from "@/lib/user-preferences/period";
|
||||||
|
|
||||||
interface LancamentosPageProps {
|
interface LancamentosPageProps {
|
||||||
lancamentos: LancamentoItem[];
|
lancamentos: LancamentoItem[];
|
||||||
@@ -39,6 +40,7 @@ interface LancamentosPageProps {
|
|||||||
contaCartaoFilterOptions: ContaCartaoFilterOption[];
|
contaCartaoFilterOptions: ContaCartaoFilterOption[];
|
||||||
selectedPeriod: string;
|
selectedPeriod: string;
|
||||||
estabelecimentos: string[];
|
estabelecimentos: string[];
|
||||||
|
periodPreferences: PeriodPreferences;
|
||||||
allowCreate?: boolean;
|
allowCreate?: boolean;
|
||||||
defaultCartaoId?: string | null;
|
defaultCartaoId?: string | null;
|
||||||
defaultPaymentMethod?: string | null;
|
defaultPaymentMethod?: string | null;
|
||||||
@@ -59,6 +61,7 @@ export function LancamentosPage({
|
|||||||
contaCartaoFilterOptions,
|
contaCartaoFilterOptions,
|
||||||
selectedPeriod,
|
selectedPeriod,
|
||||||
estabelecimentos,
|
estabelecimentos,
|
||||||
|
periodPreferences,
|
||||||
allowCreate = true,
|
allowCreate = true,
|
||||||
defaultCartaoId,
|
defaultCartaoId,
|
||||||
defaultPaymentMethod,
|
defaultPaymentMethod,
|
||||||
@@ -114,7 +117,7 @@ export function LancamentosPage({
|
|||||||
return;
|
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)) {
|
if (!supportedMethods.includes(item.paymentMethod)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -354,6 +357,7 @@ export function LancamentosPage({
|
|||||||
categoriaOptions={categoriaOptions}
|
categoriaOptions={categoriaOptions}
|
||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
defaultPeriod={selectedPeriod}
|
defaultPeriod={selectedPeriod}
|
||||||
|
periodPreferences={periodPreferences}
|
||||||
defaultCartaoId={defaultCartaoId}
|
defaultCartaoId={defaultCartaoId}
|
||||||
defaultPaymentMethod={defaultPaymentMethod}
|
defaultPaymentMethod={defaultPaymentMethod}
|
||||||
lockCartaoSelection={lockCartaoSelection}
|
lockCartaoSelection={lockCartaoSelection}
|
||||||
@@ -379,6 +383,7 @@ export function LancamentosPage({
|
|||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
lancamento={lancamentoToCopy ?? undefined}
|
lancamento={lancamentoToCopy ?? undefined}
|
||||||
defaultPeriod={selectedPeriod}
|
defaultPeriod={selectedPeriod}
|
||||||
|
periodPreferences={periodPreferences}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LancamentoDialog
|
<LancamentoDialog
|
||||||
@@ -394,6 +399,7 @@ export function LancamentosPage({
|
|||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
lancamento={selectedLancamento ?? undefined}
|
lancamento={selectedLancamento ?? undefined}
|
||||||
defaultPeriod={selectedPeriod}
|
defaultPeriod={selectedPeriod}
|
||||||
|
periodPreferences={periodPreferences}
|
||||||
onBulkEditRequest={handleBulkEditRequest}
|
onBulkEditRequest={handleBulkEditRequest}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -473,6 +479,7 @@ export function LancamentosPage({
|
|||||||
categoriaOptions={categoriaOptions}
|
categoriaOptions={categoriaOptions}
|
||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
selectedPeriod={selectedPeriod}
|
selectedPeriod={selectedPeriod}
|
||||||
|
periodPreferences={periodPreferences}
|
||||||
defaultPagadorId={defaultPagadorId}
|
defaultPagadorId={defaultPagadorId}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -508,6 +515,7 @@ export function LancamentosPage({
|
|||||||
name: p.label,
|
name: p.label,
|
||||||
}))}
|
}))}
|
||||||
defaultPeriod={selectedPeriod}
|
defaultPeriod={selectedPeriod}
|
||||||
|
periodPreferences={periodPreferences}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import type { InstallmentAnticipationWithRelations } from "@/lib/installments/anticipation-types";
|
import type { InstallmentAnticipationWithRelations } from "@/lib/installments/anticipation-types";
|
||||||
|
import { displayPeriod } from "@/lib/utils/period";
|
||||||
import { RiCalendarCheckLine, RiCloseLine, RiEyeLine } from "@remixicon/react";
|
import { RiCalendarCheckLine, RiCloseLine, RiEyeLine } from "@remixicon/react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
@@ -26,24 +27,6 @@ interface AnticipationCardProps {
|
|||||||
onCanceled?: () => void;
|
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({
|
export function AnticipationCard({
|
||||||
anticipation,
|
anticipation,
|
||||||
onViewLancamento,
|
onViewLancamento,
|
||||||
@@ -93,7 +76,7 @@ export function AnticipationCard({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
{formatPeriodLabel(anticipation.anticipationPeriod)}
|
{displayPeriod(anticipation.anticipationPeriod)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -288,16 +288,20 @@ const buildColumns = ({
|
|||||||
{
|
{
|
||||||
accessorKey: "transactionType",
|
accessorKey: "transactionType",
|
||||||
header: "Transação",
|
header: "Transação",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<TypeBadge
|
const type =
|
||||||
type={
|
row.original.categoriaName === "Saldo inicial"
|
||||||
row.original.transactionType as
|
? "Saldo inicial"
|
||||||
| "Despesa"
|
: row.original.transactionType;
|
||||||
| "Receita"
|
|
||||||
| "Transferência"
|
return (
|
||||||
}
|
<TypeBadge
|
||||||
/>
|
type={
|
||||||
),
|
type as "Despesa" | "Receita" | "Transferência" | "Saldo inicial"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "amount",
|
accessorKey: "amount",
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export type SelectOption = {
|
|||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
logo?: string | null;
|
logo?: string | null;
|
||||||
icon?: string | null;
|
icon?: string | null;
|
||||||
|
accountType?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LancamentoFilterOption = {
|
export type LancamentoFilterOption = {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import React, { CSSProperties, useEffect, useRef } from "react";
|
import React, { CSSProperties, useEffect, useRef } from "react";
|
||||||
|
|
||||||
interface MagnetLinesProps {
|
interface MagnetLinesProps {
|
||||||
@@ -10,6 +12,7 @@ interface MagnetLinesProps {
|
|||||||
baseAngle?: number;
|
baseAngle?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MagnetLines: React.FC<MagnetLinesProps> = ({
|
const MagnetLines: React.FC<MagnetLinesProps> = ({
|
||||||
@@ -22,9 +25,15 @@ const MagnetLines: React.FC<MagnetLinesProps> = ({
|
|||||||
baseAngle = -10,
|
baseAngle = -10,
|
||||||
className = "",
|
className = "",
|
||||||
style = {},
|
style = {},
|
||||||
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// Se magnetlines estiver desabilitado, não renderiza nada
|
||||||
|
if (disabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||||
import { useFormState } from "@/hooks/use-form-state";
|
import { useFormState } from "@/hooks/use-form-state";
|
||||||
|
import type { PeriodPreferences } from "@/lib/user-preferences/period";
|
||||||
|
import { createMonthOptions } from "@/lib/utils/period";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -43,64 +45,11 @@ interface BudgetDialogProps {
|
|||||||
budget?: Budget;
|
budget?: Budget;
|
||||||
categories: BudgetCategory[];
|
categories: BudgetCategory[];
|
||||||
defaultPeriod: string;
|
defaultPeriod: string;
|
||||||
|
periodPreferences: PeriodPreferences;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
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 = ({
|
const buildInitialValues = ({
|
||||||
budget,
|
budget,
|
||||||
defaultPeriod,
|
defaultPeriod,
|
||||||
@@ -119,6 +68,7 @@ export function BudgetDialog({
|
|||||||
budget,
|
budget,
|
||||||
categories,
|
categories,
|
||||||
defaultPeriod,
|
defaultPeriod,
|
||||||
|
periodPreferences,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: BudgetDialogProps) {
|
}: BudgetDialogProps) {
|
||||||
@@ -161,8 +111,13 @@ export function BudgetDialog({
|
|||||||
}, [dialogOpen]);
|
}, [dialogOpen]);
|
||||||
|
|
||||||
const periodOptions = useMemo(
|
const periodOptions = useMemo(
|
||||||
() => buildPeriodOptions(formState.period),
|
() =>
|
||||||
[formState.period]
|
createMonthOptions(
|
||||||
|
formState.period,
|
||||||
|
periodPreferences.monthsBefore,
|
||||||
|
periodPreferences.monthsAfter
|
||||||
|
),
|
||||||
|
[formState.period, periodPreferences.monthsBefore, periodPreferences.monthsAfter]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { deleteBudgetAction } from "@/app/(dashboard)/orcamentos/actions";
|
|||||||
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
|
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
|
||||||
import { EmptyState } from "@/components/empty-state";
|
import { EmptyState } from "@/components/empty-state";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { PeriodPreferences } from "@/lib/user-preferences/period";
|
||||||
import { RiAddCircleLine, RiFundsLine } from "@remixicon/react";
|
import { RiAddCircleLine, RiFundsLine } from "@remixicon/react";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -17,6 +18,7 @@ interface BudgetsPageProps {
|
|||||||
categories: BudgetCategory[];
|
categories: BudgetCategory[];
|
||||||
selectedPeriod: string;
|
selectedPeriod: string;
|
||||||
periodLabel: string;
|
periodLabel: string;
|
||||||
|
periodPreferences: PeriodPreferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BudgetsPage({
|
export function BudgetsPage({
|
||||||
@@ -24,6 +26,7 @@ export function BudgetsPage({
|
|||||||
categories,
|
categories,
|
||||||
selectedPeriod,
|
selectedPeriod,
|
||||||
periodLabel,
|
periodLabel,
|
||||||
|
periodPreferences,
|
||||||
}: BudgetsPageProps) {
|
}: BudgetsPageProps) {
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [selectedBudget, setSelectedBudget] = useState<Budget | null>(null);
|
const [selectedBudget, setSelectedBudget] = useState<Budget | null>(null);
|
||||||
@@ -91,6 +94,7 @@ export function BudgetsPage({
|
|||||||
mode="create"
|
mode="create"
|
||||||
categories={categories}
|
categories={categories}
|
||||||
defaultPeriod={selectedPeriod}
|
defaultPeriod={selectedPeriod}
|
||||||
|
periodPreferences={periodPreferences}
|
||||||
trigger={
|
trigger={
|
||||||
<Button disabled={categories.length === 0}>
|
<Button disabled={categories.length === 0}>
|
||||||
<RiAddCircleLine className="size-4" />
|
<RiAddCircleLine className="size-4" />
|
||||||
@@ -128,6 +132,7 @@ export function BudgetsPage({
|
|||||||
budget={selectedBudget ?? undefined}
|
budget={selectedBudget ?? undefined}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
defaultPeriod={selectedPeriod}
|
defaultPeriod={selectedPeriod}
|
||||||
|
periodPreferences={periodPreferences}
|
||||||
open={editOpen && !!selectedBudget}
|
open={editOpen && !!selectedBudget}
|
||||||
onOpenChange={handleEditOpenChange}
|
onOpenChange={handleEditOpenChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
RiBankLine,
|
RiBankLine,
|
||||||
RiCalendarEventLine,
|
RiCalendarEventLine,
|
||||||
RiDashboardLine,
|
RiDashboardLine,
|
||||||
|
RiGitCommitLine,
|
||||||
RiFundsLine,
|
RiFundsLine,
|
||||||
RiGroupLine,
|
RiGroupLine,
|
||||||
RiLineChartLine,
|
RiLineChartLine,
|
||||||
@@ -161,6 +162,11 @@ export function createSidebarNavData(pagadores: PagadorLike[]): SidebarNavData {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
navSecondary: [
|
navSecondary: [
|
||||||
|
{
|
||||||
|
title: "Changelog",
|
||||||
|
url: "/changelog",
|
||||||
|
icon: RiGitCommitLine,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Ajustes",
|
title: "Ajustes",
|
||||||
url: "/ajustes",
|
url: "/ajustes",
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ type TypeBadgeType =
|
|||||||
| "Receita"
|
| "Receita"
|
||||||
| "Despesa"
|
| "Despesa"
|
||||||
| "Transferência"
|
| "Transferência"
|
||||||
| "transferência";
|
| "transferência"
|
||||||
|
| "Saldo inicial"
|
||||||
|
| "Saldo Inicial";
|
||||||
|
|
||||||
interface TypeBadgeProps {
|
interface TypeBadgeProps {
|
||||||
type: TypeBadgeType;
|
type: TypeBadgeType | string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,23 +24,26 @@ const TYPE_LABELS: Record<string, string> = {
|
|||||||
Despesa: "Despesa",
|
Despesa: "Despesa",
|
||||||
Transferência: "Transferência",
|
Transferência: "Transferência",
|
||||||
transferência: "Transferência",
|
transferência: "Transferência",
|
||||||
|
"Saldo inicial": "Saldo Inicial",
|
||||||
|
"Saldo Inicial": "Saldo Inicial",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TypeBadge({ type, className }: TypeBadgeProps) {
|
export function TypeBadge({ type, className }: TypeBadgeProps) {
|
||||||
const normalizedType = type.toLowerCase();
|
const normalizedType = type.toLowerCase();
|
||||||
const isReceita = normalizedType === "receita";
|
const isReceita = normalizedType === "receita";
|
||||||
const isTransferencia = normalizedType === "transferência";
|
const isTransferencia = normalizedType === "transferência";
|
||||||
|
const isSaldoInicial = normalizedType === "saldo inicial";
|
||||||
const label = TYPE_LABELS[type] || type;
|
const label = TYPE_LABELS[type] || type;
|
||||||
|
|
||||||
const colorClass = isTransferencia
|
const colorClass = isTransferencia
|
||||||
? "text-blue-700 dark:text-blue-400"
|
? "text-blue-700 dark:text-blue-400"
|
||||||
: isReceita
|
: (isReceita || isSaldoInicial)
|
||||||
? "text-green-700 dark:text-green-400"
|
? "text-green-700 dark:text-green-400"
|
||||||
: "text-red-700 dark:text-red-400";
|
: "text-red-700 dark:text-red-400";
|
||||||
|
|
||||||
const dotColor = isTransferencia
|
const dotColor = isTransferencia
|
||||||
? "bg-blue-700 dark:bg-blue-400"
|
? "bg-blue-700 dark:bg-blue-400"
|
||||||
: isReceita
|
: (isReceita || isSaldoInicial)
|
||||||
? "bg-green-600 dark:bg-green-400"
|
? "bg-green-600 dark:bg-green-400"
|
||||||
: "bg-red-600 dark:bg-red-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 =====================
|
// ===================== PUBLIC TABLES =====================
|
||||||
|
|
||||||
export const contas = pgTable(
|
export const contas = pgTable(
|
||||||
@@ -119,6 +144,9 @@ export const contas = pgTable(
|
|||||||
excludeFromBalance: boolean("excluir_do_saldo")
|
excludeFromBalance: boolean("excluir_do_saldo")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
excludeInitialBalanceFromIncome: boolean("excluir_saldo_inicial_receitas")
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
@@ -359,30 +387,6 @@ export const anotacoes = pgTable("anotacoes", {
|
|||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.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(
|
export const savedInsights = pgTable(
|
||||||
"saved_insights",
|
"saved_insights",
|
||||||
@@ -556,7 +560,6 @@ export const userRelations = relations(user, ({ many, one }) => ({
|
|||||||
orcamentos: many(orcamentos),
|
orcamentos: many(orcamentos),
|
||||||
pagadores: many(pagadores),
|
pagadores: many(pagadores),
|
||||||
installmentAnticipations: many(installmentAnticipations),
|
installmentAnticipations: many(installmentAnticipations),
|
||||||
updateLogs: many(userUpdateLog),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const accountRelations = relations(account, ({ one }) => ({
|
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 }) => ({
|
export const lancamentosRelations = relations(lancamentos, ({ one }) => ({
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
@@ -725,6 +722,8 @@ export type NewUser = typeof user.$inferInsert;
|
|||||||
export type Account = typeof account.$inferSelect;
|
export type Account = typeof account.$inferSelect;
|
||||||
export type Session = typeof session.$inferSelect;
|
export type Session = typeof session.$inferSelect;
|
||||||
export type Verification = typeof verification.$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 Conta = typeof contas.$inferSelect;
|
||||||
export type Categoria = typeof categorias.$inferSelect;
|
export type Categoria = typeof categorias.$inferSelect;
|
||||||
export type Pagador = typeof pagadores.$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 Lancamento = typeof lancamentos.$inferSelect;
|
||||||
export type InstallmentAnticipation =
|
export type InstallmentAnticipation =
|
||||||
typeof installmentAnticipations.$inferSelect;
|
typeof installmentAnticipations.$inferSelect;
|
||||||
export type UserUpdateLog = typeof userUpdateLog.$inferSelect;
|
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ name: opensheets
|
|||||||
|
|
||||||
# MODOS DE USO:
|
# MODOS DE USO:
|
||||||
# 1. Banco LOCAL (PostgreSQL em container):
|
# 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
|
# - Execute: docker compose up --build
|
||||||
#
|
#
|
||||||
# 2. Banco REMOTO (ex: Supabase):
|
# 2. Banco REMOTO (ex: Supabase):
|
||||||
# - Configure DB_PROVIDER=remote no .env
|
# - Configure DATABASE_URL com a URL do banco remoto no .env
|
||||||
# - Configure DATABASE_URL com a URL do banco remoto
|
|
||||||
# - Execute: docker compose up app --build (apenas o serviço app)
|
# - Execute: docker compose up app --build (apenas o serviço app)
|
||||||
#
|
#
|
||||||
# 3. Para parar todos os serviços:
|
# 3. Para parar todos os serviços:
|
||||||
@@ -81,9 +80,9 @@ services:
|
|||||||
# Variáveis de ambiente da aplicação
|
# Variáveis de ambiente da aplicação
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
|
||||||
# DATABASE_URL será definida dinamicamente baseada em DB_PROVIDER
|
# DATABASE_URL do .env
|
||||||
# Se DB_PROVIDER=local, usa o serviço 'db'
|
# Banco local: use host "db" (serviço Docker)
|
||||||
# Se DB_PROVIDER=remote, usa a DATABASE_URL do .env
|
# Banco remoto: use a URL completa do provider
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
|
||||||
# Outras variáveis de ambiente necessárias
|
# 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,
|
"when": 1765200545692,
|
||||||
"tag": "0002_slimy_flatman",
|
"tag": "0002_slimy_flatman",
|
||||||
"breakpoints": true
|
"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 { categorias, lancamentos, pagadores, contas } from "@/db/schema";
|
||||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
|
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
|
||||||
import type { CategoryType } from "@/lib/categorias/constants";
|
import type { CategoryType } from "@/lib/categorias/constants";
|
||||||
import { toNumber } from "@/lib/dashboard/common";
|
import { toNumber } from "@/lib/dashboard/common";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { mapLancamentosData } from "@/lib/lancamentos/page-helpers";
|
import { mapLancamentosData } from "@/lib/lancamentos/page-helpers";
|
||||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||||
import { getPreviousPeriod } from "@/lib/utils/period";
|
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>;
|
type MappedLancamentos = ReturnType<typeof mapLancamentosData>;
|
||||||
|
|
||||||
@@ -81,7 +81,17 @@ export async function fetchCategoryDetails(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const filteredRows = currentRows.filter(
|
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);
|
const transactions = mapLancamentosData(filteredRows);
|
||||||
@@ -97,6 +107,7 @@ export async function fetchCategoryDetails(
|
|||||||
})
|
})
|
||||||
.from(lancamentos)
|
.from(lancamentos)
|
||||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||||
|
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(lancamentos.userId, userId),
|
eq(lancamentos.userId, userId),
|
||||||
@@ -104,7 +115,13 @@ export async function fetchCategoryDetails(
|
|||||||
eq(lancamentos.transactionType, transactionType),
|
eq(lancamentos.transactionType, transactionType),
|
||||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||||
sanitizedNote,
|
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,
|
userId: string,
|
||||||
currentPeriod: string
|
currentPeriod: string
|
||||||
): Promise<CategoryHistoryData> {
|
): Promise<CategoryHistoryData> {
|
||||||
// Generate last 6 months including current
|
// Generate last 8 months, current month, and next month (10 total)
|
||||||
const periods: string[] = [];
|
const periods: string[] = [];
|
||||||
const monthLabels: string[] = [];
|
const monthLabels: string[] = [];
|
||||||
|
|
||||||
const [year, month] = currentPeriod.split("-").map(Number);
|
const [year, month] = currentPeriod.split("-").map(Number);
|
||||||
const currentDate = new Date(year, month - 1, 1);
|
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 date = addMonths(currentDate, -i);
|
||||||
const period = format(date, "yyyy-MM");
|
const period = format(date, "yyyy-MM");
|
||||||
const label = format(date, "MMM", { locale: ptBR }).toUpperCase();
|
const label = format(date, "MMM", { locale: ptBR }).toUpperCase();
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { categorias, lancamentos, orcamentos, pagadores } from "@/db/schema";
|
import { categorias, lancamentos, orcamentos, pagadores, contas } from "@/db/schema";
|
||||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
|
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||||
import { getPreviousPeriod } from "@/lib/utils/period";
|
import { getPreviousPeriod } from "@/lib/utils/period";
|
||||||
import { calculatePercentageChange } from "@/lib/utils/math";
|
import { calculatePercentageChange } from "@/lib/utils/math";
|
||||||
import { safeToNumber } from "@/lib/utils/number";
|
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 = {
|
export type CategoryIncomeItem = {
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
@@ -43,6 +43,7 @@ export async function fetchIncomeByCategory(
|
|||||||
.from(lancamentos)
|
.from(lancamentos)
|
||||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||||
|
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
orcamentos,
|
orcamentos,
|
||||||
and(
|
and(
|
||||||
@@ -61,6 +62,12 @@ export async function fetchIncomeByCategory(
|
|||||||
or(
|
or(
|
||||||
isNull(lancamentos.note),
|
isNull(lancamentos.note),
|
||||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
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)
|
.from(lancamentos)
|
||||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||||
|
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(lancamentos.userId, userId),
|
eq(lancamentos.userId, userId),
|
||||||
@@ -85,6 +93,12 @@ export async function fetchIncomeByCategory(
|
|||||||
or(
|
or(
|
||||||
isNull(lancamentos.note),
|
isNull(lancamentos.note),
|
||||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
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 { lancamentos, pagadores, contas } from "@/db/schema";
|
||||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
|
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { toNumber } from "@/lib/dashboard/common";
|
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 = {
|
export type MonthData = {
|
||||||
month: string;
|
month: string;
|
||||||
@@ -79,6 +79,7 @@ export async function fetchIncomeExpenseBalance(
|
|||||||
})
|
})
|
||||||
.from(lancamentos)
|
.from(lancamentos)
|
||||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||||
|
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(lancamentos.userId, userId),
|
eq(lancamentos.userId, userId),
|
||||||
@@ -87,7 +88,13 @@ export async function fetchIncomeExpenseBalance(
|
|||||||
eq(pagadores.role, "admin"),
|
eq(pagadores.role, "admin"),
|
||||||
sql`(${lancamentos.note} IS NULL OR ${
|
sql`(${lancamentos.note} IS NULL OR ${
|
||||||
lancamentos.note
|
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 { lancamentos, pagadores, contas } from "@/db/schema";
|
||||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
|
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||||
import {
|
import {
|
||||||
@@ -74,6 +74,7 @@ export async function fetchDashboardCardMetrics(
|
|||||||
})
|
})
|
||||||
.from(lancamentos)
|
.from(lancamentos)
|
||||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||||
|
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(lancamentos.userId, userId),
|
eq(lancamentos.userId, userId),
|
||||||
@@ -88,6 +89,12 @@ export async function fetchDashboardCardMetrics(
|
|||||||
`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`
|
`${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",
|
"Pix",
|
||||||
"Dinheiro",
|
"Dinheiro",
|
||||||
"Boleto",
|
"Boleto",
|
||||||
|
"Pré-Pago | VR/VA",
|
||||||
|
"Transferência bancária",
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export function buildLancamentoInitialState(
|
|||||||
isSettled:
|
isSettled:
|
||||||
paymentMethod === "Cartão de crédito"
|
paymentMethod === "Cartão de crédito"
|
||||||
? null
|
? null
|
||||||
: lancamento?.isSettled ?? false,
|
: lancamento?.isSettled ?? true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ export function applyFieldDependencies(
|
|||||||
updates.isSettled = null;
|
updates.isSettled = null;
|
||||||
} else {
|
} else {
|
||||||
updates.cartaoId = undefined;
|
updates.cartaoId = undefined;
|
||||||
updates.isSettled = currentState.isSettled ?? false;
|
updates.isSettled = currentState.isSettled ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear boleto-specific fields if not boleto
|
// 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" {
|
export function getTransactionBadgeVariant(type?: string | null): "default" | "destructive" | "secondary" {
|
||||||
if (!type) return "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 & {
|
type ContaSluggedOption = BaseSluggedOption & {
|
||||||
kind: "conta";
|
kind: "conta";
|
||||||
logo: string | null;
|
logo: string | null;
|
||||||
|
accountType: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CartaoSluggedOption = BaseSluggedOption & {
|
type CartaoSluggedOption = BaseSluggedOption & {
|
||||||
@@ -154,7 +155,8 @@ export const toOption = (
|
|||||||
slug?: string | null,
|
slug?: string | null,
|
||||||
avatarUrl?: string | null,
|
avatarUrl?: string | null,
|
||||||
logo?: string | null,
|
logo?: string | null,
|
||||||
icon?: string | null
|
icon?: string | null,
|
||||||
|
accountType?: string | null
|
||||||
): SelectOption => ({
|
): SelectOption => ({
|
||||||
value,
|
value,
|
||||||
label: normalizeLabel(label),
|
label: normalizeLabel(label),
|
||||||
@@ -164,6 +166,7 @@ export const toOption = (
|
|||||||
avatarUrl: avatarUrl ?? null,
|
avatarUrl: avatarUrl ?? null,
|
||||||
logo: logo ?? null,
|
logo: logo ?? null,
|
||||||
icon: icon ?? null,
|
icon: icon ?? null,
|
||||||
|
accountType: accountType ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const fetchLancamentoFilterSources = async (userId: string) => {
|
export const fetchLancamentoFilterSources = async (userId: string) => {
|
||||||
@@ -234,6 +237,7 @@ export const buildSluggedFilters = ({
|
|||||||
slug: contaCartaoSlugger(label),
|
slug: contaCartaoSlugger(label),
|
||||||
kind: "conta" as const,
|
kind: "conta" as const,
|
||||||
logo: conta.logo ?? null,
|
logo: conta.logo ?? null,
|
||||||
|
accountType: conta.accountType ?? null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -468,8 +472,8 @@ export const buildOptionSets = ({
|
|||||||
: contaFiltersRaw;
|
: contaFiltersRaw;
|
||||||
|
|
||||||
const contaOptions = sortByLabel(
|
const contaOptions = sortByLabel(
|
||||||
contaOptionsSource.map(({ id, label, slug, logo }) =>
|
contaOptionsSource.map(({ id, label, slug, logo, accountType }) =>
|
||||||
toOption(id, label, undefined, undefined, slug, undefined, logo)
|
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 />
|
<RemixIcons.RiBankCardLine className={ICON_CLASS} aria-hidden />
|
||||||
),
|
),
|
||||||
debito: <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;
|
return registry[key] ?? null;
|
||||||
|
|||||||
@@ -367,17 +367,24 @@ export type SelectOption = {
|
|||||||
/**
|
/**
|
||||||
* Creates month options for a select dropdown, centered around current month
|
* Creates month options for a select dropdown, centered around current month
|
||||||
* @param currentValue - Current period value to ensure it's included in options
|
* @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
|
* @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(
|
export function createMonthOptions(
|
||||||
currentValue?: string,
|
currentValue?: string,
|
||||||
offsetRange: number = 3
|
monthsBefore: number = 3,
|
||||||
|
monthsAfter?: number
|
||||||
): SelectOption[] {
|
): SelectOption[] {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const options: SelectOption[] = [];
|
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 date = new Date(now.getFullYear(), now.getMonth() + offset, 1);
|
||||||
const value = formatPeriod(date.getFullYear(), date.getMonth() + 1);
|
const value = formatPeriod(date.getFullYear(), date.getMonth() + 1);
|
||||||
options.push({ value, label: displayPeriod(value) });
|
options.push({ value, label: displayPeriod(value) });
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"dev-env": "tsx scripts/dev.ts",
|
"dev-env": "tsx scripts/dev.ts",
|
||||||
"prebuild": "tsx scripts/generate-changelog.ts",
|
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
@@ -32,6 +31,7 @@
|
|||||||
"@ai-sdk/google": "^3.0.1",
|
"@ai-sdk/google": "^3.0.1",
|
||||||
"@ai-sdk/openai": "^3.0.1",
|
"@ai-sdk/openai": "^3.0.1",
|
||||||
"@openrouter/ai-sdk-provider": "^1.5.4",
|
"@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-alert-dialog": "1.1.15",
|
||||||
"@radix-ui/react-avatar": "1.1.11",
|
"@radix-ui/react-avatar": "1.1.11",
|
||||||
"@radix-ui/react-checkbox": "1.3.3",
|
"@radix-ui/react-checkbox": "1.3.3",
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
"@radix-ui/react-toggle": "1.1.10",
|
"@radix-ui/react-toggle": "1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "1.1.11",
|
"@radix-ui/react-toggle-group": "1.1.11",
|
||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@radix-ui/react-tooltip": "1.2.8",
|
||||||
"@remixicon/react": "4.7.0",
|
"@remixicon/react": "4.8.0",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"@vercel/analytics": "^1.6.1",
|
"@vercel/analytics": "^1.6.1",
|
||||||
"@vercel/speed-insights": "^1.3.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':
|
'@openrouter/ai-sdk-provider':
|
||||||
specifier: ^1.5.4
|
specifier: ^1.5.4
|
||||||
version: 1.5.4(ai@6.0.3(zod@4.2.1))(zod@4.2.1)
|
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':
|
'@radix-ui/react-alert-dialog':
|
||||||
specifier: 1.1.15
|
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)
|
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
|
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)
|
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':
|
'@remixicon/react':
|
||||||
specifier: 4.7.0
|
specifier: 4.8.0
|
||||||
version: 4.7.0(react@19.2.3)
|
version: 4.8.0(react@19.2.3)
|
||||||
'@tanstack/react-table':
|
'@tanstack/react-table':
|
||||||
specifier: 8.21.3
|
specifier: 8.21.3
|
||||||
version: 8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.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':
|
'@radix-ui/primitive@1.1.3':
|
||||||
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
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':
|
'@radix-ui/react-alert-dialog@1.1.15':
|
||||||
resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==}
|
resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1690,8 +1706,8 @@ packages:
|
|||||||
react-redux:
|
react-redux:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@remixicon/react@4.7.0':
|
'@remixicon/react@4.8.0':
|
||||||
resolution: {integrity: sha512-ODBQjdbOjnFguCqctYkpDjERXOInNaBnRPDKfZOBvbzExBAwr2BaH/6AHFTg/UAFzBDkwtylfMT8iKPAkLwPLQ==}
|
resolution: {integrity: sha512-cbzR04GKWa3zWdgn0C2i+u/avb167iWeu9gqFO00UGu84meARPAm3oKowDZTU6dlk/WS3UHo6k//LMRM1l7CRw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>=18.2.0'
|
react: '>=18.2.0'
|
||||||
|
|
||||||
@@ -4773,6 +4789,23 @@ snapshots:
|
|||||||
|
|
||||||
'@radix-ui/primitive@1.1.3': {}
|
'@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)':
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@radix-ui/primitive': 1.1.3
|
||||||
@@ -5357,7 +5390,7 @@ snapshots:
|
|||||||
react: 19.2.3
|
react: 19.2.3
|
||||||
react-redux: 9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1)
|
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:
|
dependencies:
|
||||||
react: 19.2.3
|
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