refactor: migrate from ESLint to Biome and extract SQL queries to data.ts
- Replace ESLint with Biome for linting and formatting - Configure Biome with tabs, double quotes, and organized imports - Move all SQL/Drizzle queries from page.tsx files to data.ts files - Create new data.ts files for: ajustes, dashboard, relatorios/categorias - Update existing data.ts files: extrato, fatura (add lancamentos queries) - Remove all drizzle-orm imports from page.tsx files - Update README.md with new tooling info Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -13,5 +13,8 @@
|
||||
".next": true
|
||||
},
|
||||
"explorerExclude.backup": {},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,7 +266,7 @@ O projeto é open source, seus dados ficam no seu controle (pode rodar localment
|
||||
- Next.js 16.1 com App Router
|
||||
- Turbopack (fast refresh)
|
||||
- TypeScript 5.9 (strict mode)
|
||||
- ESLint 9
|
||||
- Biome (linting + formatting)
|
||||
- React 19.2 (com Compiler)
|
||||
- Server Actions
|
||||
- Parallel data fetching
|
||||
@@ -322,7 +322,7 @@ O projeto é open source, seus dados ficam no seu controle (pode rodar localment
|
||||
- **Containerization:** Docker + Docker Compose
|
||||
- **Package Manager:** pnpm
|
||||
- **Build Tool:** Turbopack
|
||||
- **Linting:** ESLint 9.39.2
|
||||
- **Linting & Formatting:** Biome 2.x
|
||||
- **Analytics:** Vercel Analytics + Speed Insights
|
||||
|
||||
---
|
||||
@@ -991,7 +991,7 @@ opensheets/
|
||||
├── tailwind.config.ts # Configuração Tailwind CSS
|
||||
├── postcss.config.mjs # PostCSS config
|
||||
├── components.json # shadcn/ui config
|
||||
├── eslint.config.mjs # ESLint config
|
||||
├── biome.json # Biome config (linting + formatting)
|
||||
├── tsconfig.json # TypeScript config
|
||||
├── package.json # Dependências e scripts
|
||||
├── .env.example # Template de variáveis de ambiente
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use server";
|
||||
|
||||
import { apiTokens, pagadores } from "@/db/schema";
|
||||
import { auth } from "@/lib/auth/config";
|
||||
import { db, schema } from "@/lib/db";
|
||||
import { apiTokens, pagadores } from "@/db/schema";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { eq, and, ne, isNull } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { and, eq, isNull, ne } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { headers } from "next/headers";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { createHash, randomBytes } from "crypto";
|
||||
|
||||
type ActionResponse<T = void> = {
|
||||
success: boolean;
|
||||
@@ -58,7 +58,7 @@ const updatePreferencesSchema = z.object({
|
||||
// Actions
|
||||
|
||||
export async function updateNameAction(
|
||||
data: z.infer<typeof updateNameSchema>
|
||||
data: z.infer<typeof updateNameSchema>,
|
||||
): Promise<ActionResponse> {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
@@ -88,8 +88,8 @@ export async function updateNameAction(
|
||||
.where(
|
||||
and(
|
||||
eq(pagadores.userId, session.user.id),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
|
||||
)
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
),
|
||||
);
|
||||
|
||||
// Revalidar o layout do dashboard para atualizar a sidebar
|
||||
@@ -117,7 +117,7 @@ export async function updateNameAction(
|
||||
}
|
||||
|
||||
export async function updatePasswordAction(
|
||||
data: z.infer<typeof updatePasswordSchema>
|
||||
data: z.infer<typeof updatePasswordSchema>,
|
||||
): Promise<ActionResponse> {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
@@ -137,14 +137,15 @@ export async function updatePasswordAction(
|
||||
const userAccount = await db.query.account.findFirst({
|
||||
where: and(
|
||||
eq(schema.account.userId, session.user.id),
|
||||
eq(schema.account.providerId, "google")
|
||||
eq(schema.account.providerId, "google"),
|
||||
),
|
||||
});
|
||||
|
||||
if (userAccount) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Não é possível alterar senha para contas autenticadas via Google",
|
||||
error:
|
||||
"Não é possível alterar senha para contas autenticadas via Google",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -166,7 +167,10 @@ export async function updatePasswordAction(
|
||||
console.error("Erro na API do Better Auth:", authError);
|
||||
|
||||
// Verificar se o erro é de senha incorreta
|
||||
if (authError?.message?.includes("password") || authError?.message?.includes("incorrect")) {
|
||||
if (
|
||||
authError?.message?.includes("password") ||
|
||||
authError?.message?.includes("incorrect")
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Senha atual incorreta",
|
||||
@@ -175,7 +179,8 @@ export async function updatePasswordAction(
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "Erro ao atualizar senha. Verifique se a senha atual está correta.",
|
||||
error:
|
||||
"Erro ao atualizar senha. Verifique se a senha atual está correta.",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -195,7 +200,7 @@ export async function updatePasswordAction(
|
||||
}
|
||||
|
||||
export async function updateEmailAction(
|
||||
data: z.infer<typeof updateEmailSchema>
|
||||
data: z.infer<typeof updateEmailSchema>,
|
||||
): Promise<ActionResponse> {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
@@ -215,7 +220,7 @@ export async function updateEmailAction(
|
||||
const userAccount = await db.query.account.findFirst({
|
||||
where: and(
|
||||
eq(schema.account.userId, session.user.id),
|
||||
eq(schema.account.providerId, "google")
|
||||
eq(schema.account.providerId, "google"),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -254,7 +259,7 @@ export async function updateEmailAction(
|
||||
const existingUser = await db.query.user.findFirst({
|
||||
where: and(
|
||||
eq(schema.user.email, validated.newEmail),
|
||||
ne(schema.user.id, session.user.id)
|
||||
ne(schema.user.id, session.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -307,7 +312,7 @@ export async function updateEmailAction(
|
||||
}
|
||||
|
||||
export async function deleteAccountAction(
|
||||
data: z.infer<typeof deleteAccountSchema>
|
||||
data: z.infer<typeof deleteAccountSchema>,
|
||||
): Promise<ActionResponse> {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
@@ -349,7 +354,7 @@ export async function deleteAccountAction(
|
||||
}
|
||||
|
||||
export async function updatePreferencesAction(
|
||||
data: z.infer<typeof updatePreferencesSchema>
|
||||
data: z.infer<typeof updatePreferencesSchema>,
|
||||
): Promise<ActionResponse> {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
@@ -435,7 +440,7 @@ function hashToken(token: string): string {
|
||||
}
|
||||
|
||||
export async function createApiTokenAction(
|
||||
data: z.infer<typeof createApiTokenSchema>
|
||||
data: z.infer<typeof createApiTokenSchema>,
|
||||
): Promise<ActionResponse<{ token: string; tokenId: string }>> {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
@@ -495,7 +500,7 @@ export async function createApiTokenAction(
|
||||
}
|
||||
|
||||
export async function revokeApiTokenAction(
|
||||
data: z.infer<typeof revokeApiTokenSchema>
|
||||
data: z.infer<typeof revokeApiTokenSchema>,
|
||||
): Promise<ActionResponse> {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
@@ -519,8 +524,8 @@ export async function revokeApiTokenAction(
|
||||
and(
|
||||
eq(apiTokens.id, validated.tokenId),
|
||||
eq(apiTokens.userId, session.user.id),
|
||||
isNull(apiTokens.revokedAt)
|
||||
)
|
||||
isNull(apiTokens.revokedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
|
||||
70
app/(dashboard)/ajustes/data.ts
Normal file
70
app/(dashboard)/ajustes/data.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
import { db, schema } from "@/lib/db";
|
||||
|
||||
export interface UserPreferences {
|
||||
disableMagnetlines: boolean;
|
||||
}
|
||||
|
||||
export interface ApiToken {
|
||||
id: string;
|
||||
name: string;
|
||||
tokenPrefix: string;
|
||||
lastUsedAt: Date | null;
|
||||
lastUsedIp: string | null;
|
||||
createdAt: Date;
|
||||
expiresAt: Date | null;
|
||||
revokedAt: Date | null;
|
||||
}
|
||||
|
||||
export async function fetchAuthProvider(userId: string): Promise<string> {
|
||||
const userAccount = await db.query.account.findFirst({
|
||||
where: eq(schema.account.userId, userId),
|
||||
});
|
||||
return userAccount?.providerId || "credential";
|
||||
}
|
||||
|
||||
export async function fetchUserPreferences(
|
||||
userId: string,
|
||||
): Promise<UserPreferences | null> {
|
||||
const result = await db
|
||||
.select({
|
||||
disableMagnetlines: schema.userPreferences.disableMagnetlines,
|
||||
})
|
||||
.from(schema.userPreferences)
|
||||
.where(eq(schema.userPreferences.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
export async function fetchApiTokens(userId: string): Promise<ApiToken[]> {
|
||||
return db
|
||||
.select({
|
||||
id: apiTokens.id,
|
||||
name: apiTokens.name,
|
||||
tokenPrefix: apiTokens.tokenPrefix,
|
||||
lastUsedAt: apiTokens.lastUsedAt,
|
||||
lastUsedIp: apiTokens.lastUsedIp,
|
||||
createdAt: apiTokens.createdAt,
|
||||
expiresAt: apiTokens.expiresAt,
|
||||
revokedAt: apiTokens.revokedAt,
|
||||
})
|
||||
.from(apiTokens)
|
||||
.where(eq(apiTokens.userId, userId))
|
||||
.orderBy(desc(apiTokens.createdAt));
|
||||
}
|
||||
|
||||
export async function fetchAjustesPageData(userId: string) {
|
||||
const [authProvider, userPreferences, userApiTokens] = await Promise.all([
|
||||
fetchAuthProvider(userId),
|
||||
fetchUserPreferences(userId),
|
||||
fetchApiTokens(userId),
|
||||
]);
|
||||
|
||||
return {
|
||||
authProvider,
|
||||
userPreferences,
|
||||
userApiTokens,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiSettingsLine } from "@remixicon/react";
|
||||
import PageDescription from "@/components/page-description";
|
||||
|
||||
export const metadata = {
|
||||
title: "Ajustes | Opensheets",
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ApiTokensForm } from "@/components/ajustes/api-tokens-form";
|
||||
import { DeleteAccountForm } from "@/components/ajustes/delete-account-form";
|
||||
import { PreferencesForm } from "@/components/ajustes/preferences-form";
|
||||
import { UpdateEmailForm } from "@/components/ajustes/update-email-form";
|
||||
import { UpdateNameForm } from "@/components/ajustes/update-name-form";
|
||||
import { UpdatePasswordForm } from "@/components/ajustes/update-password-form";
|
||||
import { PreferencesForm } from "@/components/ajustes/preferences-form";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { auth } from "@/lib/auth/config";
|
||||
import { db, schema } from "@/lib/db";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { fetchAjustesPageData } from "./data";
|
||||
|
||||
export default async function Page() {
|
||||
const session = await auth.api.getSession({
|
||||
@@ -25,40 +25,8 @@ export default async function Page() {
|
||||
const userName = session.user.name || "";
|
||||
const userEmail = session.user.email || "";
|
||||
|
||||
// Detectar método de autenticação (Google OAuth vs E-mail/Senha)
|
||||
const userAccount = await db.query.account.findFirst({
|
||||
where: eq(schema.account.userId, session.user.id),
|
||||
});
|
||||
|
||||
// Buscar preferências do usuário
|
||||
const userPreferencesResult = await db
|
||||
.select({
|
||||
disableMagnetlines: schema.userPreferences.disableMagnetlines,
|
||||
})
|
||||
.from(schema.userPreferences)
|
||||
.where(eq(schema.userPreferences.userId, session.user.id))
|
||||
.limit(1);
|
||||
|
||||
const userPreferences = userPreferencesResult[0] || null;
|
||||
|
||||
// Se o providerId for "google", o usuário usa Google OAuth
|
||||
const authProvider = userAccount?.providerId || "credential";
|
||||
|
||||
// Buscar tokens de API do usuário
|
||||
const userApiTokens = await db
|
||||
.select({
|
||||
id: apiTokens.id,
|
||||
name: apiTokens.name,
|
||||
tokenPrefix: apiTokens.tokenPrefix,
|
||||
lastUsedAt: apiTokens.lastUsedAt,
|
||||
lastUsedIp: apiTokens.lastUsedIp,
|
||||
createdAt: apiTokens.createdAt,
|
||||
expiresAt: apiTokens.expiresAt,
|
||||
revokedAt: apiTokens.revokedAt,
|
||||
})
|
||||
.from(apiTokens)
|
||||
.where(eq(apiTokens.userId, session.user.id))
|
||||
.orderBy(desc(apiTokens.createdAt));
|
||||
const { authProvider, userPreferences, userApiTokens } =
|
||||
await fetchAjustesPageData(session.user.id);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { anotacoes } from "@/db/schema";
|
||||
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
|
||||
import type { ActionResult } from "@/lib/actions/types";
|
||||
import { uuidSchema } from "@/lib/schemas/common";
|
||||
import { db } from "@/lib/db";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { uuidSchema } from "@/lib/schemas/common";
|
||||
|
||||
const taskSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -15,7 +15,8 @@ const taskSchema = z.object({
|
||||
completed: z.boolean(),
|
||||
});
|
||||
|
||||
const noteBaseSchema = z.object({
|
||||
const noteBaseSchema = z
|
||||
.object({
|
||||
title: z
|
||||
.string({ message: "Informe o título da anotação." })
|
||||
.trim()
|
||||
@@ -31,7 +32,8 @@ const noteBaseSchema = z.object({
|
||||
message: "O tipo deve ser 'nota' ou 'tarefa'.",
|
||||
}),
|
||||
tasks: z.array(taskSchema).optional().default([]),
|
||||
}).refine(
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// Se for nota, a descrição é obrigatória
|
||||
if (data.type === "nota") {
|
||||
@@ -44,14 +46,17 @@ const noteBaseSchema = z.object({
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Notas precisam de descrição e Tarefas precisam de ao menos uma tarefa.",
|
||||
}
|
||||
message:
|
||||
"Notas precisam de descrição e Tarefas precisam de ao menos uma tarefa.",
|
||||
},
|
||||
);
|
||||
|
||||
const createNoteSchema = noteBaseSchema;
|
||||
const updateNoteSchema = noteBaseSchema.and(z.object({
|
||||
const updateNoteSchema = noteBaseSchema.and(
|
||||
z.object({
|
||||
id: uuidSchema("Anotação"),
|
||||
}));
|
||||
}),
|
||||
);
|
||||
const deleteNoteSchema = z.object({
|
||||
id: uuidSchema("Anotação"),
|
||||
});
|
||||
@@ -61,7 +66,7 @@ type NoteUpdateInput = z.infer<typeof updateNoteSchema>;
|
||||
type NoteDeleteInput = z.infer<typeof deleteNoteSchema>;
|
||||
|
||||
export async function createNoteAction(
|
||||
input: NoteCreateInput
|
||||
input: NoteCreateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -71,7 +76,8 @@ export async function createNoteAction(
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
type: data.type,
|
||||
tasks: data.tasks && data.tasks.length > 0 ? JSON.stringify(data.tasks) : null,
|
||||
tasks:
|
||||
data.tasks && data.tasks.length > 0 ? JSON.stringify(data.tasks) : null,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
@@ -84,7 +90,7 @@ export async function createNoteAction(
|
||||
}
|
||||
|
||||
export async function updateNoteAction(
|
||||
input: NoteUpdateInput
|
||||
input: NoteUpdateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -96,7 +102,10 @@ export async function updateNoteAction(
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
type: data.type,
|
||||
tasks: data.tasks && data.tasks.length > 0 ? JSON.stringify(data.tasks) : null,
|
||||
tasks:
|
||||
data.tasks && data.tasks.length > 0
|
||||
? JSON.stringify(data.tasks)
|
||||
: null,
|
||||
})
|
||||
.where(and(eq(anotacoes.id, data.id), eq(anotacoes.userId, user.id)))
|
||||
.returning({ id: anotacoes.id });
|
||||
@@ -117,7 +126,7 @@ export async function updateNoteAction(
|
||||
}
|
||||
|
||||
export async function deleteNoteAction(
|
||||
input: NoteDeleteInput
|
||||
input: NoteDeleteInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -151,7 +160,7 @@ const arquivarNoteSchema = z.object({
|
||||
type NoteArquivarInput = z.infer<typeof arquivarNoteSchema>;
|
||||
|
||||
export async function arquivarAnotacaoAction(
|
||||
input: NoteArquivarInput
|
||||
input: NoteArquivarInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -178,7 +187,7 @@ export async function arquivarAnotacaoAction(
|
||||
success: true,
|
||||
message: data.arquivada
|
||||
? "Anotação arquivada com sucesso."
|
||||
: "Anotação desarquivada com sucesso."
|
||||
: "Anotação desarquivada com sucesso.",
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { anotacoes, type Anotacao } from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { type Anotacao, anotacoes } from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export type Task = {
|
||||
id: string;
|
||||
@@ -21,7 +21,10 @@ export type NoteData = {
|
||||
export async function fetchNotesForUser(userId: string): Promise<NoteData[]> {
|
||||
const noteRows = await db.query.anotacoes.findMany({
|
||||
where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, false)),
|
||||
orderBy: (note: typeof anotacoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(note.createdAt)],
|
||||
orderBy: (
|
||||
note: typeof anotacoes.$inferSelect,
|
||||
{ desc }: { desc: (field: unknown) => unknown },
|
||||
) => [desc(note.createdAt)],
|
||||
});
|
||||
|
||||
return noteRows.map((note: Anotacao) => {
|
||||
@@ -49,10 +52,15 @@ export async function fetchNotesForUser(userId: string): Promise<NoteData[]> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchArquivadasForUser(userId: string): Promise<NoteData[]> {
|
||||
export async function fetchArquivadasForUser(
|
||||
userId: string,
|
||||
): Promise<NoteData[]> {
|
||||
const noteRows = await db.query.anotacoes.findMany({
|
||||
where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, true)),
|
||||
orderBy: (note: typeof anotacoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(note.createdAt)],
|
||||
orderBy: (
|
||||
note: typeof anotacoes.$inferSelect,
|
||||
{ desc }: { desc: (field: unknown) => unknown },
|
||||
) => [desc(note.createdAt)],
|
||||
});
|
||||
|
||||
return noteRows.map((note: Anotacao) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiTodoLine } from "@remixicon/react";
|
||||
import PageDescription from "@/components/page-description";
|
||||
|
||||
export const metadata = {
|
||||
title: "Anotações | Opensheets",
|
||||
|
||||
@@ -17,10 +17,7 @@ export default function AnotacoesLoading() {
|
||||
{/* Grid de cards de notas */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-2xl border p-4 space-y-3"
|
||||
>
|
||||
<div key={i} className="rounded-2xl border p-4 space-y-3">
|
||||
{/* Título */}
|
||||
<Skeleton className="h-6 w-3/4 rounded-2xl bg-foreground/10" />
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { and, eq, gte, lte, ne, or } from "drizzle-orm";
|
||||
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
||||
import type {
|
||||
CalendarData,
|
||||
CalendarEvent,
|
||||
} from "@/components/calendario/types";
|
||||
import { cartoes, lancamentos } from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
@@ -8,12 +13,6 @@ import {
|
||||
mapLancamentosData,
|
||||
} from "@/lib/lancamentos/page-helpers";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { and, eq, gte, lte, ne, or } from "drizzle-orm";
|
||||
|
||||
import type {
|
||||
CalendarData,
|
||||
CalendarEvent,
|
||||
} from "@/components/calendario/types";
|
||||
|
||||
const PAYMENT_METHOD_BOLETO = "Boleto";
|
||||
const TRANSACTION_TYPE_TRANSFERENCIA = "Transferência";
|
||||
@@ -59,8 +58,7 @@ export const fetchCalendarData = async ({
|
||||
const rangeStartKey = toDateKey(rangeStart);
|
||||
const rangeEndKey = toDateKey(rangeEnd);
|
||||
|
||||
const [lancamentoRows, cardRows, filterSources] =
|
||||
await Promise.all([
|
||||
const [lancamentoRows, cardRows, filterSources] = await Promise.all([
|
||||
db.query.lancamentos.findMany({
|
||||
where: and(
|
||||
eq(lancamentos.userId, userId),
|
||||
@@ -69,20 +67,20 @@ export const fetchCalendarData = async ({
|
||||
// Lançamentos cuja data de compra esteja no período do calendário
|
||||
and(
|
||||
gte(lancamentos.purchaseDate, rangeStart),
|
||||
lte(lancamentos.purchaseDate, rangeEnd)
|
||||
lte(lancamentos.purchaseDate, rangeEnd),
|
||||
),
|
||||
// Boletos cuja data de vencimento esteja no período do calendário
|
||||
and(
|
||||
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||
gte(lancamentos.dueDate, rangeStart),
|
||||
lte(lancamentos.dueDate, rangeEnd)
|
||||
lte(lancamentos.dueDate, rangeEnd),
|
||||
),
|
||||
// Lançamentos de cartão do período (para calcular totais de vencimento)
|
||||
and(
|
||||
eq(lancamentos.period, period),
|
||||
ne(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO)
|
||||
)
|
||||
)
|
||||
ne(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||
),
|
||||
),
|
||||
),
|
||||
with: {
|
||||
pagador: true,
|
||||
@@ -112,7 +110,7 @@ export const fetchCalendarData = async ({
|
||||
const amount = Math.abs(item.amount ?? 0);
|
||||
cardTotals.set(
|
||||
item.cartaoId,
|
||||
(cardTotals.get(item.cartaoId) ?? 0) + amount
|
||||
(cardTotals.get(item.cartaoId) ?? 0) + amount,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,7 +162,7 @@ export const fetchCalendarData = async ({
|
||||
|
||||
const normalizedDay = clampDayInMonth(year, monthIndex, dueDayNumber);
|
||||
const dueDateKey = toDateKey(
|
||||
new Date(Date.UTC(year, monthIndex, normalizedDay))
|
||||
new Date(Date.UTC(year, monthIndex, normalizedDay)),
|
||||
);
|
||||
|
||||
events.push({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiCalendarEventLine } from "@remixicon/react";
|
||||
import PageDescription from "@/components/page-description";
|
||||
|
||||
export const metadata = {
|
||||
title: "Calendário | Opensheets",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { MonthlyCalendar } from "@/components/calendario/monthly-calendar";
|
||||
import type { CalendarPeriod } from "@/components/calendario/types";
|
||||
import MonthNavigation from "@/components/month-picker/month-navigation";
|
||||
import { getUserId } from "@/lib/auth/server";
|
||||
import {
|
||||
@@ -5,10 +7,7 @@ import {
|
||||
type ResolvedSearchParams,
|
||||
} from "@/lib/lancamentos/page-helpers";
|
||||
import { parsePeriodParam } from "@/lib/utils/period";
|
||||
|
||||
import { MonthlyCalendar } from "@/components/calendario/monthly-calendar";
|
||||
import { fetchCalendarData } from "./data";
|
||||
import type { CalendarPeriod } from "@/components/calendario/types";
|
||||
|
||||
type PageSearchParams = Promise<ResolvedSearchParams>;
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
cartoes,
|
||||
categorias,
|
||||
@@ -8,29 +11,24 @@ import {
|
||||
pagadores,
|
||||
} from "@/db/schema";
|
||||
import { buildInvoicePaymentNote } from "@/lib/accounts/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
INVOICE_PAYMENT_STATUS,
|
||||
INVOICE_STATUS_VALUES,
|
||||
PERIOD_FORMAT_REGEX,
|
||||
type InvoicePaymentStatus,
|
||||
PERIOD_FORMAT_REGEX,
|
||||
} from "@/lib/faturas";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { parseLocalDateString } from "@/lib/utils/date";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
const updateInvoicePaymentStatusSchema = z.object({
|
||||
cartaoId: z
|
||||
.string({ message: "Cartão inválido." })
|
||||
.uuid("Cartão inválido."),
|
||||
cartaoId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."),
|
||||
period: z
|
||||
.string({ message: "Período inválido." })
|
||||
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
|
||||
status: z.enum(
|
||||
INVOICE_STATUS_VALUES as [InvoicePaymentStatus, ...InvoicePaymentStatus[]]
|
||||
INVOICE_STATUS_VALUES as [InvoicePaymentStatus, ...InvoicePaymentStatus[]],
|
||||
),
|
||||
paymentDate: z.string().optional(),
|
||||
});
|
||||
@@ -52,7 +50,7 @@ const formatDecimal = (value: number) =>
|
||||
(Math.round(value * 100) / 100).toFixed(2);
|
||||
|
||||
export async function updateInvoicePaymentStatusAction(
|
||||
input: UpdateInvoicePaymentStatusInput
|
||||
input: UpdateInvoicePaymentStatusInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -75,7 +73,7 @@ export async function updateInvoicePaymentStatusAction(
|
||||
where: and(
|
||||
eq(faturas.cartaoId, data.cartaoId),
|
||||
eq(faturas.userId, user.id),
|
||||
eq(faturas.period, data.period)
|
||||
eq(faturas.period, data.period),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -104,8 +102,8 @@ export async function updateInvoicePaymentStatusAction(
|
||||
and(
|
||||
eq(lancamentos.userId, user.id),
|
||||
eq(lancamentos.cartaoId, card.id),
|
||||
eq(lancamentos.period, data.period)
|
||||
)
|
||||
eq(lancamentos.period, data.period),
|
||||
),
|
||||
);
|
||||
|
||||
const invoiceNote = buildInvoicePaymentNote(card.id, data.period);
|
||||
@@ -132,8 +130,8 @@ export async function updateInvoicePaymentStatusAction(
|
||||
eq(lancamentos.userId, user.id),
|
||||
eq(lancamentos.cartaoId, card.id),
|
||||
eq(lancamentos.period, data.period),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
|
||||
)
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
),
|
||||
);
|
||||
|
||||
const adminShare = Math.abs(Number(adminShareRow?.total ?? 0));
|
||||
@@ -143,7 +141,7 @@ export async function updateInvoicePaymentStatusAction(
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(pagadores.userId, user.id),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -151,7 +149,7 @@ export async function updateInvoicePaymentStatusAction(
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(categorias.userId, user.id),
|
||||
eq(categorias.name, "Pagamentos")
|
||||
eq(categorias.name, "Pagamentos"),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -182,7 +180,7 @@ export async function updateInvoicePaymentStatusAction(
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(lancamentos.userId, user.id),
|
||||
eq(lancamentos.note, invoiceNote)
|
||||
eq(lancamentos.note, invoiceNote),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -202,8 +200,8 @@ export async function updateInvoicePaymentStatusAction(
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, user.id),
|
||||
eq(lancamentos.note, invoiceNote)
|
||||
)
|
||||
eq(lancamentos.note, invoiceNote),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -229,9 +227,7 @@ export async function updateInvoicePaymentStatusAction(
|
||||
}
|
||||
|
||||
const updatePaymentDateSchema = z.object({
|
||||
cartaoId: z
|
||||
.string({ message: "Cartão inválido." })
|
||||
.uuid("Cartão inválido."),
|
||||
cartaoId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."),
|
||||
period: z
|
||||
.string({ message: "Período inválido." })
|
||||
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
|
||||
@@ -241,7 +237,7 @@ const updatePaymentDateSchema = z.object({
|
||||
type UpdatePaymentDateInput = z.infer<typeof updatePaymentDateSchema>;
|
||||
|
||||
export async function updatePaymentDateAction(
|
||||
input: UpdatePaymentDateInput
|
||||
input: UpdatePaymentDateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -263,7 +259,7 @@ export async function updatePaymentDateAction(
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(lancamentos.userId, user.id),
|
||||
eq(lancamentos.note, invoiceNote)
|
||||
eq(lancamentos.note, invoiceNote),
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { and, desc, eq, type SQL, sum } from "drizzle-orm";
|
||||
import { cartoes, faturas, lancamentos } from "@/db/schema";
|
||||
import { buildInvoicePaymentNote } from "@/lib/accounts/constants";
|
||||
import { db } from "@/lib/db";
|
||||
@@ -5,7 +6,6 @@ import {
|
||||
INVOICE_PAYMENT_STATUS,
|
||||
type InvoicePaymentStatus,
|
||||
} from "@/lib/faturas";
|
||||
import { and, eq, sum } from "drizzle-orm";
|
||||
|
||||
const toNumber = (value: string | number | null | undefined) => {
|
||||
if (typeof value === "number") {
|
||||
@@ -41,7 +41,7 @@ export async function fetchCardData(userId: string, cartaoId: string) {
|
||||
export async function fetchInvoiceData(
|
||||
userId: string,
|
||||
cartaoId: string,
|
||||
selectedPeriod: string
|
||||
selectedPeriod: string,
|
||||
): Promise<{
|
||||
totalAmount: number;
|
||||
invoiceStatus: InvoicePaymentStatus;
|
||||
@@ -57,7 +57,7 @@ export async function fetchInvoiceData(
|
||||
where: and(
|
||||
eq(faturas.cartaoId, cartaoId),
|
||||
eq(faturas.userId, userId),
|
||||
eq(faturas.period, selectedPeriod)
|
||||
eq(faturas.period, selectedPeriod),
|
||||
),
|
||||
}),
|
||||
db
|
||||
@@ -67,14 +67,14 @@ export async function fetchInvoiceData(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.cartaoId, cartaoId),
|
||||
eq(lancamentos.period, selectedPeriod)
|
||||
)
|
||||
eq(lancamentos.period, selectedPeriod),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const totalAmount = toNumber(totalRow[0]?.totalAmount);
|
||||
const isInvoiceStatus = (
|
||||
value: string | null | undefined
|
||||
value: string | null | undefined,
|
||||
): value is InvoicePaymentStatus =>
|
||||
!!value && ["pendente", "pago"].includes(value);
|
||||
|
||||
@@ -92,7 +92,7 @@ export async function fetchInvoiceData(
|
||||
},
|
||||
where: and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.note, invoiceNote)
|
||||
eq(lancamentos.note, invoiceNote),
|
||||
),
|
||||
});
|
||||
paymentDate = paymentLancamento?.purchaseDate
|
||||
@@ -102,3 +102,16 @@ export async function fetchInvoiceData(
|
||||
|
||||
return { totalAmount, invoiceStatus, paymentDate };
|
||||
}
|
||||
|
||||
export async function fetchCardLancamentos(filters: SQL[]) {
|
||||
return db.query.lancamentos.findMany({
|
||||
where: and(...filters),
|
||||
with: {
|
||||
pagador: true,
|
||||
conta: true,
|
||||
cartao: true,
|
||||
categoria: true,
|
||||
},
|
||||
orderBy: desc(lancamentos.purchaseDate),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { RiPencilLine } from "@remixicon/react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
||||
import { CardDialog } from "@/components/cartoes/card-dialog";
|
||||
import type { Card } from "@/components/cartoes/types";
|
||||
@@ -5,8 +7,7 @@ import { InvoiceSummaryCard } from "@/components/faturas/invoice-summary-card";
|
||||
import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
|
||||
import MonthNavigation from "@/components/month-picker/month-navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { lancamentos, type Conta } from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
import type { Conta } from "@/db/schema";
|
||||
import { getUserId } from "@/lib/auth/server";
|
||||
import {
|
||||
buildLancamentoWhere,
|
||||
@@ -21,10 +22,7 @@ import {
|
||||
} from "@/lib/lancamentos/page-helpers";
|
||||
import { loadLogoOptions } from "@/lib/logo/options";
|
||||
import { parsePeriodParam } from "@/lib/utils/period";
|
||||
import { RiPencilLine } from "@remixicon/react";
|
||||
import { and, desc } from "drizzle-orm";
|
||||
import { notFound } from "next/navigation";
|
||||
import { fetchCardData, fetchInvoiceData } from "./data";
|
||||
import { fetchCardData, fetchCardLancamentos, fetchInvoiceData } from "./data";
|
||||
|
||||
type PageSearchParams = Promise<ResolvedSearchParams>;
|
||||
|
||||
@@ -53,12 +51,8 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const [
|
||||
filterSources,
|
||||
logoOptions,
|
||||
invoiceData,
|
||||
estabelecimentos,
|
||||
] = await Promise.all([
|
||||
const [filterSources, logoOptions, invoiceData, estabelecimentos] =
|
||||
await Promise.all([
|
||||
fetchLancamentoFilterSources(userId),
|
||||
loadLogoOptions(),
|
||||
fetchInvoiceData(userId, cartaoId, selectedPeriod),
|
||||
@@ -75,16 +69,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
cardId: card.id,
|
||||
});
|
||||
|
||||
const lancamentoRows = await db.query.lancamentos.findMany({
|
||||
where: and(...filters),
|
||||
with: {
|
||||
pagador: true,
|
||||
conta: true,
|
||||
cartao: true,
|
||||
categoria: true,
|
||||
},
|
||||
orderBy: desc(lancamentos.purchaseDate),
|
||||
});
|
||||
const lancamentoRows = await fetchCardLancamentos(filters);
|
||||
|
||||
const lancamentosData = mapLancamentosData(lancamentoRows);
|
||||
|
||||
@@ -137,7 +122,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
card.limit !== null && card.limit !== undefined ? Number(card.limit) : null;
|
||||
|
||||
const periodLabel = `${monthName.charAt(0).toUpperCase()}${monthName.slice(
|
||||
1
|
||||
1,
|
||||
)} de ${year}`;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { cartoes, contas } from "@/db/schema";
|
||||
import { type ActionResult, handleActionError } from "@/lib/actions/helpers";
|
||||
import { revalidateForEntity } from "@/lib/actions/helpers";
|
||||
import {
|
||||
type ActionResult,
|
||||
handleActionError,
|
||||
revalidateForEntity,
|
||||
} from "@/lib/actions/helpers";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
dayOfMonthSchema,
|
||||
noteSchema,
|
||||
@@ -11,10 +18,6 @@ import {
|
||||
} from "@/lib/schemas/common";
|
||||
import { formatDecimalForDb } from "@/lib/utils/currency";
|
||||
import { normalizeFilePath } from "@/lib/utils/string";
|
||||
import { db } from "@/lib/db";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
const cardBaseSchema = z.object({
|
||||
name: z
|
||||
@@ -64,7 +67,7 @@ async function assertAccountOwnership(userId: string, contaId: string) {
|
||||
}
|
||||
|
||||
export async function createCardAction(
|
||||
input: CardCreateInput
|
||||
input: CardCreateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -96,7 +99,7 @@ export async function createCardAction(
|
||||
}
|
||||
|
||||
export async function updateCardAction(
|
||||
input: CardUpdateInput
|
||||
input: CardUpdateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -138,7 +141,7 @@ export async function updateCardAction(
|
||||
}
|
||||
|
||||
export async function deleteCardAction(
|
||||
input: CardDeleteInput
|
||||
input: CardDeleteInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
|
||||
@@ -32,8 +32,14 @@ export async function fetchCardsForUser(userId: string): Promise<{
|
||||
}> {
|
||||
const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([
|
||||
db.query.cartoes.findMany({
|
||||
orderBy: (card: typeof cartoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(card.name)],
|
||||
where: and(eq(cartoes.userId, userId), not(ilike(cartoes.status, "inativo"))),
|
||||
orderBy: (
|
||||
card: typeof cartoes.$inferSelect,
|
||||
{ desc }: { desc: (field: unknown) => unknown },
|
||||
) => [desc(card.name)],
|
||||
where: and(
|
||||
eq(cartoes.userId, userId),
|
||||
not(ilike(cartoes.status, "inativo")),
|
||||
),
|
||||
with: {
|
||||
conta: {
|
||||
columns: {
|
||||
@@ -44,7 +50,10 @@ export async function fetchCardsForUser(userId: string): Promise<{
|
||||
},
|
||||
}),
|
||||
db.query.contas.findMany({
|
||||
orderBy: (account: typeof contas.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(account.name)],
|
||||
orderBy: (
|
||||
account: typeof contas.$inferSelect,
|
||||
{ desc }: { desc: (field: unknown) => unknown },
|
||||
) => [desc(account.name)],
|
||||
where: eq(contas.userId, userId),
|
||||
columns: {
|
||||
id: true,
|
||||
@@ -62,17 +71,19 @@ export async function fetchCardsForUser(userId: string): Promise<{
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false))
|
||||
)
|
||||
or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false)),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.cartaoId),
|
||||
]);
|
||||
|
||||
const usageMap = new Map<string, number>();
|
||||
usageRows.forEach((row: { cartaoId: string | null; total: number | null }) => {
|
||||
usageRows.forEach(
|
||||
(row: { cartaoId: string | null; total: number | null }) => {
|
||||
if (!row.cartaoId) return;
|
||||
usageMap.set(row.cartaoId, Number(row.total ?? 0));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const cards = cardRows.map((card) => ({
|
||||
id: card.id,
|
||||
@@ -116,7 +127,10 @@ export async function fetchInativosForUser(userId: string): Promise<{
|
||||
}> {
|
||||
const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([
|
||||
db.query.cartoes.findMany({
|
||||
orderBy: (card: typeof cartoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(card.name)],
|
||||
orderBy: (
|
||||
card: typeof cartoes.$inferSelect,
|
||||
{ desc }: { desc: (field: unknown) => unknown },
|
||||
) => [desc(card.name)],
|
||||
where: and(eq(cartoes.userId, userId), ilike(cartoes.status, "inativo")),
|
||||
with: {
|
||||
conta: {
|
||||
@@ -128,7 +142,10 @@ export async function fetchInativosForUser(userId: string): Promise<{
|
||||
},
|
||||
}),
|
||||
db.query.contas.findMany({
|
||||
orderBy: (account: typeof contas.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(account.name)],
|
||||
orderBy: (
|
||||
account: typeof contas.$inferSelect,
|
||||
{ desc }: { desc: (field: unknown) => unknown },
|
||||
) => [desc(account.name)],
|
||||
where: eq(contas.userId, userId),
|
||||
columns: {
|
||||
id: true,
|
||||
@@ -146,17 +163,19 @@ export async function fetchInativosForUser(userId: string): Promise<{
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false))
|
||||
)
|
||||
or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false)),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.cartaoId),
|
||||
]);
|
||||
|
||||
const usageMap = new Map<string, number>();
|
||||
usageRows.forEach((row: { cartaoId: string | null; total: number | null }) => {
|
||||
usageRows.forEach(
|
||||
(row: { cartaoId: string | null; total: number | null }) => {
|
||||
if (!row.cartaoId) return;
|
||||
usageMap.set(row.cartaoId, Number(row.total ?? 0));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const cards = cardRows.map((card) => ({
|
||||
id: card.id,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiBankCard2Line } from "@remixicon/react";
|
||||
import PageDescription from "@/components/page-description";
|
||||
|
||||
export const metadata = {
|
||||
title: "Cartões | Opensheets",
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Loading state para a página de cartões
|
||||
*/
|
||||
export default function CartoesLoading() {
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
||||
import { CategoryDetailHeader } from "@/components/categorias/category-detail-header";
|
||||
import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page";
|
||||
import MonthNavigation from "@/components/month-picker/month-navigation";
|
||||
import { fetchCategoryDetails } from "@/lib/dashboard/categories/category-details";
|
||||
import { getUserId } from "@/lib/auth/server";
|
||||
import { fetchCategoryDetails } from "@/lib/dashboard/categories/category-details";
|
||||
import {
|
||||
buildOptionSets,
|
||||
buildSluggedFilters,
|
||||
fetchLancamentoFilterSources,
|
||||
} from "@/lib/lancamentos/page-helpers";
|
||||
import { displayPeriod, parsePeriodParam } from "@/lib/utils/period";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
@@ -21,11 +21,11 @@ type PageProps = {
|
||||
|
||||
const getSingleParam = (
|
||||
params: Record<string, string | string[] | undefined> | undefined,
|
||||
key: string
|
||||
key: string,
|
||||
) => {
|
||||
const value = params?.[key];
|
||||
if (!value) return null;
|
||||
return Array.isArray(value) ? value[0] ?? null : value;
|
||||
return Array.isArray(value) ? (value[0] ?? null) : value;
|
||||
};
|
||||
|
||||
export default async function Page({ params, searchParams }: PageProps) {
|
||||
@@ -36,8 +36,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
|
||||
|
||||
const [detail, filterSources, estabelecimentos] =
|
||||
await Promise.all([
|
||||
const [detail, filterSources, estabelecimentos] = await Promise.all([
|
||||
fetchCategoryDetails(userId, categoryId, selectedPeriod),
|
||||
fetchLancamentoFilterSources(userId),
|
||||
getRecentEstablishmentsAction(),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { categorias } from "@/db/schema";
|
||||
import {
|
||||
type ActionResult,
|
||||
@@ -11,8 +13,6 @@ import { CATEGORY_TYPES } from "@/lib/categorias/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { uuidSchema } from "@/lib/schemas/common";
|
||||
import { normalizeIconInput } from "@/lib/utils/string";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
const categoryBaseSchema = z.object({
|
||||
name: z
|
||||
@@ -43,7 +43,7 @@ type CategoryUpdateInput = z.infer<typeof updateCategorySchema>;
|
||||
type CategoryDeleteInput = z.infer<typeof deleteCategorySchema>;
|
||||
|
||||
export async function createCategoryAction(
|
||||
input: CategoryCreateInput
|
||||
input: CategoryCreateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -65,7 +65,7 @@ export async function createCategoryAction(
|
||||
}
|
||||
|
||||
export async function updateCategoryAction(
|
||||
input: CategoryUpdateInput
|
||||
input: CategoryUpdateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -123,7 +123,7 @@ export async function updateCategoryAction(
|
||||
}
|
||||
|
||||
export async function deleteCategoryAction(
|
||||
input: CategoryDeleteInput
|
||||
input: CategoryDeleteInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { CategoryType } from "@/components/categorias/types";
|
||||
import { categorias, type Categoria } from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { CategoryType } from "@/components/categorias/types";
|
||||
import { type Categoria, categorias } from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export type CategoryData = {
|
||||
id: string;
|
||||
@@ -11,7 +11,7 @@ export type CategoryData = {
|
||||
};
|
||||
|
||||
export async function fetchCategoriesForUser(
|
||||
userId: string
|
||||
userId: string,
|
||||
): Promise<CategoryData[]> {
|
||||
const categoryRows = await db.query.categorias.findMany({
|
||||
where: eq(categorias.userId, userId),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiPriceTag3Line } from "@remixicon/react";
|
||||
import PageDescription from "@/components/page-description";
|
||||
|
||||
export const metadata = {
|
||||
title: "Categorias | Opensheets",
|
||||
|
||||
@@ -28,10 +28,7 @@ export default function CategoriasLoading() {
|
||||
{/* Grid de cards de categorias */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-2xl border p-6 space-y-4"
|
||||
>
|
||||
<div key={i} className="rounded-2xl border p-6 space-y-4">
|
||||
{/* Ícone + Nome */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-12 rounded-2xl bg-foreground/10" />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { and, desc, eq, lt, type SQL, sql } from "drizzle-orm";
|
||||
import { contas, lancamentos, pagadores } from "@/db/schema";
|
||||
import { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { and, eq, lt, sql } from "drizzle-orm";
|
||||
|
||||
export type AccountSummaryData = {
|
||||
openingBalance: number;
|
||||
@@ -31,7 +31,7 @@ export async function fetchAccountData(userId: string, contaId: string) {
|
||||
export async function fetchAccountSummary(
|
||||
userId: string,
|
||||
contaId: string,
|
||||
selectedPeriod: string
|
||||
selectedPeriod: string,
|
||||
): Promise<AccountSummaryData> {
|
||||
const [periodSummary] = await db
|
||||
.select({
|
||||
@@ -79,8 +79,8 @@ export async function fetchAccountSummary(
|
||||
eq(lancamentos.contaId, contaId),
|
||||
eq(lancamentos.period, selectedPeriod),
|
||||
eq(lancamentos.isSettled, true),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
|
||||
)
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
),
|
||||
);
|
||||
|
||||
const [previousRow] = await db
|
||||
@@ -105,8 +105,8 @@ export async function fetchAccountSummary(
|
||||
eq(lancamentos.contaId, contaId),
|
||||
lt(lancamentos.period, selectedPeriod),
|
||||
eq(lancamentos.isSettled, true),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
|
||||
)
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
),
|
||||
);
|
||||
|
||||
const account = await fetchAccountData(userId, contaId);
|
||||
@@ -129,3 +129,23 @@ export async function fetchAccountSummary(
|
||||
totalExpenses,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchAccountLancamentos(
|
||||
filters: SQL[],
|
||||
settledOnly = true,
|
||||
) {
|
||||
const allFilters = settledOnly
|
||||
? [...filters, eq(lancamentos.isSettled, true)]
|
||||
: filters;
|
||||
|
||||
return db.query.lancamentos.findMany({
|
||||
where: and(...allFilters),
|
||||
with: {
|
||||
pagador: true,
|
||||
conta: true,
|
||||
cartao: true,
|
||||
categoria: true,
|
||||
},
|
||||
orderBy: desc(lancamentos.purchaseDate),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { RiPencilLine } from "@remixicon/react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
||||
import { AccountDialog } from "@/components/contas/account-dialog";
|
||||
import { AccountStatementCard } from "@/components/contas/account-statement-card";
|
||||
@@ -5,8 +7,6 @@ import type { Account } from "@/components/contas/types";
|
||||
import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
|
||||
import MonthNavigation from "@/components/month-picker/month-navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { lancamentos } from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
import { getUserId } from "@/lib/auth/server";
|
||||
import {
|
||||
buildLancamentoWhere,
|
||||
@@ -21,10 +21,11 @@ import {
|
||||
} from "@/lib/lancamentos/page-helpers";
|
||||
import { loadLogoOptions } from "@/lib/logo/options";
|
||||
import { parsePeriodParam } from "@/lib/utils/period";
|
||||
import { RiPencilLine } from "@remixicon/react";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { notFound } from "next/navigation";
|
||||
import { fetchAccountData, fetchAccountSummary } from "./data";
|
||||
import {
|
||||
fetchAccountData,
|
||||
fetchAccountLancamentos,
|
||||
fetchAccountSummary,
|
||||
} from "./data";
|
||||
|
||||
type PageSearchParams = Promise<ResolvedSearchParams>;
|
||||
|
||||
@@ -56,12 +57,8 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const [
|
||||
filterSources,
|
||||
logoOptions,
|
||||
accountSummary,
|
||||
estabelecimentos,
|
||||
] = await Promise.all([
|
||||
const [filterSources, logoOptions, accountSummary, estabelecimentos] =
|
||||
await Promise.all([
|
||||
fetchLancamentoFilterSources(userId),
|
||||
loadLogoOptions(),
|
||||
fetchAccountSummary(userId, contaId, selectedPeriod),
|
||||
@@ -78,18 +75,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
accountId: account.id,
|
||||
});
|
||||
|
||||
filters.push(eq(lancamentos.isSettled, true));
|
||||
|
||||
const lancamentoRows = await db.query.lancamentos.findMany({
|
||||
where: and(...filters),
|
||||
with: {
|
||||
pagador: true,
|
||||
conta: true,
|
||||
cartao: true,
|
||||
categoria: true,
|
||||
},
|
||||
orderBy: desc(lancamentos.purchaseDate),
|
||||
});
|
||||
const lancamentoRows = await fetchAccountLancamentos(filters);
|
||||
|
||||
const lancamentosData = mapLancamentosData(lancamentoRows);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { categorias, contas, lancamentos, pagadores } from "@/db/schema";
|
||||
import {
|
||||
INITIAL_BALANCE_CATEGORY_NAME,
|
||||
@@ -8,23 +10,24 @@ import {
|
||||
INITIAL_BALANCE_PAYMENT_METHOD,
|
||||
INITIAL_BALANCE_TRANSACTION_TYPE,
|
||||
} from "@/lib/accounts/constants";
|
||||
import { type ActionResult, handleActionError } from "@/lib/actions/helpers";
|
||||
import { revalidateForEntity } from "@/lib/actions/helpers";
|
||||
import { noteSchema, uuidSchema } from "@/lib/schemas/common";
|
||||
import { formatDecimalForDbRequired } from "@/lib/utils/currency";
|
||||
import { getTodayInfo } from "@/lib/utils/date";
|
||||
import { normalizeFilePath } from "@/lib/utils/string";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
type ActionResult,
|
||||
handleActionError,
|
||||
revalidateForEntity,
|
||||
} from "@/lib/actions/helpers";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { noteSchema, uuidSchema } from "@/lib/schemas/common";
|
||||
import {
|
||||
TRANSFER_CATEGORY_NAME,
|
||||
TRANSFER_CONDITION,
|
||||
TRANSFER_ESTABLISHMENT,
|
||||
TRANSFER_PAYMENT_METHOD,
|
||||
} from "@/lib/transferencias/constants";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { formatDecimalForDbRequired } from "@/lib/utils/currency";
|
||||
import { getTodayInfo } from "@/lib/utils/date";
|
||||
import { normalizeFilePath } from "@/lib/utils/string";
|
||||
|
||||
const accountBaseSchema = z.object({
|
||||
name: z
|
||||
@@ -50,7 +53,7 @@ const accountBaseSchema = z.object({
|
||||
.transform((value) => (value.length === 0 ? "0" : value.replace(",", ".")))
|
||||
.refine(
|
||||
(value) => !Number.isNaN(Number.parseFloat(value)),
|
||||
"Informe um saldo inicial válido."
|
||||
"Informe um saldo inicial válido.",
|
||||
)
|
||||
.transform((value) => Number.parseFloat(value)),
|
||||
excludeFromBalance: z
|
||||
@@ -74,7 +77,7 @@ type AccountUpdateInput = z.infer<typeof updateAccountSchema>;
|
||||
type AccountDeleteInput = z.infer<typeof deleteAccountSchema>;
|
||||
|
||||
export async function createAccountAction(
|
||||
input: AccountCreateInput
|
||||
input: AccountCreateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -114,27 +117,27 @@ export async function createAccountAction(
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(categorias.userId, user.id),
|
||||
eq(categorias.name, INITIAL_BALANCE_CATEGORY_NAME)
|
||||
eq(categorias.name, INITIAL_BALANCE_CATEGORY_NAME),
|
||||
),
|
||||
}),
|
||||
tx.query.pagadores.findFirst({
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(pagadores.userId, user.id),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!category) {
|
||||
throw new Error(
|
||||
'Categoria "Saldo inicial" não encontrada. Crie-a antes de definir um saldo inicial.'
|
||||
'Categoria "Saldo inicial" não encontrada. Crie-a antes de definir um saldo inicial.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!adminPagador) {
|
||||
throw new Error(
|
||||
"Pagador com papel administrador não encontrado. Crie um pagador admin antes de definir um saldo inicial."
|
||||
"Pagador com papel administrador não encontrado. Crie um pagador admin antes de definir um saldo inicial.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,7 +172,7 @@ export async function createAccountAction(
|
||||
}
|
||||
|
||||
export async function updateAccountAction(
|
||||
input: AccountUpdateInput
|
||||
input: AccountUpdateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -211,7 +214,7 @@ export async function updateAccountAction(
|
||||
}
|
||||
|
||||
export async function deleteAccountAction(
|
||||
input: AccountDeleteInput
|
||||
input: AccountDeleteInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -250,7 +253,7 @@ const transferSchema = z.object({
|
||||
.transform((value) => (value.length === 0 ? "0" : value.replace(",", ".")))
|
||||
.refine(
|
||||
(value) => !Number.isNaN(Number.parseFloat(value)),
|
||||
"Informe um valor válido."
|
||||
"Informe um valor válido.",
|
||||
)
|
||||
.transform((value) => Number.parseFloat(value))
|
||||
.refine((value) => value > 0, "O valor deve ser maior que zero."),
|
||||
@@ -264,7 +267,7 @@ const transferSchema = z.object({
|
||||
type TransferInput = z.infer<typeof transferSchema>;
|
||||
|
||||
export async function transferBetweenAccountsAction(
|
||||
input: TransferInput
|
||||
input: TransferInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -288,14 +291,14 @@ export async function transferBetweenAccountsAction(
|
||||
columns: { id: true, name: true },
|
||||
where: and(
|
||||
eq(contas.id, data.fromAccountId),
|
||||
eq(contas.userId, user.id)
|
||||
eq(contas.userId, user.id),
|
||||
),
|
||||
}),
|
||||
tx.query.contas.findFirst({
|
||||
columns: { id: true, name: true },
|
||||
where: and(
|
||||
eq(contas.id, data.toAccountId),
|
||||
eq(contas.userId, user.id)
|
||||
eq(contas.userId, user.id),
|
||||
),
|
||||
}),
|
||||
]);
|
||||
@@ -313,13 +316,13 @@ export async function transferBetweenAccountsAction(
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(categorias.userId, user.id),
|
||||
eq(categorias.name, TRANSFER_CATEGORY_NAME)
|
||||
eq(categorias.name, TRANSFER_CATEGORY_NAME),
|
||||
),
|
||||
});
|
||||
|
||||
if (!transferCategory) {
|
||||
throw new Error(
|
||||
`Categoria "${TRANSFER_CATEGORY_NAME}" não encontrada. Por favor, crie esta categoria antes de fazer transferências.`
|
||||
`Categoria "${TRANSFER_CATEGORY_NAME}" não encontrada. Por favor, crie esta categoria antes de fazer transferências.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -328,13 +331,13 @@ export async function transferBetweenAccountsAction(
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(pagadores.userId, user.id),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
),
|
||||
});
|
||||
|
||||
if (!adminPagador) {
|
||||
throw new Error(
|
||||
"Pagador administrador não encontrado. Por favor, crie um pagador admin."
|
||||
"Pagador administrador não encontrado. Por favor, crie um pagador admin.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { and, eq, ilike, not, sql } from "drizzle-orm";
|
||||
import { contas, lancamentos, pagadores } from "@/db/schema";
|
||||
import { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { loadLogoOptions } from "@/lib/logo/options";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { and, eq, ilike, not, sql } from "drizzle-orm";
|
||||
|
||||
export type AccountData = {
|
||||
id: string;
|
||||
@@ -19,7 +19,7 @@ export type AccountData = {
|
||||
};
|
||||
|
||||
export async function fetchAccountsForUser(
|
||||
userId: string
|
||||
userId: string,
|
||||
): Promise<{ accounts: AccountData[]; logoOptions: LogoOption[] }> {
|
||||
const [accountRows, logoOptions] = await Promise.all([
|
||||
db
|
||||
@@ -51,16 +51,16 @@ export async function fetchAccountsForUser(
|
||||
and(
|
||||
eq(lancamentos.contaId, contas.id),
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.isSettled, true)
|
||||
)
|
||||
eq(lancamentos.isSettled, true),
|
||||
),
|
||||
)
|
||||
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(contas.userId, userId),
|
||||
not(ilike(contas.status, "inativa")),
|
||||
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`
|
||||
)
|
||||
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`,
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
contas.id,
|
||||
@@ -71,7 +71,7 @@ export async function fetchAccountsForUser(
|
||||
contas.logo,
|
||||
contas.initialBalance,
|
||||
contas.excludeFromBalance,
|
||||
contas.excludeInitialBalanceFromIncome
|
||||
contas.excludeInitialBalanceFromIncome,
|
||||
),
|
||||
loadLogoOptions(),
|
||||
]);
|
||||
@@ -95,7 +95,7 @@ export async function fetchAccountsForUser(
|
||||
}
|
||||
|
||||
export async function fetchInativosForUser(
|
||||
userId: string
|
||||
userId: string,
|
||||
): Promise<{ accounts: AccountData[]; logoOptions: LogoOption[] }> {
|
||||
const [accountRows, logoOptions] = await Promise.all([
|
||||
db
|
||||
@@ -127,16 +127,16 @@ export async function fetchInativosForUser(
|
||||
and(
|
||||
eq(lancamentos.contaId, contas.id),
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.isSettled, true)
|
||||
)
|
||||
eq(lancamentos.isSettled, true),
|
||||
),
|
||||
)
|
||||
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(contas.userId, userId),
|
||||
ilike(contas.status, "inativa"),
|
||||
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`
|
||||
)
|
||||
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`,
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
contas.id,
|
||||
@@ -147,7 +147,7 @@ export async function fetchInativosForUser(
|
||||
contas.logo,
|
||||
contas.initialBalance,
|
||||
contas.excludeFromBalance,
|
||||
contas.excludeInitialBalanceFromIncome
|
||||
contas.excludeInitialBalanceFromIncome,
|
||||
),
|
||||
loadLogoOptions(),
|
||||
]);
|
||||
|
||||
@@ -8,7 +8,11 @@ export default async function InativosPage() {
|
||||
|
||||
return (
|
||||
<main className="flex flex-col items-start gap-6">
|
||||
<AccountsPage accounts={accounts} logoOptions={logoOptions} isInativos={true} />
|
||||
<AccountsPage
|
||||
accounts={accounts}
|
||||
logoOptions={logoOptions}
|
||||
isInativos={true}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiBankLine } from "@remixicon/react";
|
||||
import PageDescription from "@/components/page-description";
|
||||
|
||||
export const metadata = {
|
||||
title: "Contas | Opensheets",
|
||||
|
||||
@@ -4,8 +4,6 @@ import { fetchAccountsForUser } from "./data";
|
||||
|
||||
export default async function Page() {
|
||||
const userId = await getUserId();
|
||||
const now = new Date();
|
||||
|
||||
const { accounts, logoOptions } = await fetchAccountsForUser(userId);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiSecurePaymentLine } from "@remixicon/react";
|
||||
import PageDescription from "@/components/page-description";
|
||||
|
||||
export const metadata = {
|
||||
title: "Análise de Parcelas | Opensheets",
|
||||
|
||||
25
app/(dashboard)/dashboard/data.ts
Normal file
25
app/(dashboard)/dashboard/data.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db, schema } from "@/lib/db";
|
||||
|
||||
export interface UserDashboardPreferences {
|
||||
disableMagnetlines: boolean;
|
||||
dashboardWidgets: string | null;
|
||||
}
|
||||
|
||||
export async function fetchUserDashboardPreferences(
|
||||
userId: string,
|
||||
): Promise<UserDashboardPreferences> {
|
||||
const result = await db
|
||||
.select({
|
||||
disableMagnetlines: schema.userPreferences.disableMagnetlines,
|
||||
dashboardWidgets: schema.userPreferences.dashboardWidgets,
|
||||
})
|
||||
.from(schema.userPreferences)
|
||||
.where(eq(schema.userPreferences.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
return {
|
||||
disableMagnetlines: result[0]?.disableMagnetlines ?? false,
|
||||
dashboardWidgets: result[0]?.dashboardWidgets ?? null,
|
||||
};
|
||||
}
|
||||
@@ -4,9 +4,8 @@ import { SectionCards } from "@/components/dashboard/section-cards";
|
||||
import MonthNavigation from "@/components/month-picker/month-navigation";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import { fetchDashboardData } from "@/lib/dashboard/fetch-dashboard-data";
|
||||
import { db, schema } from "@/lib/db";
|
||||
import { parsePeriodParam } from "@/lib/utils/period";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { fetchUserDashboardPreferences } from "./data";
|
||||
|
||||
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
@@ -29,20 +28,12 @@ export default async function Page({ searchParams }: PageProps) {
|
||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
|
||||
|
||||
const [data, preferencesResult] = await Promise.all([
|
||||
const [data, preferences] = await Promise.all([
|
||||
fetchDashboardData(user.id, selectedPeriod),
|
||||
db
|
||||
.select({
|
||||
disableMagnetlines: schema.userPreferences.disableMagnetlines,
|
||||
dashboardWidgets: schema.userPreferences.dashboardWidgets,
|
||||
})
|
||||
.from(schema.userPreferences)
|
||||
.where(eq(schema.userPreferences.userId, user.id))
|
||||
.limit(1),
|
||||
fetchUserDashboardPreferences(user.id),
|
||||
]);
|
||||
|
||||
const disableMagnetlines = preferencesResult[0]?.disableMagnetlines ?? false;
|
||||
const dashboardWidgets = preferencesResult[0]?.dashboardWidgets ?? null;
|
||||
const { disableMagnetlines, dashboardWidgets } = preferences;
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 px-6">
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
"use server";
|
||||
|
||||
import { anthropic } from "@ai-sdk/anthropic";
|
||||
import { google } from "@ai-sdk/google";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
||||
import { generateObject } from "ai";
|
||||
import { getDay } from "date-fns";
|
||||
import { and, eq, isNull, ne, or, sql } from "drizzle-orm";
|
||||
import {
|
||||
cartoes,
|
||||
categorias,
|
||||
@@ -10,21 +17,14 @@ import {
|
||||
savedInsights,
|
||||
} from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import {
|
||||
InsightsResponseSchema,
|
||||
type InsightsResponse,
|
||||
InsightsResponseSchema,
|
||||
} from "@/lib/schemas/insights";
|
||||
import { getPreviousPeriod } from "@/lib/utils/period";
|
||||
import { anthropic } from "@ai-sdk/anthropic";
|
||||
import { google } from "@ai-sdk/google";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
||||
import { generateObject } from "ai";
|
||||
import { getDay } from "date-fns";
|
||||
import { and, eq, isNull, ne, or, sql } from "drizzle-orm";
|
||||
import { AVAILABLE_MODELS, INSIGHTS_SYSTEM_PROMPT } from "./data";
|
||||
|
||||
const TRANSFERENCIA = "Transferência";
|
||||
@@ -54,7 +54,12 @@ async function aggregateMonthData(userId: string, period: string) {
|
||||
const threeMonthsAgo = getPreviousPeriod(twoMonthsAgo);
|
||||
|
||||
// Buscar métricas de receitas e despesas dos últimos 3 meses
|
||||
const [currentPeriodRows, previousPeriodRows, twoMonthsAgoRows, threeMonthsAgoRows] = await Promise.all([
|
||||
const [
|
||||
currentPeriodRows,
|
||||
previousPeriodRows,
|
||||
twoMonthsAgoRows,
|
||||
threeMonthsAgoRows,
|
||||
] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
transactionType: lancamentos.transactionType,
|
||||
@@ -72,9 +77,9 @@ async function aggregateMonthData(userId: string, period: string) {
|
||||
isNull(lancamentos.note),
|
||||
sql`${
|
||||
lancamentos.note
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
)
|
||||
)
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.transactionType),
|
||||
db
|
||||
@@ -94,9 +99,9 @@ async function aggregateMonthData(userId: string, period: string) {
|
||||
isNull(lancamentos.note),
|
||||
sql`${
|
||||
lancamentos.note
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
)
|
||||
)
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.transactionType),
|
||||
db
|
||||
@@ -116,9 +121,9 @@ async function aggregateMonthData(userId: string, period: string) {
|
||||
isNull(lancamentos.note),
|
||||
sql`${
|
||||
lancamentos.note
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
)
|
||||
)
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.transactionType),
|
||||
db
|
||||
@@ -138,9 +143,9 @@ async function aggregateMonthData(userId: string, period: string) {
|
||||
isNull(lancamentos.note),
|
||||
sql`${
|
||||
lancamentos.note
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
)
|
||||
)
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.transactionType),
|
||||
]);
|
||||
@@ -199,9 +204,9 @@ async function aggregateMonthData(userId: string, period: string) {
|
||||
isNull(lancamentos.note),
|
||||
sql`${
|
||||
lancamentos.note
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
)
|
||||
)
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(categorias.name)
|
||||
.orderBy(sql`sum(${lancamentos.amount}) ASC`)
|
||||
@@ -222,8 +227,8 @@ async function aggregateMonthData(userId: string, period: string) {
|
||||
eq(lancamentos.categoriaId, categorias.id),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.transactionType, "Despesa")
|
||||
)
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period)))
|
||||
.groupBy(categorias.name, orcamentos.amount);
|
||||
@@ -248,8 +253,8 @@ async function aggregateMonthData(userId: string, period: string) {
|
||||
and(
|
||||
eq(contas.userId, userId),
|
||||
eq(contas.status, "ativa"),
|
||||
eq(contas.excludeFromBalance, false)
|
||||
)
|
||||
eq(contas.excludeFromBalance, false),
|
||||
),
|
||||
);
|
||||
|
||||
// Calcular ticket médio das transações
|
||||
@@ -265,8 +270,8 @@ async function aggregateMonthData(userId: string, period: string) {
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
ne(lancamentos.transactionType, TRANSFERENCIA)
|
||||
)
|
||||
ne(lancamentos.transactionType, TRANSFERENCIA),
|
||||
),
|
||||
);
|
||||
|
||||
// Buscar gastos por dia da semana
|
||||
@@ -282,8 +287,8 @@ async function aggregateMonthData(userId: string, period: string) {
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
|
||||
)
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
),
|
||||
);
|
||||
|
||||
// Agregar por dia da semana
|
||||
@@ -308,8 +313,8 @@ async function aggregateMonthData(userId: string, period: string) {
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
|
||||
)
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.paymentMethod);
|
||||
|
||||
@@ -333,13 +338,16 @@ async function aggregateMonthData(userId: string, period: string) {
|
||||
sql`${lancamentos.period} IN (${period}, ${previousPeriod}, ${twoMonthsAgo})`,
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
ne(lancamentos.transactionType, TRANSFERENCIA)
|
||||
)
|
||||
ne(lancamentos.transactionType, TRANSFERENCIA),
|
||||
),
|
||||
)
|
||||
.orderBy(lancamentos.name);
|
||||
|
||||
// Análise de recorrência
|
||||
const transactionsByName = new Map<string, Array<{ period: string; amount: number }>>();
|
||||
const transactionsByName = new Map<
|
||||
string,
|
||||
Array<{ period: string; amount: number }>
|
||||
>();
|
||||
|
||||
for (const tx of last3MonthsTransactions) {
|
||||
const key = tx.name.toLowerCase().trim();
|
||||
@@ -356,13 +364,18 @@ async function aggregateMonthData(userId: string, period: string) {
|
||||
}
|
||||
|
||||
// Identificar gastos recorrentes (aparece em 2+ meses com valor similar)
|
||||
const recurringExpenses: Array<{ name: string; avgAmount: number; frequency: number }> = [];
|
||||
const recurringExpenses: Array<{
|
||||
name: string;
|
||||
avgAmount: number;
|
||||
frequency: number;
|
||||
}> = [];
|
||||
let totalRecurring = 0;
|
||||
|
||||
for (const [name, occurrences] of transactionsByName.entries()) {
|
||||
if (occurrences.length >= 2) {
|
||||
const amounts = occurrences.map(o => o.amount);
|
||||
const avgAmount = amounts.reduce((sum, amt) => sum + amt, 0) / amounts.length;
|
||||
const amounts = occurrences.map((o) => o.amount);
|
||||
const avgAmount =
|
||||
amounts.reduce((sum, amt) => sum + amt, 0) / amounts.length;
|
||||
const maxDiff = Math.max(...amounts) - Math.min(...amounts);
|
||||
|
||||
// Considerar recorrente se variação <= 20% da média
|
||||
@@ -374,7 +387,9 @@ async function aggregateMonthData(userId: string, period: string) {
|
||||
});
|
||||
|
||||
// Somar apenas os do mês atual
|
||||
const currentMonthOccurrence = occurrences.find(o => o.period === period);
|
||||
const currentMonthOccurrence = occurrences.find(
|
||||
(o) => o.period === period,
|
||||
);
|
||||
if (currentMonthOccurrence) {
|
||||
totalRecurring += currentMonthOccurrence.amount;
|
||||
}
|
||||
@@ -384,12 +399,15 @@ async function aggregateMonthData(userId: string, period: string) {
|
||||
|
||||
// Análise de gastos parcelados
|
||||
const installmentTransactions = last3MonthsTransactions.filter(
|
||||
tx => tx.condition === "Parcelado" && tx.installmentCount && tx.installmentCount > 1
|
||||
(tx) =>
|
||||
tx.condition === "Parcelado" &&
|
||||
tx.installmentCount &&
|
||||
tx.installmentCount > 1,
|
||||
);
|
||||
|
||||
const installmentData = installmentTransactions
|
||||
.filter(tx => tx.period === period)
|
||||
.map(tx => ({
|
||||
.filter((tx) => tx.period === period)
|
||||
.map((tx) => ({
|
||||
name: tx.name,
|
||||
currentInstallment: tx.currentInstallment ?? 1,
|
||||
totalInstallments: tx.installmentCount ?? 1,
|
||||
@@ -397,10 +415,13 @@ async function aggregateMonthData(userId: string, period: string) {
|
||||
category: tx.categoryName ?? "Outros",
|
||||
}));
|
||||
|
||||
const totalInstallmentAmount = installmentData.reduce((sum, tx) => sum + tx.amount, 0);
|
||||
const totalInstallmentAmount = installmentData.reduce(
|
||||
(sum, tx) => sum + tx.amount,
|
||||
0,
|
||||
);
|
||||
const futureCommitment = installmentData.reduce((sum, tx) => {
|
||||
const remaining = (tx.totalInstallments - tx.currentInstallment);
|
||||
return sum + (tx.amount * remaining);
|
||||
const remaining = tx.totalInstallments - tx.currentInstallment;
|
||||
return sum + tx.amount * remaining;
|
||||
}, 0);
|
||||
|
||||
// Montar dados agregados e anonimizados
|
||||
@@ -413,13 +434,36 @@ async function aggregateMonthData(userId: string, period: string) {
|
||||
// Tendência de 3 meses
|
||||
threeMonthTrend: {
|
||||
periods: [threeMonthsAgo, twoMonthsAgo, previousPeriod, period],
|
||||
incomes: [threeMonthsAgoIncome, twoMonthsAgoIncome, previousIncome, currentIncome],
|
||||
expenses: [threeMonthsAgoExpense, twoMonthsAgoExpense, previousExpense, currentExpense],
|
||||
avgIncome: (threeMonthsAgoIncome + twoMonthsAgoIncome + previousIncome + currentIncome) / 4,
|
||||
avgExpense: (threeMonthsAgoExpense + twoMonthsAgoExpense + previousExpense + currentExpense) / 4,
|
||||
trend: currentExpense > previousExpense && previousExpense > twoMonthsAgoExpense
|
||||
incomes: [
|
||||
threeMonthsAgoIncome,
|
||||
twoMonthsAgoIncome,
|
||||
previousIncome,
|
||||
currentIncome,
|
||||
],
|
||||
expenses: [
|
||||
threeMonthsAgoExpense,
|
||||
twoMonthsAgoExpense,
|
||||
previousExpense,
|
||||
currentExpense,
|
||||
],
|
||||
avgIncome:
|
||||
(threeMonthsAgoIncome +
|
||||
twoMonthsAgoIncome +
|
||||
previousIncome +
|
||||
currentIncome) /
|
||||
4,
|
||||
avgExpense:
|
||||
(threeMonthsAgoExpense +
|
||||
twoMonthsAgoExpense +
|
||||
previousExpense +
|
||||
currentExpense) /
|
||||
4,
|
||||
trend:
|
||||
currentExpense > previousExpense &&
|
||||
previousExpense > twoMonthsAgoExpense
|
||||
? "crescente"
|
||||
: currentExpense < previousExpense && previousExpense < twoMonthsAgoExpense
|
||||
: currentExpense < previousExpense &&
|
||||
previousExpense < twoMonthsAgoExpense
|
||||
? "decrescente"
|
||||
: "estável",
|
||||
},
|
||||
@@ -446,7 +490,7 @@ async function aggregateMonthData(userId: string, period: string) {
|
||||
currentExpense > 0
|
||||
? (Math.abs(toNumber(cat.total)) / currentExpense) * 100
|
||||
: 0,
|
||||
})
|
||||
}),
|
||||
),
|
||||
budgets: budgetsData.map(
|
||||
(b: { categoryName: string; budgetAmount: unknown; spent: unknown }) => ({
|
||||
@@ -457,7 +501,7 @@ async function aggregateMonthData(userId: string, period: string) {
|
||||
toNumber(b.budgetAmount) > 0
|
||||
? (Math.abs(toNumber(b.spent)) / toNumber(b.budgetAmount)) * 100
|
||||
: 0,
|
||||
})
|
||||
}),
|
||||
),
|
||||
creditCards: {
|
||||
totalLimit: toNumber(cardsData[0]?.totalLimit ?? 0),
|
||||
@@ -480,35 +524,40 @@ async function aggregateMonthData(userId: string, period: string) {
|
||||
total: toNumber(pm.total),
|
||||
percentage:
|
||||
currentExpense > 0 ? (toNumber(pm.total) / currentExpense) * 100 : 0,
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
||||
// Análise de recorrência
|
||||
recurringExpenses: {
|
||||
count: recurringExpenses.length,
|
||||
total: totalRecurring,
|
||||
percentageOfTotal: currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0,
|
||||
percentageOfTotal:
|
||||
currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0,
|
||||
topRecurring: recurringExpenses
|
||||
.sort((a, b) => b.avgAmount - a.avgAmount)
|
||||
.slice(0, 5)
|
||||
.map(r => ({
|
||||
.map((r) => ({
|
||||
name: r.name,
|
||||
avgAmount: r.avgAmount,
|
||||
frequency: r.frequency,
|
||||
})),
|
||||
predictability: currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0,
|
||||
predictability:
|
||||
currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0,
|
||||
},
|
||||
|
||||
// Análise de parcelamentos
|
||||
installments: {
|
||||
currentMonthInstallments: installmentData.length,
|
||||
totalInstallmentAmount,
|
||||
percentageOfExpenses: currentExpense > 0 ? (totalInstallmentAmount / currentExpense) * 100 : 0,
|
||||
percentageOfExpenses:
|
||||
currentExpense > 0
|
||||
? (totalInstallmentAmount / currentExpense) * 100
|
||||
: 0,
|
||||
futureCommitment,
|
||||
topInstallments: installmentData
|
||||
.sort((a, b) => b.amount - a.amount)
|
||||
.slice(0, 5)
|
||||
.map(i => ({
|
||||
.map((i) => ({
|
||||
name: i.name,
|
||||
current: i.currentInstallment,
|
||||
total: i.totalInstallments,
|
||||
@@ -527,7 +576,7 @@ async function aggregateMonthData(userId: string, period: string) {
|
||||
*/
|
||||
export async function generateInsightsAction(
|
||||
period: string,
|
||||
modelId: string
|
||||
modelId: string,
|
||||
): Promise<ActionResult<InsightsResponse>> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -555,7 +604,8 @@ export async function generateInsightsAction(
|
||||
if (!apiKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: "OPENROUTER_API_KEY não configurada. Adicione a chave no arquivo .env",
|
||||
error:
|
||||
"OPENROUTER_API_KEY não configurada. Adicione a chave no arquivo .env",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -639,7 +689,7 @@ Responda APENAS com um JSON válido seguindo exatamente o schema especificado.`,
|
||||
export async function saveInsightsAction(
|
||||
period: string,
|
||||
modelId: string,
|
||||
data: InsightsResponse
|
||||
data: InsightsResponse,
|
||||
): Promise<ActionResult<{ id: string; createdAt: Date }>> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -649,7 +699,10 @@ export async function saveInsightsAction(
|
||||
.select()
|
||||
.from(savedInsights)
|
||||
.where(
|
||||
and(eq(savedInsights.userId, user.id), eq(savedInsights.period, period))
|
||||
and(
|
||||
eq(savedInsights.userId, user.id),
|
||||
eq(savedInsights.period, period),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
@@ -665,10 +718,13 @@ export async function saveInsightsAction(
|
||||
.where(
|
||||
and(
|
||||
eq(savedInsights.userId, user.id),
|
||||
eq(savedInsights.period, period)
|
||||
eq(savedInsights.period, period),
|
||||
),
|
||||
)
|
||||
)
|
||||
.returning({ id: savedInsights.id, createdAt: savedInsights.createdAt });
|
||||
.returning({
|
||||
id: savedInsights.id,
|
||||
createdAt: savedInsights.createdAt,
|
||||
});
|
||||
|
||||
const updatedRecord = updated[0];
|
||||
if (!updatedRecord) {
|
||||
@@ -728,9 +784,7 @@ export async function saveInsightsAction(
|
||||
/**
|
||||
* Carrega insights salvos do banco de dados
|
||||
*/
|
||||
export async function loadSavedInsightsAction(
|
||||
period: string
|
||||
): Promise<
|
||||
export async function loadSavedInsightsAction(period: string): Promise<
|
||||
ActionResult<{
|
||||
insights: InsightsResponse;
|
||||
modelId: string;
|
||||
@@ -744,7 +798,10 @@ export async function loadSavedInsightsAction(
|
||||
.select()
|
||||
.from(savedInsights)
|
||||
.where(
|
||||
and(eq(savedInsights.userId, user.id), eq(savedInsights.period, period))
|
||||
and(
|
||||
eq(savedInsights.userId, user.id),
|
||||
eq(savedInsights.period, period),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
@@ -789,7 +846,7 @@ export async function loadSavedInsightsAction(
|
||||
* Remove insights salvos do banco de dados
|
||||
*/
|
||||
export async function deleteSavedInsightsAction(
|
||||
period: string
|
||||
period: string,
|
||||
): Promise<ActionResult<void>> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -797,7 +854,10 @@ export async function deleteSavedInsightsAction(
|
||||
await db
|
||||
.delete(savedInsights)
|
||||
.where(
|
||||
and(eq(savedInsights.userId, user.id), eq(savedInsights.period, period))
|
||||
and(
|
||||
eq(savedInsights.userId, user.id),
|
||||
eq(savedInsights.period, period),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiSparklingLine } from "@remixicon/react";
|
||||
import PageDescription from "@/components/page-description";
|
||||
|
||||
export const metadata = {
|
||||
title: "Insights | Opensheets",
|
||||
|
||||
@@ -16,10 +16,7 @@ export default function InsightsLoading() {
|
||||
{/* Grid de insights */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-2xl border p-6 space-y-4"
|
||||
>
|
||||
<div key={i} className="rounded-2xl border p-6 space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-6 w-48 rounded-2xl bg-foreground/10" />
|
||||
|
||||
@@ -10,11 +10,11 @@ type PageProps = {
|
||||
|
||||
const getSingleParam = (
|
||||
params: Record<string, string | string[] | undefined> | undefined,
|
||||
key: string
|
||||
key: string,
|
||||
) => {
|
||||
const value = params?.[key];
|
||||
if (!value) return null;
|
||||
return Array.isArray(value) ? value[0] ?? null : value;
|
||||
return Array.isArray(value) ? (value[0] ?? null) : value;
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
"use server";
|
||||
|
||||
import { contas, lancamentos, pagadores, categorias, cartoes } from "@/db/schema";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
cartoes,
|
||||
categorias,
|
||||
contas,
|
||||
lancamentos,
|
||||
pagadores,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
INITIAL_BALANCE_CONDITION,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
@@ -9,8 +18,8 @@ import {
|
||||
} from "@/lib/accounts/constants";
|
||||
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
|
||||
import type { ActionResult } from "@/lib/actions/types";
|
||||
import { db } from "@/lib/db";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
LANCAMENTO_CONDITIONS,
|
||||
LANCAMENTO_PAYMENT_METHODS,
|
||||
@@ -22,14 +31,7 @@ import {
|
||||
} from "@/lib/pagadores/notifications";
|
||||
import { noteSchema, uuidSchema } from "@/lib/schemas/common";
|
||||
import { formatDecimalForDbRequired } from "@/lib/utils/currency";
|
||||
import {
|
||||
getTodayDate,
|
||||
getTodayDateString,
|
||||
parseLocalDateString,
|
||||
} from "@/lib/utils/date";
|
||||
import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { getTodayDate, parseLocalDateString } from "@/lib/utils/date";
|
||||
|
||||
// ============================================================================
|
||||
// Authorization Validation Functions
|
||||
@@ -37,15 +39,12 @@ import { z } from "zod";
|
||||
|
||||
async function validatePagadorOwnership(
|
||||
userId: string,
|
||||
pagadorId: string | null | undefined
|
||||
pagadorId: string | null | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!pagadorId) return true; // Se não tem pagadorId, não precisa validar
|
||||
|
||||
const pagador = await db.query.pagadores.findFirst({
|
||||
where: and(
|
||||
eq(pagadores.id, pagadorId),
|
||||
eq(pagadores.userId, userId)
|
||||
),
|
||||
where: and(eq(pagadores.id, pagadorId), eq(pagadores.userId, userId)),
|
||||
});
|
||||
|
||||
return !!pagador;
|
||||
@@ -53,15 +52,12 @@ async function validatePagadorOwnership(
|
||||
|
||||
async function validateCategoriaOwnership(
|
||||
userId: string,
|
||||
categoriaId: string | null | undefined
|
||||
categoriaId: string | null | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!categoriaId) return true;
|
||||
|
||||
const categoria = await db.query.categorias.findFirst({
|
||||
where: and(
|
||||
eq(categorias.id, categoriaId),
|
||||
eq(categorias.userId, userId)
|
||||
),
|
||||
where: and(eq(categorias.id, categoriaId), eq(categorias.userId, userId)),
|
||||
});
|
||||
|
||||
return !!categoria;
|
||||
@@ -69,15 +65,12 @@ async function validateCategoriaOwnership(
|
||||
|
||||
async function validateContaOwnership(
|
||||
userId: string,
|
||||
contaId: string | null | undefined
|
||||
contaId: string | null | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!contaId) return true;
|
||||
|
||||
const conta = await db.query.contas.findFirst({
|
||||
where: and(
|
||||
eq(contas.id, contaId),
|
||||
eq(contas.userId, userId)
|
||||
),
|
||||
where: and(eq(contas.id, contaId), eq(contas.userId, userId)),
|
||||
});
|
||||
|
||||
return !!conta;
|
||||
@@ -85,15 +78,12 @@ async function validateContaOwnership(
|
||||
|
||||
async function validateCartaoOwnership(
|
||||
userId: string,
|
||||
cartaoId: string | null | undefined
|
||||
cartaoId: string | null | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!cartaoId) return true;
|
||||
|
||||
const cartao = await db.query.cartoes.findFirst({
|
||||
where: and(
|
||||
eq(cartoes.id, cartaoId),
|
||||
eq(cartoes.userId, userId)
|
||||
),
|
||||
where: and(eq(cartoes.id, cartaoId), eq(cartoes.userId, userId)),
|
||||
});
|
||||
|
||||
return !!cartao;
|
||||
@@ -188,7 +178,7 @@ const baseFields = z.object({
|
||||
|
||||
const refineLancamento = (
|
||||
data: z.infer<typeof baseFields> & { id?: string },
|
||||
ctx: z.RefinementCtx
|
||||
ctx: z.RefinementCtx,
|
||||
) => {
|
||||
if (data.condition === "Parcelado") {
|
||||
if (!data.installmentCount) {
|
||||
@@ -316,7 +306,7 @@ const splitAmount = (totalCents: number, parts: number) => {
|
||||
|
||||
return Array.from(
|
||||
{ length: parts },
|
||||
(_, index) => base + (index < remainder ? 1 : 0)
|
||||
(_, index) => base + (index < remainder ? 1 : 0),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -347,7 +337,7 @@ const addMonthsToDate = (value: Date, offset: number) => {
|
||||
const lastDay = new Date(
|
||||
result.getFullYear(),
|
||||
result.getMonth() + 1,
|
||||
0
|
||||
0,
|
||||
).getDate();
|
||||
|
||||
result.setDate(Math.min(originalDay, lastDay));
|
||||
@@ -445,7 +435,7 @@ const buildLancamentoRecords = ({
|
||||
if (data.condition === "Parcelado") {
|
||||
const installmentTotal = data.installmentCount ?? 0;
|
||||
const amountsByShare = shares.map((share) =>
|
||||
splitAmount(share.amountCents, installmentTotal)
|
||||
splitAmount(share.amountCents, installmentTotal),
|
||||
);
|
||||
|
||||
for (
|
||||
@@ -534,7 +524,7 @@ const buildLancamentoRecords = ({
|
||||
};
|
||||
|
||||
export async function createLancamentoAction(
|
||||
input: CreateInput
|
||||
input: CreateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -544,19 +534,31 @@ export async function createLancamentoAction(
|
||||
if (data.pagadorId) {
|
||||
const isValid = await validatePagadorOwnership(user.id, data.pagadorId);
|
||||
if (!isValid) {
|
||||
return { success: false, error: "Pagador não encontrado ou sem permissão." };
|
||||
return {
|
||||
success: false,
|
||||
error: "Pagador não encontrado ou sem permissão.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (data.secondaryPagadorId) {
|
||||
const isValid = await validatePagadorOwnership(user.id, data.secondaryPagadorId);
|
||||
const isValid = await validatePagadorOwnership(
|
||||
user.id,
|
||||
data.secondaryPagadorId,
|
||||
);
|
||||
if (!isValid) {
|
||||
return { success: false, error: "Pagador secundário não encontrado ou sem permissão." };
|
||||
return {
|
||||
success: false,
|
||||
error: "Pagador secundário não encontrado ou sem permissão.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (data.categoriaId) {
|
||||
const isValid = await validateCategoriaOwnership(user.id, data.categoriaId);
|
||||
const isValid = await validateCategoriaOwnership(
|
||||
user.id,
|
||||
data.categoriaId,
|
||||
);
|
||||
if (!isValid) {
|
||||
return { success: false, error: "Categoria não encontrada." };
|
||||
}
|
||||
@@ -634,7 +636,7 @@ export async function createLancamentoAction(
|
||||
purchaseDate: record.purchaseDate ?? null,
|
||||
period: record.period ?? null,
|
||||
note: record.note ?? null,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
if (notificationEntries.size > 0) {
|
||||
@@ -654,7 +656,7 @@ export async function createLancamentoAction(
|
||||
}
|
||||
|
||||
export async function updateLancamentoAction(
|
||||
input: UpdateInput
|
||||
input: UpdateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -664,19 +666,31 @@ export async function updateLancamentoAction(
|
||||
if (data.pagadorId) {
|
||||
const isValid = await validatePagadorOwnership(user.id, data.pagadorId);
|
||||
if (!isValid) {
|
||||
return { success: false, error: "Pagador não encontrado ou sem permissão." };
|
||||
return {
|
||||
success: false,
|
||||
error: "Pagador não encontrado ou sem permissão.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (data.secondaryPagadorId) {
|
||||
const isValid = await validatePagadorOwnership(user.id, data.secondaryPagadorId);
|
||||
const isValid = await validatePagadorOwnership(
|
||||
user.id,
|
||||
data.secondaryPagadorId,
|
||||
);
|
||||
if (!isValid) {
|
||||
return { success: false, error: "Pagador secundário não encontrado ou sem permissão." };
|
||||
return {
|
||||
success: false,
|
||||
error: "Pagador secundário não encontrado ou sem permissão.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (data.categoriaId) {
|
||||
const isValid = await validateCategoriaOwnership(user.id, data.categoriaId);
|
||||
const isValid = await validateCategoriaOwnership(
|
||||
user.id,
|
||||
data.categoriaId,
|
||||
);
|
||||
if (!isValid) {
|
||||
return { success: false, error: "Categoria não encontrada." };
|
||||
}
|
||||
@@ -740,7 +754,7 @@ export async function updateLancamentoAction(
|
||||
const normalizedSettled =
|
||||
data.paymentMethod === "Cartão de crédito"
|
||||
? null
|
||||
: data.isSettled ?? false;
|
||||
: (data.isSettled ?? false);
|
||||
const shouldSetBoletoPaymentDate =
|
||||
data.paymentMethod === "Boleto" && Boolean(normalizedSettled);
|
||||
const boletoPaymentDateValue = shouldSetBoletoPaymentDate
|
||||
@@ -774,13 +788,13 @@ export async function updateLancamentoAction(
|
||||
|
||||
if (isInitialBalanceLancamento(existing) && existing?.contaId) {
|
||||
const updatedInitialBalance = formatDecimalForDbRequired(
|
||||
Math.abs(data.amount ?? 0)
|
||||
Math.abs(data.amount ?? 0),
|
||||
);
|
||||
await db
|
||||
.update(contas)
|
||||
.set({ initialBalance: updatedInitialBalance })
|
||||
.where(
|
||||
and(eq(contas.id, existing.contaId), eq(contas.userId, user.id))
|
||||
and(eq(contas.id, existing.contaId), eq(contas.userId, user.id)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -793,7 +807,7 @@ export async function updateLancamentoAction(
|
||||
}
|
||||
|
||||
export async function deleteLancamentoAction(
|
||||
input: DeleteInput
|
||||
input: DeleteInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -875,7 +889,7 @@ export async function deleteLancamentoAction(
|
||||
}
|
||||
|
||||
export async function toggleLancamentoSettlementAction(
|
||||
input: ToggleSettlementInput
|
||||
input: ToggleSettlementInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -935,7 +949,7 @@ const deleteBulkSchema = z.object({
|
||||
type DeleteBulkInput = z.infer<typeof deleteBulkSchema>;
|
||||
|
||||
export async function deleteLancamentoBulkAction(
|
||||
input: DeleteBulkInput
|
||||
input: DeleteBulkInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -967,7 +981,7 @@ export async function deleteLancamentoBulkAction(
|
||||
await db
|
||||
.delete(lancamentos)
|
||||
.where(
|
||||
and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id))
|
||||
and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)),
|
||||
);
|
||||
|
||||
revalidate();
|
||||
@@ -981,8 +995,8 @@ export async function deleteLancamentoBulkAction(
|
||||
and(
|
||||
eq(lancamentos.seriesId, existing.seriesId),
|
||||
eq(lancamentos.userId, user.id),
|
||||
sql`${lancamentos.period} >= ${existing.period}`
|
||||
)
|
||||
sql`${lancamentos.period} >= ${existing.period}`,
|
||||
),
|
||||
);
|
||||
|
||||
revalidate();
|
||||
@@ -998,8 +1012,8 @@ export async function deleteLancamentoBulkAction(
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.seriesId, existing.seriesId),
|
||||
eq(lancamentos.userId, user.id)
|
||||
)
|
||||
eq(lancamentos.userId, user.id),
|
||||
),
|
||||
);
|
||||
|
||||
revalidate();
|
||||
@@ -1054,7 +1068,7 @@ const updateBulkSchema = z.object({
|
||||
type UpdateBulkInput = z.infer<typeof updateBulkSchema>;
|
||||
|
||||
export async function updateLancamentoBulkAction(
|
||||
input: UpdateBulkInput
|
||||
input: UpdateBulkInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -1141,7 +1155,7 @@ export async function updateLancamentoBulkAction(
|
||||
};
|
||||
|
||||
const applyUpdates = async (
|
||||
records: Array<{ id: string; purchaseDate: Date | null }>
|
||||
records: Array<{ id: string; purchaseDate: Date | null }>,
|
||||
) => {
|
||||
if (records.length === 0) {
|
||||
return;
|
||||
@@ -1168,8 +1182,8 @@ export async function updateLancamentoBulkAction(
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.id, record.id),
|
||||
eq(lancamentos.userId, user.id)
|
||||
)
|
||||
eq(lancamentos.userId, user.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1196,7 +1210,7 @@ export async function updateLancamentoBulkAction(
|
||||
where: and(
|
||||
eq(lancamentos.seriesId, existing.seriesId),
|
||||
eq(lancamentos.userId, user.id),
|
||||
sql`${lancamentos.period} >= ${existing.period}`
|
||||
sql`${lancamentos.period} >= ${existing.period}`,
|
||||
),
|
||||
orderBy: asc(lancamentos.purchaseDate),
|
||||
});
|
||||
@@ -1205,7 +1219,7 @@ export async function updateLancamentoBulkAction(
|
||||
futureLancamentos.map((item) => ({
|
||||
id: item.id,
|
||||
purchaseDate: item.purchaseDate ?? null,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
revalidate();
|
||||
@@ -1223,7 +1237,7 @@ export async function updateLancamentoBulkAction(
|
||||
},
|
||||
where: and(
|
||||
eq(lancamentos.seriesId, existing.seriesId),
|
||||
eq(lancamentos.userId, user.id)
|
||||
eq(lancamentos.userId, user.id),
|
||||
),
|
||||
orderBy: asc(lancamentos.purchaseDate),
|
||||
});
|
||||
@@ -1232,7 +1246,7 @@ export async function updateLancamentoBulkAction(
|
||||
allLancamentos.map((item) => ({
|
||||
id: item.id,
|
||||
purchaseDate: item.purchaseDate ?? null,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
revalidate();
|
||||
@@ -1290,7 +1304,7 @@ const massAddSchema = z.object({
|
||||
type MassAddInput = z.infer<typeof massAddSchema>;
|
||||
|
||||
export async function createMassLancamentosAction(
|
||||
input: MassAddInput
|
||||
input: MassAddInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -1298,14 +1312,20 @@ export async function createMassLancamentosAction(
|
||||
|
||||
// Validar campos fixos
|
||||
if (data.fixedFields.contaId) {
|
||||
const isValid = await validateContaOwnership(user.id, data.fixedFields.contaId);
|
||||
const isValid = await validateContaOwnership(
|
||||
user.id,
|
||||
data.fixedFields.contaId,
|
||||
);
|
||||
if (!isValid) {
|
||||
return { success: false, error: "Conta não encontrada." };
|
||||
}
|
||||
}
|
||||
|
||||
if (data.fixedFields.cartaoId) {
|
||||
const isValid = await validateCartaoOwnership(user.id, data.fixedFields.cartaoId);
|
||||
const isValid = await validateCartaoOwnership(
|
||||
user.id,
|
||||
data.fixedFields.cartaoId,
|
||||
);
|
||||
if (!isValid) {
|
||||
return { success: false, error: "Cartão não encontrado." };
|
||||
}
|
||||
@@ -1316,21 +1336,27 @@ export async function createMassLancamentosAction(
|
||||
const transaction = data.transactions[i];
|
||||
|
||||
if (transaction.pagadorId) {
|
||||
const isValid = await validatePagadorOwnership(user.id, transaction.pagadorId);
|
||||
const isValid = await validatePagadorOwnership(
|
||||
user.id,
|
||||
transaction.pagadorId,
|
||||
);
|
||||
if (!isValid) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Pagador não encontrado na transação ${i + 1}.`
|
||||
error: `Pagador não encontrado na transação ${i + 1}.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (transaction.categoriaId) {
|
||||
const isValid = await validateCategoriaOwnership(user.id, transaction.categoriaId);
|
||||
const isValid = await validateCategoriaOwnership(
|
||||
user.id,
|
||||
transaction.categoriaId,
|
||||
);
|
||||
if (!isValid) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Categoria não encontrada na transação ${i + 1}.`
|
||||
error: `Categoria não encontrada na transação ${i + 1}.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1365,10 +1391,10 @@ export async function createMassLancamentosAction(
|
||||
const contaId =
|
||||
paymentMethod === "Cartão de crédito"
|
||||
? null
|
||||
: data.fixedFields.contaId ?? null;
|
||||
: (data.fixedFields.contaId ?? null);
|
||||
const cartaoId =
|
||||
paymentMethod === "Cartão de crédito"
|
||||
? data.fixedFields.cartaoId ?? null
|
||||
? (data.fixedFields.cartaoId ?? null)
|
||||
: null;
|
||||
const categoriaId = transaction.categoriaId ?? null;
|
||||
|
||||
@@ -1463,7 +1489,7 @@ const deleteMultipleSchema = z.object({
|
||||
type DeleteMultipleInput = z.infer<typeof deleteMultipleSchema>;
|
||||
|
||||
export async function deleteMultipleLancamentosAction(
|
||||
input: DeleteMultipleInput
|
||||
input: DeleteMultipleInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -1485,7 +1511,7 @@ export async function deleteMultipleLancamentosAction(
|
||||
},
|
||||
where: and(
|
||||
inArray(lancamentos.id, data.ids),
|
||||
eq(lancamentos.userId, user.id)
|
||||
eq(lancamentos.userId, user.id),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1497,17 +1523,17 @@ export async function deleteMultipleLancamentosAction(
|
||||
await db
|
||||
.delete(lancamentos)
|
||||
.where(
|
||||
and(inArray(lancamentos.id, data.ids), eq(lancamentos.userId, user.id))
|
||||
and(inArray(lancamentos.id, data.ids), eq(lancamentos.userId, user.id)),
|
||||
);
|
||||
|
||||
// Send notifications
|
||||
const notificationData = existing
|
||||
.filter(
|
||||
(
|
||||
item
|
||||
item,
|
||||
): item is typeof item & {
|
||||
pagadorId: NonNullable<typeof item.pagadorId>;
|
||||
} => Boolean(item.pagadorId)
|
||||
} => Boolean(item.pagadorId),
|
||||
)
|
||||
.map((item) => ({
|
||||
pagadorId: item.pagadorId,
|
||||
@@ -1561,8 +1587,8 @@ export async function getRecentEstablishmentsAction(): Promise<string[]> {
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, user.id),
|
||||
gte(lancamentos.purchaseDate, threeMonthsAgo)
|
||||
)
|
||||
gte(lancamentos.purchaseDate, threeMonthsAgo),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(lancamentos.purchaseDate));
|
||||
|
||||
@@ -1575,9 +1601,9 @@ export async function getRecentEstablishmentsAction(): Promise<string[]> {
|
||||
(name): name is string =>
|
||||
name != null &&
|
||||
name.trim().length > 0 &&
|
||||
!name.toLowerCase().startsWith("pagamento fatura")
|
||||
)
|
||||
)
|
||||
!name.toLowerCase().startsWith("pagamento fatura"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Return top 50 most recent unique establishments
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"use server";
|
||||
|
||||
import { and, asc, desc, eq, inArray, isNull, or } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
categorias,
|
||||
installmentAnticipations,
|
||||
lancamentos,
|
||||
pagadores,
|
||||
type InstallmentAnticipation,
|
||||
type Lancamento,
|
||||
} from "@/db/schema";
|
||||
import { handleActionError } from "@/lib/actions/helpers";
|
||||
import type { ActionResult } from "@/lib/actions/types";
|
||||
import { db } from "@/lib/db";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
generateAnticipationDescription,
|
||||
generateAnticipationNote,
|
||||
@@ -24,9 +25,6 @@ import type {
|
||||
} from "@/lib/installments/anticipation-types";
|
||||
import { uuidSchema } from "@/lib/schemas/common";
|
||||
import { formatDecimalForDbRequired } from "@/lib/utils/currency";
|
||||
import { and, asc, desc, eq, inArray, isNull, or } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Schema de validação para criar antecipação
|
||||
@@ -63,7 +61,7 @@ const cancelAnticipationSchema = z.object({
|
||||
* Busca parcelas elegíveis para antecipação de uma série
|
||||
*/
|
||||
export async function getEligibleInstallmentsAction(
|
||||
seriesId: string
|
||||
seriesId: string,
|
||||
): Promise<ActionResult<EligibleInstallment[]>> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -79,7 +77,7 @@ export async function getEligibleInstallmentsAction(
|
||||
eq(lancamentos.condition, "Parcelado"),
|
||||
// Apenas parcelas não pagas e não antecipadas
|
||||
or(eq(lancamentos.isSettled, false), isNull(lancamentos.isSettled)),
|
||||
eq(lancamentos.isAnticipated, false)
|
||||
eq(lancamentos.isAnticipated, false),
|
||||
),
|
||||
orderBy: [asc(lancamentos.currentInstallment)],
|
||||
columns: {
|
||||
@@ -124,7 +122,7 @@ export async function getEligibleInstallmentsAction(
|
||||
* Cria uma antecipação de parcelas
|
||||
*/
|
||||
export async function createInstallmentAnticipationAction(
|
||||
input: CreateAnticipationInput
|
||||
input: CreateAnticipationInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -137,7 +135,7 @@ export async function createInstallmentAnticipationAction(
|
||||
eq(lancamentos.userId, user.id),
|
||||
eq(lancamentos.seriesId, data.seriesId),
|
||||
or(eq(lancamentos.isSettled, false), isNull(lancamentos.isSettled)),
|
||||
eq(lancamentos.isAnticipated, false)
|
||||
eq(lancamentos.isAnticipated, false),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -158,7 +156,7 @@ export async function createInstallmentAnticipationAction(
|
||||
// 2. Calcular valor total
|
||||
const totalAmountCents = installments.reduce(
|
||||
(sum, inst) => sum + Number(inst.amount) * 100,
|
||||
0
|
||||
0,
|
||||
);
|
||||
const totalAmount = totalAmountCents / 100;
|
||||
const totalAmountAbs = Math.abs(totalAmount);
|
||||
@@ -175,7 +173,8 @@ export async function createInstallmentAnticipationAction(
|
||||
}
|
||||
|
||||
// 2.3. Calcular valor final (se negativo, soma o desconto para reduzir a despesa)
|
||||
const finalAmount = totalAmount < 0
|
||||
const finalAmount =
|
||||
totalAmount < 0
|
||||
? totalAmount + discount // Despesa: -1000 + 20 = -980
|
||||
: totalAmount - discount; // Receita: 1000 - 20 = 980
|
||||
|
||||
@@ -190,7 +189,7 @@ export async function createInstallmentAnticipationAction(
|
||||
.values({
|
||||
name: generateAnticipationDescription(
|
||||
firstInstallment.name,
|
||||
installments.length
|
||||
installments.length,
|
||||
),
|
||||
condition: "À vista",
|
||||
transactionType: firstInstallment.transactionType,
|
||||
@@ -219,7 +218,7 @@ export async function createInstallmentAnticipationAction(
|
||||
paymentMethod: inst.paymentMethod,
|
||||
categoriaId: inst.categoriaId,
|
||||
pagadorId: inst.pagadorId,
|
||||
}))
|
||||
})),
|
||||
),
|
||||
userId: user.id,
|
||||
installmentCount: null,
|
||||
@@ -270,7 +269,9 @@ export async function createInstallmentAnticipationAction(
|
||||
return {
|
||||
success: true,
|
||||
message: `${installments.length} ${
|
||||
installments.length === 1 ? "parcela antecipada" : "parcelas antecipadas"
|
||||
installments.length === 1
|
||||
? "parcela antecipada"
|
||||
: "parcelas antecipadas"
|
||||
} com sucesso!`,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -282,7 +283,7 @@ export async function createInstallmentAnticipationAction(
|
||||
* Busca histórico de antecipações de uma série
|
||||
*/
|
||||
export async function getInstallmentAnticipationsAction(
|
||||
seriesId: string
|
||||
seriesId: string,
|
||||
): Promise<ActionResult<InstallmentAnticipationWithRelations[]>> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -297,7 +298,8 @@ export async function getInstallmentAnticipationsAction(
|
||||
seriesId: installmentAnticipations.seriesId,
|
||||
anticipationPeriod: installmentAnticipations.anticipationPeriod,
|
||||
anticipationDate: installmentAnticipations.anticipationDate,
|
||||
anticipatedInstallmentIds: installmentAnticipations.anticipatedInstallmentIds,
|
||||
anticipatedInstallmentIds:
|
||||
installmentAnticipations.anticipatedInstallmentIds,
|
||||
totalAmount: installmentAnticipations.totalAmount,
|
||||
installmentCount: installmentAnticipations.installmentCount,
|
||||
discount: installmentAnticipations.discount,
|
||||
@@ -313,14 +315,20 @@ export async function getInstallmentAnticipationsAction(
|
||||
categoria: categorias,
|
||||
})
|
||||
.from(installmentAnticipations)
|
||||
.leftJoin(lancamentos, eq(installmentAnticipations.lancamentoId, lancamentos.id))
|
||||
.leftJoin(
|
||||
lancamentos,
|
||||
eq(installmentAnticipations.lancamentoId, lancamentos.id),
|
||||
)
|
||||
.leftJoin(pagadores, eq(installmentAnticipations.pagadorId, pagadores.id))
|
||||
.leftJoin(categorias, eq(installmentAnticipations.categoriaId, categorias.id))
|
||||
.leftJoin(
|
||||
categorias,
|
||||
eq(installmentAnticipations.categoriaId, categorias.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(installmentAnticipations.seriesId, validatedSeriesId),
|
||||
eq(installmentAnticipations.userId, user.id)
|
||||
)
|
||||
eq(installmentAnticipations.userId, user.id),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(installmentAnticipations.createdAt));
|
||||
|
||||
@@ -338,7 +346,7 @@ export async function getInstallmentAnticipationsAction(
|
||||
* Remove o lançamento de antecipação e restaura as parcelas originais
|
||||
*/
|
||||
export async function cancelInstallmentAnticipationAction(
|
||||
input: CancelAnticipationInput
|
||||
input: CancelAnticipationInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -352,7 +360,8 @@ export async function cancelInstallmentAnticipationAction(
|
||||
seriesId: installmentAnticipations.seriesId,
|
||||
anticipationPeriod: installmentAnticipations.anticipationPeriod,
|
||||
anticipationDate: installmentAnticipations.anticipationDate,
|
||||
anticipatedInstallmentIds: installmentAnticipations.anticipatedInstallmentIds,
|
||||
anticipatedInstallmentIds:
|
||||
installmentAnticipations.anticipatedInstallmentIds,
|
||||
totalAmount: installmentAnticipations.totalAmount,
|
||||
installmentCount: installmentAnticipations.installmentCount,
|
||||
discount: installmentAnticipations.discount,
|
||||
@@ -365,12 +374,15 @@ export async function cancelInstallmentAnticipationAction(
|
||||
lancamento: lancamentos,
|
||||
})
|
||||
.from(installmentAnticipations)
|
||||
.leftJoin(lancamentos, eq(installmentAnticipations.lancamentoId, lancamentos.id))
|
||||
.leftJoin(
|
||||
lancamentos,
|
||||
eq(installmentAnticipations.lancamentoId, lancamentos.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(installmentAnticipations.id, data.anticipationId),
|
||||
eq(installmentAnticipations.userId, user.id)
|
||||
)
|
||||
eq(installmentAnticipations.userId, user.id),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
@@ -383,7 +395,7 @@ export async function cancelInstallmentAnticipationAction(
|
||||
// 2. Verificar se o lançamento já foi pago
|
||||
if (anticipation.lancamento?.isSettled === true) {
|
||||
throw new Error(
|
||||
"Não é possível cancelar uma antecipação já paga. Remova o pagamento primeiro."
|
||||
"Não é possível cancelar uma antecipação já paga. Remova o pagamento primeiro.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -403,8 +415,8 @@ export async function cancelInstallmentAnticipationAction(
|
||||
.where(
|
||||
inArray(
|
||||
lancamentos.id,
|
||||
anticipation.anticipatedInstallmentIds as string[]
|
||||
)
|
||||
anticipation.anticipatedInstallmentIds as string[],
|
||||
),
|
||||
);
|
||||
|
||||
// 5. Deletar lançamento de antecipação
|
||||
@@ -434,7 +446,7 @@ export async function cancelInstallmentAnticipationAction(
|
||||
* Busca detalhes de uma antecipação específica
|
||||
*/
|
||||
export async function getAnticipationDetailsAction(
|
||||
anticipationId: string
|
||||
anticipationId: string,
|
||||
): Promise<ActionResult<InstallmentAnticipationWithRelations>> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -445,7 +457,7 @@ export async function getAnticipationDetailsAction(
|
||||
const anticipation = await db.query.installmentAnticipations.findFirst({
|
||||
where: and(
|
||||
eq(installmentAnticipations.id, validatedId),
|
||||
eq(installmentAnticipations.userId, user.id)
|
||||
eq(installmentAnticipations.userId, user.id),
|
||||
),
|
||||
with: {
|
||||
lancamento: true,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { lancamentos, contas, pagadores, cartoes, categorias } from "@/db/schema";
|
||||
import { and, desc, eq, isNull, ne, or, type SQL } from "drizzle-orm";
|
||||
import {
|
||||
cartoes,
|
||||
categorias,
|
||||
contas,
|
||||
lancamentos,
|
||||
pagadores,
|
||||
} from "@/db/schema";
|
||||
import { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { and, desc, eq, isNull, ne, or, type SQL } from "drizzle-orm";
|
||||
|
||||
export async function fetchLancamentos(filters: SQL[]) {
|
||||
const lancamentoRows = await db
|
||||
@@ -24,9 +30,9 @@ export async function fetchLancamentos(filters: SQL[]) {
|
||||
or(
|
||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
||||
isNull(contas.excludeInitialBalanceFromIncome),
|
||||
eq(contas.excludeInitialBalanceFromIncome, false)
|
||||
)
|
||||
)
|
||||
eq(contas.excludeInitialBalanceFromIncome, false),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt));
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiArrowLeftRightLine } from "@remixicon/react";
|
||||
import PageDescription from "@/components/page-description";
|
||||
|
||||
export const metadata = {
|
||||
title: "Lançamentos | Opensheets",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import MonthNavigation from "@/components/month-picker/month-navigation";
|
||||
import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page";
|
||||
import MonthNavigation from "@/components/month-picker/month-navigation";
|
||||
import { getUserId } from "@/lib/auth/server";
|
||||
import {
|
||||
buildLancamentoWhere,
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
type ResolvedSearchParams,
|
||||
} from "@/lib/lancamentos/page-helpers";
|
||||
import { parsePeriodParam } from "@/lib/utils/period";
|
||||
import { fetchLancamentos } from "./data";
|
||||
import { getRecentEstablishmentsAction } from "./actions";
|
||||
import { fetchLancamentos } from "./data";
|
||||
|
||||
type PageSearchParams = Promise<ResolvedSearchParams>;
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { categorias, orcamentos } from "@/db/schema";
|
||||
import {
|
||||
type ActionResult,
|
||||
handleActionError,
|
||||
revalidateForEntity,
|
||||
} from "@/lib/actions/helpers";
|
||||
import { db } from "@/lib/db";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { periodSchema, uuidSchema } from "@/lib/schemas/common";
|
||||
import {
|
||||
formatDecimalForDbRequired,
|
||||
normalizeDecimalInput,
|
||||
} from "@/lib/utils/currency";
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
const budgetBaseSchema = z.object({
|
||||
categoriaId: uuidSchema("Categoria"),
|
||||
@@ -26,12 +26,12 @@ const budgetBaseSchema = z.object({
|
||||
.transform((value) => normalizeDecimalInput(value))
|
||||
.refine(
|
||||
(value) => !Number.isNaN(Number.parseFloat(value)),
|
||||
"Informe um valor limite válido."
|
||||
"Informe um valor limite válido.",
|
||||
)
|
||||
.transform((value) => Number.parseFloat(value))
|
||||
.refine(
|
||||
(value) => value >= 0,
|
||||
"O valor limite deve ser maior ou igual a zero."
|
||||
"O valor limite deve ser maior ou igual a zero.",
|
||||
),
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ const ensureCategory = async (userId: string, categoriaId: string) => {
|
||||
};
|
||||
|
||||
export async function createBudgetAction(
|
||||
input: BudgetCreateInput
|
||||
input: BudgetCreateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -109,7 +109,7 @@ export async function createBudgetAction(
|
||||
}
|
||||
|
||||
export async function updateBudgetAction(
|
||||
input: BudgetUpdateInput
|
||||
input: BudgetUpdateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -163,7 +163,7 @@ export async function updateBudgetAction(
|
||||
}
|
||||
|
||||
export async function deleteBudgetAction(
|
||||
input: BudgetDeleteInput
|
||||
input: BudgetDeleteInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -193,12 +193,10 @@ const duplicatePreviousMonthSchema = z.object({
|
||||
period: periodSchema,
|
||||
});
|
||||
|
||||
type DuplicatePreviousMonthInput = z.infer<
|
||||
typeof duplicatePreviousMonthSchema
|
||||
>;
|
||||
type DuplicatePreviousMonthInput = z.infer<typeof duplicatePreviousMonthSchema>;
|
||||
|
||||
export async function duplicatePreviousMonthBudgetsAction(
|
||||
input: DuplicatePreviousMonthInput
|
||||
input: DuplicatePreviousMonthInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -218,7 +216,7 @@ export async function duplicatePreviousMonthBudgetsAction(
|
||||
const previousBudgets = await db.query.orcamentos.findMany({
|
||||
where: and(
|
||||
eq(orcamentos.userId, user.id),
|
||||
eq(orcamentos.period, previousPeriod)
|
||||
eq(orcamentos.period, previousPeriod),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -233,17 +231,17 @@ export async function duplicatePreviousMonthBudgetsAction(
|
||||
const currentBudgets = await db.query.orcamentos.findMany({
|
||||
where: and(
|
||||
eq(orcamentos.userId, user.id),
|
||||
eq(orcamentos.period, data.period)
|
||||
eq(orcamentos.period, data.period),
|
||||
),
|
||||
});
|
||||
|
||||
// Filtrar para evitar duplicatas
|
||||
const existingCategoryIds = new Set(
|
||||
currentBudgets.map((b) => b.categoriaId)
|
||||
currentBudgets.map((b) => b.categoriaId),
|
||||
);
|
||||
|
||||
const budgetsToCopy = previousBudgets.filter(
|
||||
(b) => b.categoriaId && !existingCategoryIds.has(b.categoriaId)
|
||||
(b) => b.categoriaId && !existingCategoryIds.has(b.categoriaId),
|
||||
);
|
||||
|
||||
if (budgetsToCopy.length === 0) {
|
||||
@@ -261,7 +259,7 @@ export async function duplicatePreviousMonthBudgetsAction(
|
||||
period: data.period,
|
||||
userId: user.id,
|
||||
categoriaId: b.categoriaId!,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
revalidateForEntity("orcamentos");
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { and, asc, eq, inArray, sum } from "drizzle-orm";
|
||||
import {
|
||||
categorias,
|
||||
lancamentos,
|
||||
orcamentos,
|
||||
type Orcamento,
|
||||
orcamentos,
|
||||
} from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
import { and, asc, eq, inArray, sum } from "drizzle-orm";
|
||||
|
||||
const toNumber = (value: string | number | null | undefined) => {
|
||||
if (typeof value === "number") return value;
|
||||
@@ -37,7 +37,7 @@ export type CategoryOption = {
|
||||
|
||||
export async function fetchBudgetsForUser(
|
||||
userId: string,
|
||||
selectedPeriod: string
|
||||
selectedPeriod: string,
|
||||
): Promise<{
|
||||
budgets: BudgetData[];
|
||||
categoriesOptions: CategoryOption[];
|
||||
@@ -46,7 +46,7 @@ export async function fetchBudgetsForUser(
|
||||
db.query.orcamentos.findMany({
|
||||
where: and(
|
||||
eq(orcamentos.userId, userId),
|
||||
eq(orcamentos.period, selectedPeriod)
|
||||
eq(orcamentos.period, selectedPeriod),
|
||||
),
|
||||
with: {
|
||||
categoria: true,
|
||||
@@ -81,16 +81,18 @@ export async function fetchBudgetsForUser(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, selectedPeriod),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
inArray(lancamentos.categoriaId, categoryIds)
|
||||
)
|
||||
inArray(lancamentos.categoriaId, categoryIds),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.categoriaId);
|
||||
|
||||
totalsByCategory = new Map(
|
||||
totals.map((row: { categoriaId: string | null; totalAmount: string | null }) => [
|
||||
totals.map(
|
||||
(row: { categoriaId: string | null; totalAmount: string | null }) => [
|
||||
row.categoriaId ?? "",
|
||||
Math.abs(toNumber(row.totalAmount)),
|
||||
])
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,7 +114,7 @@ export async function fetchBudgetsForUser(
|
||||
.sort((a, b) =>
|
||||
(a.category?.name ?? "").localeCompare(b.category?.name ?? "", "pt-BR", {
|
||||
sensitivity: "base",
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
const categoriesOptions = categoryRows.map((category) => ({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiFundsLine } from "@remixicon/react";
|
||||
import PageDescription from "@/components/page-description";
|
||||
|
||||
export const metadata = {
|
||||
title: "Anotações | Opensheets",
|
||||
|
||||
@@ -23,10 +23,7 @@ export default function OrcamentosLoading() {
|
||||
{/* Grid de cards de orçamentos */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-2xl border p-6 space-y-4"
|
||||
>
|
||||
<div key={i} className="rounded-2xl border p-6 space-y-4">
|
||||
{/* Categoria com ícone */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-2xl bg-foreground/10" />
|
||||
|
||||
@@ -12,11 +12,11 @@ type PageProps = {
|
||||
|
||||
const getSingleParam = (
|
||||
params: Record<string, string | string[] | undefined> | undefined,
|
||||
key: string
|
||||
key: string,
|
||||
) => {
|
||||
const value = params?.[key];
|
||||
if (!value) return null;
|
||||
return Array.isArray(value) ? value[0] ?? null : value;
|
||||
return Array.isArray(value) ? (value[0] ?? null) : value;
|
||||
};
|
||||
|
||||
const capitalize = (value: string) =>
|
||||
@@ -35,7 +35,10 @@ export default async function Page({ searchParams }: PageProps) {
|
||||
|
||||
const periodLabel = `${capitalize(rawMonthName)} ${year}`;
|
||||
|
||||
const { budgets, categoriesOptions } = await fetchBudgetsForUser(userId, selectedPeriod);
|
||||
const { budgets, categoriesOptions } = await fetchBudgetsForUser(
|
||||
userId,
|
||||
selectedPeriod,
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"use server";
|
||||
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { Resend } from "resend";
|
||||
import { z } from "zod";
|
||||
import { lancamentos, pagadores } from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
fetchPagadorBoletoStats,
|
||||
fetchPagadorCardUsage,
|
||||
@@ -10,10 +14,6 @@ import {
|
||||
fetchPagadorMonthlyBreakdown,
|
||||
} from "@/lib/pagadores/details";
|
||||
import { displayPeriod } from "@/lib/utils/period";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { Resend } from "resend";
|
||||
import { z } from "zod";
|
||||
|
||||
const inputSchema = z.object({
|
||||
pagadorId: z.string().uuid("Pagador inválido."),
|
||||
@@ -122,7 +122,7 @@ const buildSummaryHtml = ({
|
||||
return `
|
||||
<tr>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;font-weight:500;">${escapeHtml(
|
||||
point.label
|
||||
point.label,
|
||||
)}</td>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
@@ -130,7 +130,7 @@ const buildSummaryHtml = ({
|
||||
<div style="background:${barColor};height:100%;width:${percentage}%;transition:width 0.3s;"></div>
|
||||
</div>
|
||||
<span style="font-weight:600;min-width:100px;text-align:right;">${formatCurrency(
|
||||
point.despesas
|
||||
point.despesas,
|
||||
)}</span>
|
||||
</div>
|
||||
</td>
|
||||
@@ -146,12 +146,12 @@ const buildSummaryHtml = ({
|
||||
(item) => `
|
||||
<tr>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;font-weight:500;">${escapeHtml(
|
||||
item.name
|
||||
item.name,
|
||||
)}</td>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:600;">${formatCurrency(
|
||||
item.amount
|
||||
item.amount,
|
||||
)}</td>
|
||||
</tr>`
|
||||
</tr>`,
|
||||
)
|
||||
.join("")
|
||||
: `<tr><td colspan="2" style="padding:16px;text-align:center;color:#94a3b8;">Sem gastos com cartão neste período.</td></tr>`;
|
||||
@@ -163,15 +163,15 @@ const buildSummaryHtml = ({
|
||||
(item) => `
|
||||
<tr>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;font-weight:500;">${escapeHtml(
|
||||
item.name
|
||||
item.name,
|
||||
)}</td>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${
|
||||
item.dueDate ? formatDate(item.dueDate) : "—"
|
||||
}</td>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:600;">${formatCurrency(
|
||||
item.amount
|
||||
item.amount,
|
||||
)}</td>
|
||||
</tr>`
|
||||
</tr>`,
|
||||
)
|
||||
.join("")
|
||||
: `<tr><td colspan="3" style="padding:16px;text-align:center;color:#94a3b8;">Sem boletos neste período.</td></tr>`;
|
||||
@@ -183,7 +183,7 @@ const buildSummaryHtml = ({
|
||||
(item) => `
|
||||
<tr>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;white-space:nowrap;">${formatDate(
|
||||
item.purchaseDate
|
||||
item.purchaseDate,
|
||||
)}</td>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${
|
||||
escapeHtml(item.name) || "Sem descrição"
|
||||
@@ -195,9 +195,9 @@ const buildSummaryHtml = ({
|
||||
escapeHtml(item.paymentMethod) || "—"
|
||||
}</td>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:600;">${formatCurrency(
|
||||
item.amount
|
||||
item.amount,
|
||||
)}</td>
|
||||
</tr>`
|
||||
</tr>`,
|
||||
)
|
||||
.join("")
|
||||
: `<tr><td colspan="5" style="padding:16px;text-align:center;color:#94a3b8;">Nenhum lançamento registrado no período.</td></tr>`;
|
||||
@@ -209,7 +209,7 @@ const buildSummaryHtml = ({
|
||||
(item) => `
|
||||
<tr>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;white-space:nowrap;">${formatDate(
|
||||
item.purchaseDate
|
||||
item.purchaseDate,
|
||||
)}</td>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${
|
||||
escapeHtml(item.name) || "Sem descrição"
|
||||
@@ -218,12 +218,12 @@ const buildSummaryHtml = ({
|
||||
item.currentInstallment
|
||||
}/${item.installmentCount}</td>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:600;">${formatCurrency(
|
||||
item.installmentAmount
|
||||
item.installmentAmount,
|
||||
)}</td>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;color:#64748b;">${formatCurrency(
|
||||
item.totalAmount
|
||||
item.totalAmount,
|
||||
)}</td>
|
||||
</tr>`
|
||||
</tr>`,
|
||||
)
|
||||
.join("")
|
||||
: `<tr><td colspan="5" style="padding:16px;text-align:center;color:#94a3b8;">Nenhum lançamento parcelado neste período.</td></tr>`;
|
||||
@@ -237,7 +237,7 @@ const buildSummaryHtml = ({
|
||||
<div style="background:linear-gradient(90deg,#dc5a3a,#ea744e);padding:28px 24px;border-radius:12px 12px 0 0;">
|
||||
<h1 style="margin:0 0 6px 0;font-size:26px;font-weight:800;letter-spacing:-0.3px;color:#ffffff;">Resumo Financeiro</h1>
|
||||
<p style="margin:0;font-size:15px;color:#ffece6;">${escapeHtml(
|
||||
periodLabel
|
||||
periodLabel,
|
||||
)}</p>
|
||||
</div>
|
||||
|
||||
@@ -246,7 +246,7 @@ const buildSummaryHtml = ({
|
||||
<!-- Saudação -->
|
||||
<p style="margin:0 0 24px 0;font-size:15px;color:#334155;">
|
||||
Olá <strong>${escapeHtml(
|
||||
pagadorName
|
||||
pagadorName,
|
||||
)}</strong>, segue o consolidado do mês:
|
||||
</p>
|
||||
|
||||
@@ -258,26 +258,26 @@ const buildSummaryHtml = ({
|
||||
<td style="padding:16px 18px;background:#fff7f5;border-bottom:1px solid #f1f5f9;font-size:15px;color:#475569;">Total gasto</td>
|
||||
<td style="padding:16px 18px;background:#fff7f5;border-bottom:1px solid #f1f5f9;text-align:right;">
|
||||
<strong style="font-size:22px;color:#0f172a;">${formatCurrency(
|
||||
monthlyBreakdown.totalExpenses
|
||||
monthlyBreakdown.totalExpenses,
|
||||
)}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:12px 18px;font-size:14px;color:#64748b;">💳 Cartões</td>
|
||||
<td style="padding:12px 18px;text-align:right;"><strong style="font-size:15px;color:#0f172a;">${formatCurrency(
|
||||
monthlyBreakdown.paymentSplits.card
|
||||
monthlyBreakdown.paymentSplits.card,
|
||||
)}</strong></td>
|
||||
</tr>
|
||||
<tr style="background:#fcfcfd;">
|
||||
<td style="padding:12px 18px;font-size:14px;color:#64748b;">📄 Boletos</td>
|
||||
<td style="padding:12px 18px;text-align:right;"><strong style="font-size:15px;color:#0f172a;">${formatCurrency(
|
||||
monthlyBreakdown.paymentSplits.boleto
|
||||
monthlyBreakdown.paymentSplits.boleto,
|
||||
)}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:12px 18px;font-size:14px;color:#64748b;">⚡ Pix/Débito/Dinheiro</td>
|
||||
<td style="padding:12px 18px;text-align:right;"><strong style="font-size:15px;color:#0f172a;">${formatCurrency(
|
||||
monthlyBreakdown.paymentSplits.instant
|
||||
monthlyBreakdown.paymentSplits.instant,
|
||||
)}</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -305,7 +305,7 @@ const buildSummaryHtml = ({
|
||||
<td style="color:#475569;font-weight:700;font-size:15px;">Total</td>
|
||||
<td style="text-align:right;">
|
||||
<strong style="font-size:18px;color:#0f172a;">${formatCurrency(
|
||||
monthlyBreakdown.paymentSplits.card
|
||||
monthlyBreakdown.paymentSplits.card,
|
||||
)}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -333,7 +333,7 @@ const buildSummaryHtml = ({
|
||||
<td style="color:#475569;font-weight:700;font-size:15px;">Total</td>
|
||||
<td style="text-align:right;">
|
||||
<strong style="font-size:18px;color:#0f172a;">${formatCurrency(
|
||||
boletoStats.totalAmount
|
||||
boletoStats.totalAmount,
|
||||
)}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -396,7 +396,7 @@ const buildSummaryHtml = ({
|
||||
};
|
||||
|
||||
export async function sendPagadorSummaryAction(
|
||||
input: z.infer<typeof inputSchema>
|
||||
input: z.infer<typeof inputSchema>,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const { pagadorId, period } = inputSchema.parse(input);
|
||||
@@ -471,8 +471,8 @@ export async function sendPagadorSummaryAction(
|
||||
eq(lancamentos.userId, user.id),
|
||||
eq(lancamentos.pagadorId, pagadorId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.paymentMethod, "Boleto")
|
||||
)
|
||||
eq(lancamentos.paymentMethod, "Boleto"),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(lancamentos.dueDate)),
|
||||
db
|
||||
@@ -490,8 +490,8 @@ export async function sendPagadorSummaryAction(
|
||||
and(
|
||||
eq(lancamentos.userId, user.id),
|
||||
eq(lancamentos.pagadorId, pagadorId),
|
||||
eq(lancamentos.period, period)
|
||||
)
|
||||
eq(lancamentos.period, period),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(lancamentos.purchaseDate)),
|
||||
db
|
||||
@@ -509,8 +509,8 @@ export async function sendPagadorSummaryAction(
|
||||
eq(lancamentos.pagadorId, pagadorId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.condition, "Parcelado"),
|
||||
eq(lancamentos.isAnticipated, false)
|
||||
)
|
||||
eq(lancamentos.isAnticipated, false),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(lancamentos.purchaseDate)),
|
||||
]);
|
||||
@@ -530,7 +530,7 @@ export async function sendPagadorSummaryAction(
|
||||
transactionType: row.transactionType,
|
||||
purchaseDate: row.purchaseDate,
|
||||
amount: Number(row.amount ?? 0),
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
const normalizedParcelados: ParceladoItem[] = parceladoRows.map((row) => {
|
||||
@@ -573,7 +573,7 @@ export async function sendPagadorSummaryAction(
|
||||
.update(pagadores)
|
||||
.set({ lastMailAt: now })
|
||||
.where(
|
||||
and(eq(pagadores.id, pagadorRow.id), eq(pagadores.userId, user.id))
|
||||
and(eq(pagadores.id, pagadorRow.id), eq(pagadores.userId, user.id)),
|
||||
);
|
||||
|
||||
revalidatePath(`/pagadores/${pagadorRow.id}`);
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { lancamentos, pagadorShares, user as usersTable, contas, cartoes, categorias, pagadores } from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
import { and, desc, eq, type SQL } from "drizzle-orm";
|
||||
import {
|
||||
cartoes,
|
||||
categorias,
|
||||
contas,
|
||||
lancamentos,
|
||||
pagadores,
|
||||
pagadorShares,
|
||||
user as usersTable,
|
||||
} from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export type ShareData = {
|
||||
id: string;
|
||||
@@ -11,7 +19,7 @@ export type ShareData = {
|
||||
};
|
||||
|
||||
export async function fetchPagadorShares(
|
||||
pagadorId: string
|
||||
pagadorId: string,
|
||||
): Promise<ShareData[]> {
|
||||
const shareRows = await db
|
||||
.select({
|
||||
@@ -22,10 +30,7 @@ export async function fetchPagadorShares(
|
||||
userEmail: usersTable.email,
|
||||
})
|
||||
.from(pagadorShares)
|
||||
.innerJoin(
|
||||
usersTable,
|
||||
eq(pagadorShares.sharedWithUserId, usersTable.id)
|
||||
)
|
||||
.innerJoin(usersTable, eq(pagadorShares.sharedWithUserId, usersTable.id))
|
||||
.where(eq(pagadorShares.pagadorId, pagadorId));
|
||||
|
||||
return shareRows.map((share) => ({
|
||||
@@ -39,7 +44,7 @@ export async function fetchPagadorShares(
|
||||
|
||||
export async function fetchCurrentUserShare(
|
||||
pagadorId: string,
|
||||
userId: string
|
||||
userId: string,
|
||||
): Promise<{ id: string; createdAt: string } | null> {
|
||||
const shareRow = await db.query.pagadorShares.findFirst({
|
||||
columns: {
|
||||
@@ -48,7 +53,7 @@ export async function fetchCurrentUserShare(
|
||||
},
|
||||
where: and(
|
||||
eq(pagadorShares.pagadorId, pagadorId),
|
||||
eq(pagadorShares.sharedWithUserId, userId)
|
||||
eq(pagadorShares.sharedWithUserId, userId),
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
||||
import { PagadorCardUsageCard } from "@/components/pagadores/details/pagador-card-usage-card";
|
||||
import { PagadorHistoryCard } from "@/components/pagadores/details/pagador-history-card";
|
||||
import { PagadorInfoCard } from "@/components/pagadores/details/pagador-info-card";
|
||||
import { PagadorMonthlySummaryCard } from "@/components/pagadores/details/pagador-monthly-summary-card";
|
||||
import { PagadorBoletoCard } from "@/components/pagadores/details/pagador-payment-method-cards";
|
||||
import { PagadorSharingCard } from "@/components/pagadores/details/pagador-sharing-card";
|
||||
import { PagadorLeaveShareCard } from "@/components/pagadores/details/pagador-leave-share-card";
|
||||
import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
|
||||
import type {
|
||||
ContaCartaoFilterOption,
|
||||
@@ -14,8 +8,15 @@ import type {
|
||||
SelectOption,
|
||||
} from "@/components/lancamentos/types";
|
||||
import MonthNavigation from "@/components/month-picker/month-navigation";
|
||||
import { PagadorCardUsageCard } from "@/components/pagadores/details/pagador-card-usage-card";
|
||||
import { PagadorHistoryCard } from "@/components/pagadores/details/pagador-history-card";
|
||||
import { PagadorInfoCard } from "@/components/pagadores/details/pagador-info-card";
|
||||
import { PagadorLeaveShareCard } from "@/components/pagadores/details/pagador-leave-share-card";
|
||||
import { PagadorMonthlySummaryCard } from "@/components/pagadores/details/pagador-monthly-summary-card";
|
||||
import { PagadorBoletoCard } from "@/components/pagadores/details/pagador-payment-method-cards";
|
||||
import { PagadorSharingCard } from "@/components/pagadores/details/pagador-sharing-card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { pagadores } from "@/db/schema";
|
||||
import type { pagadores } from "@/db/schema";
|
||||
import { getUserId } from "@/lib/auth/server";
|
||||
import {
|
||||
buildLancamentoWhere,
|
||||
@@ -25,22 +26,25 @@ import {
|
||||
extractLancamentoSearchFilters,
|
||||
fetchLancamentoFilterSources,
|
||||
getSingleParam,
|
||||
mapLancamentosData,
|
||||
type LancamentoSearchFilters,
|
||||
mapLancamentosData,
|
||||
type ResolvedSearchParams,
|
||||
type SlugMaps,
|
||||
type SluggedFilters,
|
||||
type SlugMaps,
|
||||
} from "@/lib/lancamentos/page-helpers";
|
||||
import { getPagadorAccess } from "@/lib/pagadores/access";
|
||||
import { parsePeriodParam } from "@/lib/utils/period";
|
||||
import {
|
||||
fetchPagadorBoletoStats,
|
||||
fetchPagadorCardUsage,
|
||||
fetchPagadorHistory,
|
||||
fetchPagadorMonthlyBreakdown,
|
||||
} from "@/lib/pagadores/details";
|
||||
import { notFound } from "next/navigation";
|
||||
import { fetchPagadorLancamentos, fetchPagadorShares, fetchCurrentUserShare } from "./data";
|
||||
import { parsePeriodParam } from "@/lib/utils/period";
|
||||
import {
|
||||
fetchCurrentUserShare,
|
||||
fetchPagadorLancamentos,
|
||||
fetchPagadorShares,
|
||||
} from "./data";
|
||||
|
||||
type PageSearchParams = Promise<ResolvedSearchParams>;
|
||||
|
||||
@@ -212,7 +216,9 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
|
||||
// Construir opções do usuário logado para usar ao importar
|
||||
if (loggedUserFilterSources) {
|
||||
const loggedUserSluggedFilters = buildSluggedFilters(loggedUserFilterSources);
|
||||
const loggedUserSluggedFilters = buildSluggedFilters(
|
||||
loggedUserFilterSources,
|
||||
);
|
||||
loggedUserOptionSets = buildOptionSets({
|
||||
...loggedUserSluggedFilters,
|
||||
pagadorRows: loggedUserFilterSources.pagadorRows,
|
||||
@@ -222,12 +228,12 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
|
||||
const pagadorSlug =
|
||||
effectiveSluggedFilters.pagadorFiltersRaw.find(
|
||||
(item) => item.id === pagador.id
|
||||
(item) => item.id === pagador.id,
|
||||
)?.slug ?? null;
|
||||
|
||||
const pagadorFilterOptions = pagadorSlug
|
||||
? optionSets.pagadorFilterOptions.filter(
|
||||
(option) => option.slug === pagadorSlug
|
||||
(option) => option.slug === pagadorSlug,
|
||||
)
|
||||
: optionSets.pagadorFilterOptions;
|
||||
|
||||
@@ -334,7 +340,9 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
estabelecimentos={estabelecimentos}
|
||||
allowCreate={canEdit}
|
||||
importPagadorOptions={loggedUserOptionSets?.pagadorOptions}
|
||||
importSplitPagadorOptions={loggedUserOptionSets?.splitPagadorOptions}
|
||||
importSplitPagadorOptions={
|
||||
loggedUserOptionSets?.splitPagadorOptions
|
||||
}
|
||||
importDefaultPagadorId={loggedUserOptionSets?.defaultPagadorId}
|
||||
importContaOptions={loggedUserOptionSets?.contaOptions}
|
||||
importCartaoOptions={loggedUserOptionSets?.cartaoOptions}
|
||||
@@ -349,12 +357,12 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
|
||||
const normalizeOptionLabel = (
|
||||
value: string | null | undefined,
|
||||
fallback: string
|
||||
fallback: string,
|
||||
) => (value?.trim().length ? value.trim() : fallback);
|
||||
|
||||
function buildReadOnlyOptionSets(
|
||||
items: LancamentoItem[],
|
||||
pagador: typeof pagadores.$inferSelect
|
||||
pagador: typeof pagadores.$inferSelect,
|
||||
): OptionSet {
|
||||
const pagadorLabel = normalizeOptionLabel(pagador.name, "Pagador");
|
||||
const pagadorOptions: SelectOption[] = [
|
||||
@@ -405,7 +413,7 @@ function buildReadOnlyOptionSets(
|
||||
(option) => ({
|
||||
slug: option.value,
|
||||
label: option.label,
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
const contaCartaoFilterOptions: ContaCartaoFilterOption[] = [
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"use server";
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { pagadores, pagadorShares, user } from "@/db/schema";
|
||||
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
|
||||
import type { ActionResult } from "@/lib/actions/types";
|
||||
import { db } from "@/lib/db";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
DEFAULT_PAGADOR_AVATAR,
|
||||
PAGADOR_ROLE_ADMIN,
|
||||
@@ -14,10 +18,6 @@ import {
|
||||
import { normalizeAvatarPath } from "@/lib/pagadores/utils";
|
||||
import { noteSchema, uuidSchema } from "@/lib/schemas/common";
|
||||
import { normalizeOptionalString } from "@/lib/utils/string";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
|
||||
const statusEnum = z.enum(PAGADOR_STATUS_OPTIONS as [string, ...string[]], {
|
||||
errorMap: () => ({
|
||||
@@ -83,7 +83,7 @@ const generateShareCode = () => {
|
||||
};
|
||||
|
||||
export async function createPagadorAction(
|
||||
input: CreateInput
|
||||
input: CreateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -110,14 +110,17 @@ export async function createPagadorAction(
|
||||
}
|
||||
|
||||
export async function updatePagadorAction(
|
||||
input: UpdateInput
|
||||
input: UpdateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const currentUser = await getUser();
|
||||
const data = updateSchema.parse(input);
|
||||
|
||||
const existing = await db.query.pagadores.findFirst({
|
||||
where: and(eq(pagadores.id, data.id), eq(pagadores.userId, currentUser.id)),
|
||||
where: and(
|
||||
eq(pagadores.id, data.id),
|
||||
eq(pagadores.userId, currentUser.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
@@ -139,7 +142,9 @@ export async function updatePagadorAction(
|
||||
isAutoSend: data.isAutoSend ?? false,
|
||||
role: existing.role ?? PAGADOR_ROLE_TERCEIRO,
|
||||
})
|
||||
.where(and(eq(pagadores.id, data.id), eq(pagadores.userId, currentUser.id)));
|
||||
.where(
|
||||
and(eq(pagadores.id, data.id), eq(pagadores.userId, currentUser.id)),
|
||||
);
|
||||
|
||||
// Se o pagador é admin, sincronizar nome com o usuário
|
||||
if (existing.role === PAGADOR_ROLE_ADMIN) {
|
||||
@@ -160,7 +165,7 @@ export async function updatePagadorAction(
|
||||
}
|
||||
|
||||
export async function deletePagadorAction(
|
||||
input: DeleteInput
|
||||
input: DeleteInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -197,7 +202,7 @@ export async function deletePagadorAction(
|
||||
}
|
||||
|
||||
export async function joinPagadorByShareCodeAction(
|
||||
input: ShareCodeJoinInput
|
||||
input: ShareCodeJoinInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -221,7 +226,7 @@ export async function joinPagadorByShareCodeAction(
|
||||
const existingShare = await db.query.pagadorShares.findFirst({
|
||||
where: and(
|
||||
eq(pagadorShares.pagadorId, pagadorRow.id),
|
||||
eq(pagadorShares.sharedWithUserId, user.id)
|
||||
eq(pagadorShares.sharedWithUserId, user.id),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -248,7 +253,7 @@ export async function joinPagadorByShareCodeAction(
|
||||
}
|
||||
|
||||
export async function deletePagadorShareAction(
|
||||
input: ShareDeleteInput
|
||||
input: ShareDeleteInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -271,16 +276,18 @@ export async function deletePagadorShareAction(
|
||||
});
|
||||
|
||||
// Permitir que o owner OU o próprio usuário compartilhado remova o share
|
||||
if (!existing || (existing.pagador.userId !== user.id && existing.sharedWithUserId !== user.id)) {
|
||||
if (
|
||||
!existing ||
|
||||
(existing.pagador.userId !== user.id &&
|
||||
existing.sharedWithUserId !== user.id)
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Compartilhamento não encontrado.",
|
||||
};
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(pagadorShares)
|
||||
.where(eq(pagadorShares.id, data.shareId));
|
||||
await db.delete(pagadorShares).where(eq(pagadorShares.id, data.shareId));
|
||||
|
||||
revalidate();
|
||||
revalidatePath(`/pagadores/${existing.pagadorId}`);
|
||||
@@ -292,7 +299,7 @@ export async function deletePagadorShareAction(
|
||||
}
|
||||
|
||||
export async function regeneratePagadorShareCodeAction(
|
||||
input: ShareCodeRegenerateInput
|
||||
input: ShareCodeRegenerateInput,
|
||||
): Promise<{ success: true; message: string; code: string } | ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -300,7 +307,10 @@ export async function regeneratePagadorShareCodeAction(
|
||||
|
||||
const existing = await db.query.pagadores.findFirst({
|
||||
columns: { id: true, userId: true },
|
||||
where: and(eq(pagadores.id, data.pagadorId), eq(pagadores.userId, user.id)),
|
||||
where: and(
|
||||
eq(pagadores.id, data.pagadorId),
|
||||
eq(pagadores.userId, user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
@@ -314,7 +324,12 @@ export async function regeneratePagadorShareCodeAction(
|
||||
await db
|
||||
.update(pagadores)
|
||||
.set({ shareCode: newCode })
|
||||
.where(and(eq(pagadores.id, data.pagadorId), eq(pagadores.userId, user.id)));
|
||||
.where(
|
||||
and(
|
||||
eq(pagadores.id, data.pagadorId),
|
||||
eq(pagadores.userId, user.id),
|
||||
),
|
||||
);
|
||||
|
||||
revalidate();
|
||||
revalidatePath(`/pagadores/${data.pagadorId}`);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiGroupLine } from "@remixicon/react";
|
||||
import PageDescription from "@/components/page-description";
|
||||
|
||||
export const metadata = {
|
||||
title: "Pagadores | Opensheets",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { PagadoresPage } from "@/components/pagadores/pagadores-page";
|
||||
import type { PagadorStatus } from "@/lib/pagadores/constants";
|
||||
import {
|
||||
PAGADOR_STATUS_OPTIONS,
|
||||
DEFAULT_PAGADOR_AVATAR,
|
||||
PAGADOR_ROLE_ADMIN,
|
||||
} from "@/lib/pagadores/constants";
|
||||
import { getUserId } from "@/lib/auth/server";
|
||||
import { fetchPagadoresWithAccess } from "@/lib/pagadores/access";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { PagadoresPage } from "@/components/pagadores/pagadores-page";
|
||||
import { getUserId } from "@/lib/auth/server";
|
||||
import { fetchPagadoresWithAccess } from "@/lib/pagadores/access";
|
||||
import type { PagadorStatus } from "@/lib/pagadores/constants";
|
||||
import {
|
||||
DEFAULT_PAGADOR_AVATAR,
|
||||
PAGADOR_ROLE_ADMIN,
|
||||
PAGADOR_STATUS_OPTIONS,
|
||||
} from "@/lib/pagadores/constants";
|
||||
|
||||
const AVATAR_DIRECTORY = path.join(process.cwd(), "public", "avatares");
|
||||
const AVATAR_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".svg", ".webp"]);
|
||||
@@ -36,7 +36,7 @@ async function loadAvatarOptions() {
|
||||
const resolveStatus = (status: string | null): PagadorStatus => {
|
||||
const normalized = status?.trim() ?? "";
|
||||
const found = PAGADOR_STATUS_OPTIONS.find(
|
||||
(option) => option.toLowerCase() === normalized.toLowerCase()
|
||||
(option) => option.toLowerCase() === normalized.toLowerCase(),
|
||||
);
|
||||
return found ?? PAGADOR_STATUS_OPTIONS[0];
|
||||
};
|
||||
@@ -64,7 +64,7 @@ export default async function Page() {
|
||||
sharedByName: pagador.sharedByName ?? null,
|
||||
sharedByEmail: pagador.sharedByEmail ?? null,
|
||||
shareId: pagador.shareId ?? null,
|
||||
shareCode: pagador.canEdit ? pagador.shareCode ?? null : null,
|
||||
shareCode: pagador.canEdit ? (pagador.shareCode ?? null) : null,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
// Admin sempre primeiro
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { inboxItems } from "@/db/schema";
|
||||
import { handleActionError } from "@/lib/actions/helpers";
|
||||
import type { ActionResult } from "@/lib/actions/types";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
const markProcessedSchema = z.object({
|
||||
inboxItemId: z.string().uuid("ID do item inválido"),
|
||||
|
||||
@@ -2,19 +2,28 @@
|
||||
* Data fetching functions for Pré-Lançamentos
|
||||
*/
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import { inboxItems, categorias, contas, cartoes, lancamentos } from "@/db/schema";
|
||||
import { eq, desc, and, gte } from "drizzle-orm";
|
||||
import type { InboxItem, SelectOption } from "@/components/pre-lancamentos/types";
|
||||
import { and, desc, eq, gte } from "drizzle-orm";
|
||||
import type {
|
||||
InboxItem,
|
||||
SelectOption,
|
||||
} from "@/components/pre-lancamentos/types";
|
||||
import {
|
||||
cartoes,
|
||||
categorias,
|
||||
contas,
|
||||
inboxItems,
|
||||
lancamentos,
|
||||
} from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
fetchLancamentoFilterSources,
|
||||
buildSluggedFilters,
|
||||
buildOptionSets,
|
||||
buildSluggedFilters,
|
||||
fetchLancamentoFilterSources,
|
||||
} from "@/lib/lancamentos/page-helpers";
|
||||
|
||||
export async function fetchInboxItems(
|
||||
userId: string,
|
||||
status: "pending" | "processed" | "discarded" = "pending"
|
||||
status: "pending" | "processed" | "discarded" = "pending",
|
||||
): Promise<InboxItem[]> {
|
||||
const items = await db
|
||||
.select()
|
||||
@@ -27,7 +36,7 @@ export async function fetchInboxItems(
|
||||
|
||||
export async function fetchInboxItemById(
|
||||
userId: string,
|
||||
itemId: string
|
||||
itemId: string,
|
||||
): Promise<InboxItem | null> {
|
||||
const [item] = await db
|
||||
.select()
|
||||
@@ -40,7 +49,7 @@ export async function fetchInboxItemById(
|
||||
|
||||
export async function fetchCategoriasForSelect(
|
||||
userId: string,
|
||||
type?: string
|
||||
type?: string,
|
||||
): Promise<SelectOption[]> {
|
||||
const query = db
|
||||
.select({ id: categorias.id, name: categorias.name })
|
||||
@@ -48,14 +57,16 @@ export async function fetchCategoriasForSelect(
|
||||
.where(
|
||||
type
|
||||
? and(eq(categorias.userId, userId), eq(categorias.type, type))
|
||||
: eq(categorias.userId, userId)
|
||||
: eq(categorias.userId, userId),
|
||||
)
|
||||
.orderBy(categorias.name);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
export async function fetchContasForSelect(userId: string): Promise<SelectOption[]> {
|
||||
export async function fetchContasForSelect(
|
||||
userId: string,
|
||||
): Promise<SelectOption[]> {
|
||||
const items = await db
|
||||
.select({ id: contas.id, name: contas.name })
|
||||
.from(contas)
|
||||
@@ -66,7 +77,7 @@ export async function fetchContasForSelect(userId: string): Promise<SelectOption
|
||||
}
|
||||
|
||||
export async function fetchCartoesForSelect(
|
||||
userId: string
|
||||
userId: string,
|
||||
): Promise<(SelectOption & { lastDigits?: string })[]> {
|
||||
const items = await db
|
||||
.select({ id: cartoes.id, name: cartoes.name })
|
||||
@@ -81,7 +92,9 @@ export async function fetchPendingInboxCount(userId: string): Promise<number> {
|
||||
const items = await db
|
||||
.select({ id: inboxItems.id })
|
||||
.from(inboxItems)
|
||||
.where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, "pending")));
|
||||
.where(
|
||||
and(eq(inboxItems.userId, userId), eq(inboxItems.status, "pending")),
|
||||
);
|
||||
|
||||
return items.length;
|
||||
}
|
||||
@@ -123,8 +136,8 @@ export async function fetchInboxDialogData(userId: string): Promise<{
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
gte(lancamentos.purchaseDate, threeMonthsAgo)
|
||||
)
|
||||
gte(lancamentos.purchaseDate, threeMonthsAgo),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(lancamentos.purchaseDate));
|
||||
|
||||
@@ -135,11 +148,11 @@ export async function fetchInboxDialogData(userId: string): Promise<{
|
||||
(name: string | null): name is string =>
|
||||
name != null &&
|
||||
name.trim().length > 0 &&
|
||||
!name.toLowerCase().startsWith("pagamento fatura")
|
||||
!name.toLowerCase().startsWith("pagamento fatura"),
|
||||
);
|
||||
const estabelecimentos = Array.from<string>(new Set(filteredNames)).slice(
|
||||
0,
|
||||
100
|
||||
100,
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiInboxLine } from "@remixicon/react";
|
||||
import PageDescription from "@/components/page-description";
|
||||
|
||||
export const metadata = {
|
||||
title: "Pré-Lançamentos | Opensheets",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiBankCard2Line } from "@remixicon/react";
|
||||
import PageDescription from "@/components/page-description";
|
||||
|
||||
export const metadata = {
|
||||
title: "Relatório de Cartões | Opensheets",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { RiBankCard2Line } from "@remixicon/react";
|
||||
import MonthNavigation from "@/components/month-picker/month-navigation";
|
||||
import { CardCategoryBreakdown } from "@/components/relatorios/cartoes/card-category-breakdown";
|
||||
import { CardInvoiceStatus } from "@/components/relatorios/cartoes/card-invoice-status";
|
||||
@@ -7,7 +8,6 @@ import { CardsOverview } from "@/components/relatorios/cartoes/cards-overview";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import { fetchCartoesReportData } from "@/lib/relatorios/cartoes-report";
|
||||
import { parsePeriodParam } from "@/lib/utils/period";
|
||||
import { RiBankCard2Line } from "@remixicon/react";
|
||||
|
||||
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
|
||||
12
app/(dashboard)/relatorios/categorias/data.ts
Normal file
12
app/(dashboard)/relatorios/categorias/data.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { type Categoria, categorias } from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function fetchUserCategories(
|
||||
userId: string,
|
||||
): Promise<Categoria[]> {
|
||||
return db.query.categorias.findMany({
|
||||
where: eq(categorias.userId, userId),
|
||||
orderBy: [asc(categorias.name)],
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiFileChartLine } from "@remixicon/react";
|
||||
import PageDescription from "@/components/page-description";
|
||||
|
||||
export const metadata = {
|
||||
title: "Relatórios | Opensheets",
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { CategoryReportPage } from "@/components/relatorios/category-report-page";
|
||||
import { getUserId } from "@/lib/auth/server";
|
||||
import { addMonthsToPeriod, getCurrentPeriod } from "@/lib/utils/period";
|
||||
import { validateDateRange } from "@/lib/relatorios/utils";
|
||||
import { fetchCategoryReport } from "@/lib/relatorios/fetch-category-report";
|
||||
import { fetchCategoryChartData } from "@/lib/relatorios/fetch-category-chart-data";
|
||||
import type { CategoryReportFilters } from "@/lib/relatorios/types";
|
||||
import type {
|
||||
CategoryOption,
|
||||
FilterState,
|
||||
} from "@/components/relatorios/types";
|
||||
import { db } from "@/lib/db";
|
||||
import { categorias, type Categoria } from "@/db/schema";
|
||||
import { eq, asc } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { Categoria } from "@/db/schema";
|
||||
import { getUserId } from "@/lib/auth/server";
|
||||
import { fetchCategoryChartData } from "@/lib/relatorios/fetch-category-chart-data";
|
||||
import { fetchCategoryReport } from "@/lib/relatorios/fetch-category-report";
|
||||
import type { CategoryReportFilters } from "@/lib/relatorios/types";
|
||||
import { validateDateRange } from "@/lib/relatorios/utils";
|
||||
import { addMonthsToPeriod, getCurrentPeriod } from "@/lib/utils/period";
|
||||
import { fetchUserCategories } from "./data";
|
||||
|
||||
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
@@ -22,11 +21,11 @@ type PageProps = {
|
||||
|
||||
const getSingleParam = (
|
||||
params: Record<string, string | string[] | undefined> | undefined,
|
||||
key: string
|
||||
key: string,
|
||||
): string | null => {
|
||||
const value = params?.[key];
|
||||
if (!value) return null;
|
||||
return Array.isArray(value) ? value[0] ?? null : value;
|
||||
return Array.isArray(value) ? (value[0] ?? null) : value;
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
@@ -59,15 +58,12 @@ export default async function Page({ searchParams }: PageProps) {
|
||||
if (!validation.isValid) {
|
||||
// Redirect to default if validation fails
|
||||
redirect(
|
||||
`/relatorios/categorias?inicio=${defaultStartPeriod}&fim=${currentPeriod}`
|
||||
`/relatorios/categorias?inicio=${defaultStartPeriod}&fim=${currentPeriod}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch all categories for the user
|
||||
const categoriaRows = await db.query.categorias.findMany({
|
||||
where: eq(categorias.userId, userId),
|
||||
orderBy: [asc(categorias.name)],
|
||||
});
|
||||
const categoriaRows = await fetchUserCategories(userId);
|
||||
|
||||
// Map to CategoryOption format
|
||||
const categoryOptions: CategoryOption[] = categoriaRows.map(
|
||||
@@ -76,7 +72,7 @@ export default async function Page({ searchParams }: PageProps) {
|
||||
name: cat.name,
|
||||
icon: cat.icon,
|
||||
type: cat.type as "despesa" | "receita",
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// Build filters for data fetching
|
||||
@@ -95,7 +91,7 @@ export default async function Page({ searchParams }: PageProps) {
|
||||
userId,
|
||||
startPeriod,
|
||||
endPeriod,
|
||||
selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined
|
||||
selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined,
|
||||
);
|
||||
|
||||
// Build initial filter state for client component
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiStore2Line } from "@remixicon/react";
|
||||
import PageDescription from "@/components/page-description";
|
||||
|
||||
export const metadata = {
|
||||
title: "Top Estabelecimentos | Opensheets",
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import { AnimatedThemeToggler } from "@/components/animated-theme-toggler";
|
||||
import { Logo } from "@/components/logo";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { getOptionalUserSession } from "@/lib/auth/server";
|
||||
import {
|
||||
RiArrowRightSLine,
|
||||
RiBankCard2Line,
|
||||
@@ -12,23 +6,29 @@ import {
|
||||
RiCodeSSlashLine,
|
||||
RiDatabase2Line,
|
||||
RiDeviceLine,
|
||||
RiDownloadCloudLine,
|
||||
RiEyeOffLine,
|
||||
RiFileTextLine,
|
||||
RiFlashlightLine,
|
||||
RiGithubFill,
|
||||
RiLineChartLine,
|
||||
RiLockLine,
|
||||
RiPercentLine,
|
||||
RiPieChartLine,
|
||||
RiRobot2Line,
|
||||
RiShieldCheckLine,
|
||||
RiTeamLine,
|
||||
RiTimeLine,
|
||||
RiWalletLine,
|
||||
RiRobot2Line,
|
||||
RiTeamLine,
|
||||
RiFileTextLine,
|
||||
RiDownloadCloudLine,
|
||||
RiEyeOffLine,
|
||||
RiFlashlightLine,
|
||||
RiPercentLine,
|
||||
} from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { AnimatedThemeToggler } from "@/components/animated-theme-toggler";
|
||||
import { Logo } from "@/components/logo";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { getOptionalUserSession } from "@/lib/auth/server";
|
||||
|
||||
export default async function Page() {
|
||||
const session = await getOptionalUserSession();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { auth } from "@/lib/auth/config";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
import { auth } from "@/lib/auth/config";
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth.handler);
|
||||
|
||||
@@ -5,11 +5,16 @@
|
||||
* Usado pelo app Android quando o access token expira.
|
||||
*/
|
||||
|
||||
import { refreshAccessToken, extractBearerToken, verifyJwt, hashToken } from "@/lib/auth/api-token";
|
||||
import { db } from "@/lib/db";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
import { eq, and, isNull } from "drizzle-orm";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
import {
|
||||
extractBearerToken,
|
||||
hashToken,
|
||||
refreshAccessToken,
|
||||
verifyJwt,
|
||||
} from "@/lib/auth/api-token";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
@@ -20,7 +25,7 @@ export async function POST(request: Request) {
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: "Refresh token não fornecido" },
|
||||
{ status: 401 }
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +35,7 @@ export async function POST(request: Request) {
|
||||
if (!payload || payload.type !== "api_refresh") {
|
||||
return NextResponse.json(
|
||||
{ error: "Refresh token inválido ou expirado" },
|
||||
{ status: 401 }
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,14 +44,14 @@ export async function POST(request: Request) {
|
||||
where: and(
|
||||
eq(apiTokens.id, payload.tokenId),
|
||||
eq(apiTokens.userId, payload.sub),
|
||||
isNull(apiTokens.revokedAt)
|
||||
isNull(apiTokens.revokedAt),
|
||||
),
|
||||
});
|
||||
|
||||
if (!tokenRecord) {
|
||||
return NextResponse.json(
|
||||
{ error: "Token revogado ou não encontrado" },
|
||||
{ status: 401 }
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,7 +61,7 @@ export async function POST(request: Request) {
|
||||
if (!result) {
|
||||
return NextResponse.json(
|
||||
{ error: "Não foi possível renovar o token" },
|
||||
{ status: 401 }
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,7 +71,9 @@ export async function POST(request: Request) {
|
||||
.set({
|
||||
tokenHash: hashToken(result.accessToken),
|
||||
lastUsedAt: new Date(),
|
||||
lastUsedIp: request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip"),
|
||||
lastUsedIp:
|
||||
request.headers.get("x-forwarded-for") ||
|
||||
request.headers.get("x-real-ip"),
|
||||
expiresAt: result.expiresAt,
|
||||
})
|
||||
.where(eq(apiTokens.id, payload.tokenId));
|
||||
@@ -79,7 +86,7 @@ export async function POST(request: Request) {
|
||||
console.error("[API] Error refreshing device token:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erro ao renovar token" },
|
||||
{ status: 500 }
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,17 @@
|
||||
* Requer sessão web autenticada.
|
||||
*/
|
||||
|
||||
import { auth } from "@/lib/auth/config";
|
||||
import { generateTokenPair, hashToken, getTokenPrefix } from "@/lib/auth/api-token";
|
||||
import { db } from "@/lib/db";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
import { headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
import {
|
||||
generateTokenPair,
|
||||
getTokenPrefix,
|
||||
hashToken,
|
||||
} from "@/lib/auth/api-token";
|
||||
import { auth } from "@/lib/auth/config";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
const createTokenSchema = z.object({
|
||||
name: z.string().min(1, "Nome é obrigatório").max(100, "Nome muito longo"),
|
||||
@@ -24,10 +28,7 @@ export async function POST(request: Request) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "Não autenticado" },
|
||||
{ status: 401 }
|
||||
);
|
||||
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Validar body
|
||||
@@ -37,7 +38,7 @@ export async function POST(request: Request) {
|
||||
// Gerar par de tokens
|
||||
const { accessToken, refreshToken, tokenId, expiresAt } = generateTokenPair(
|
||||
session.user.id,
|
||||
deviceId
|
||||
deviceId,
|
||||
);
|
||||
|
||||
// Salvar hash do token no banco
|
||||
@@ -58,22 +59,20 @@ export async function POST(request: Request) {
|
||||
tokenId,
|
||||
name,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
message: "Token criado com sucesso. Guarde-o em local seguro, ele não será mostrado novamente.",
|
||||
message:
|
||||
"Token criado com sucesso. Guarde-o em local seguro, ele não será mostrado novamente.",
|
||||
},
|
||||
{ status: 201 }
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.issues[0]?.message ?? "Dados inválidos" },
|
||||
{ status: 400 }
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
console.error("[API] Error creating device token:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erro ao criar token" },
|
||||
{ status: 500 }
|
||||
);
|
||||
return NextResponse.json({ error: "Erro ao criar token" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,18 +5,18 @@
|
||||
* Requer sessão web autenticada.
|
||||
*/
|
||||
|
||||
import { auth } from "@/lib/auth/config";
|
||||
import { db } from "@/lib/db";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
import { auth } from "@/lib/auth/config";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ tokenId: string }>;
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, { params }: RouteParams) {
|
||||
export async function DELETE(_request: Request, { params }: RouteParams) {
|
||||
try {
|
||||
const { tokenId } = await params;
|
||||
|
||||
@@ -24,24 +24,21 @@ export async function DELETE(request: Request, { params }: RouteParams) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "Não autenticado" },
|
||||
{ status: 401 }
|
||||
);
|
||||
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Verificar se token pertence ao usuário
|
||||
const token = await db.query.apiTokens.findFirst({
|
||||
where: and(
|
||||
eq(apiTokens.id, tokenId),
|
||||
eq(apiTokens.userId, session.user.id)
|
||||
eq(apiTokens.userId, session.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: "Token não encontrado" },
|
||||
{ status: 404 }
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,7 +56,7 @@ export async function DELETE(request: Request, { params }: RouteParams) {
|
||||
console.error("[API] Error revoking device token:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erro ao revogar token" },
|
||||
{ status: 500 }
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
* Requer sessão web autenticada.
|
||||
*/
|
||||
|
||||
import { auth } from "@/lib/auth/config";
|
||||
import { db } from "@/lib/db";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
import { auth } from "@/lib/auth/config";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -18,10 +18,7 @@ export async function GET() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "Não autenticado" },
|
||||
{ status: 401 }
|
||||
);
|
||||
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Buscar tokens ativos do usuário
|
||||
@@ -40,14 +37,16 @@ export async function GET() {
|
||||
.orderBy(desc(apiTokens.createdAt));
|
||||
|
||||
// Separar tokens ativos e revogados
|
||||
const activeTokens = tokens.filter((t) => !t.expiresAt || new Date(t.expiresAt) > new Date());
|
||||
const activeTokens = tokens.filter(
|
||||
(t) => !t.expiresAt || new Date(t.expiresAt) > new Date(),
|
||||
);
|
||||
|
||||
return NextResponse.json({ tokens: activeTokens });
|
||||
} catch (error) {
|
||||
console.error("[API] Error listing device tokens:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erro ao listar tokens" },
|
||||
{ status: 500 }
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
* Aceita tokens no formato os_xxx (hash-based, sem expiração).
|
||||
*/
|
||||
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
import { extractBearerToken, hashToken } from "@/lib/auth/api-token";
|
||||
import { db } from "@/lib/db";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
import { eq, and, isNull } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
@@ -22,7 +22,7 @@ export async function POST(request: Request) {
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "Token não fornecido" },
|
||||
{ status: 401 }
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function POST(request: Request) {
|
||||
if (!token.startsWith("os_")) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "Formato de token inválido" },
|
||||
{ status: 401 }
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,21 +41,22 @@ export async function POST(request: Request) {
|
||||
const tokenRecord = await db.query.apiTokens.findFirst({
|
||||
where: and(
|
||||
eq(apiTokens.tokenHash, tokenHash),
|
||||
isNull(apiTokens.revokedAt)
|
||||
isNull(apiTokens.revokedAt),
|
||||
),
|
||||
});
|
||||
|
||||
if (!tokenRecord) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "Token inválido ou revogado" },
|
||||
{ status: 401 }
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Atualizar último uso
|
||||
const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|
||||
|| request.headers.get("x-real-ip")
|
||||
|| null;
|
||||
const clientIp =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
null;
|
||||
|
||||
await db
|
||||
.update(apiTokens)
|
||||
@@ -75,7 +76,7 @@ export async function POST(request: Request) {
|
||||
console.error("[API] Error verifying device token:", error);
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "Erro ao validar token" },
|
||||
{ status: 500 }
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function GET() {
|
||||
version: APP_VERSION,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 200 }
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (error) {
|
||||
// Se houver erro na conexão com banco, retorna status 503 (Service Unavailable)
|
||||
@@ -36,9 +36,10 @@ export async function GET() {
|
||||
name: "OpenSheets",
|
||||
version: APP_VERSION,
|
||||
timestamp: new Date().toISOString(),
|
||||
message: error instanceof Error ? error.message : "Database connection failed",
|
||||
message:
|
||||
error instanceof Error ? error.message : "Database connection failed",
|
||||
},
|
||||
{ status: 503 }
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
* Requer autenticação via API token (formato os_xxx).
|
||||
*/
|
||||
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { apiTokens, inboxItems } from "@/db/schema";
|
||||
import { extractBearerToken, hashToken } from "@/lib/auth/api-token";
|
||||
import { db } from "@/lib/db";
|
||||
import { inboxBatchSchema } from "@/lib/schemas/inbox";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
// Rate limiting simples em memória
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
* Requer autenticação via API token (formato os_xxx).
|
||||
*/
|
||||
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { apiTokens, inboxItems } from "@/db/schema";
|
||||
import { extractBearerToken, hashToken } from "@/lib/auth/api-token";
|
||||
import { db } from "@/lib/db";
|
||||
import { inboxItemSchema } from "@/lib/schemas/inbox";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
// Rate limiting simples em memória (em produção, use Redis)
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { main_font } from "@/public/fonts/font_index";
|
||||
import { Analytics } from "@vercel/analytics/next";
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||
import type { Metadata } from "next";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { main_font } from "@/public/fonts/font_index";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { RiFileSearchLine } from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
||||
67
biome.json
Normal file
67
biome.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noArrayIndexKey": "off",
|
||||
"noExplicitAny": "warn",
|
||||
"noImplicitAnyLet": "warn",
|
||||
"noShadowRestrictedNames": "warn",
|
||||
"noDocumentCookie": "off",
|
||||
"useIterableCallbackReturn": "off"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "warn"
|
||||
},
|
||||
"a11y": {
|
||||
"noLabelWithoutControl": "off",
|
||||
"useFocusableInteractive": "off",
|
||||
"useSemanticElements": "off",
|
||||
"noSvgWithoutTitle": "off",
|
||||
"useButtonType": "off",
|
||||
"useAriaPropsSupportedByRole": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedVariables": "warn",
|
||||
"noUnusedFunctionParameters": "off",
|
||||
"noInvalidUseBeforeDeclaration": "warn",
|
||||
"useExhaustiveDependencies": "warn",
|
||||
"useHookAtTopLevel": "warn"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "off"
|
||||
},
|
||||
"performance": {
|
||||
"noImgElement": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
RiAddLine,
|
||||
RiAlertLine,
|
||||
RiCheckLine,
|
||||
RiDeleteBinLine,
|
||||
RiFileCopyLine,
|
||||
RiSmartphoneLine,
|
||||
} from "@remixicon/react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
createApiTokenAction,
|
||||
revokeApiTokenAction,
|
||||
} from "@/app/(dashboard)/ajustes/actions";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -24,18 +26,19 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
RiSmartphoneLine,
|
||||
RiDeleteBinLine,
|
||||
RiAddLine,
|
||||
RiFileCopyLine,
|
||||
RiCheckLine,
|
||||
RiAlertLine,
|
||||
} from "@remixicon/react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { createApiTokenAction, revokeApiTokenAction } from "@/app/(dashboard)/ajustes/actions";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface ApiToken {
|
||||
id: string;
|
||||
@@ -138,13 +141,17 @@ export function ApiTokensForm({ tokens }: ApiTokensFormProps) {
|
||||
<div>
|
||||
<h3 className="font-medium">Dispositivos conectados</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Gerencie os dispositivos que podem enviar notificações para o OpenSheets.
|
||||
Gerencie os dispositivos que podem enviar notificações para o
|
||||
OpenSheets.
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={isCreateOpen} onOpenChange={(open) => {
|
||||
<Dialog
|
||||
open={isCreateOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) handleCloseCreate();
|
||||
else setIsCreateOpen(true);
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<RiAddLine className="h-4 w-4 mr-1" />
|
||||
@@ -157,7 +164,8 @@ export function ApiTokensForm({ tokens }: ApiTokensFormProps) {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Criar Token de API</DialogTitle>
|
||||
<DialogDescription>
|
||||
Crie um token para conectar o OpenSheets Companion no seu dispositivo Android.
|
||||
Crie um token para conectar o OpenSheets Companion no seu
|
||||
dispositivo Android.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
@@ -184,7 +192,10 @@ export function ApiTokensForm({ tokens }: ApiTokensFormProps) {
|
||||
<Button variant="outline" onClick={handleCloseCreate}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={isCreating || !tokenName.trim()}>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={isCreating || !tokenName.trim()}
|
||||
>
|
||||
{isCreating ? "Criando..." : "Criar Token"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -194,7 +205,8 @@ export function ApiTokensForm({ tokens }: ApiTokensFormProps) {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Token Criado</DialogTitle>
|
||||
<DialogDescription>
|
||||
Copie o token abaixo e cole no app OpenSheets Companion. Este token
|
||||
Copie o token abaixo e cole no app OpenSheets Companion.
|
||||
Este token
|
||||
<strong> não será exibido novamente</strong>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -309,17 +321,22 @@ export function ApiTokensForm({ tokens }: ApiTokensFormProps) {
|
||||
)}
|
||||
|
||||
{/* Revoke Confirmation Dialog */}
|
||||
<AlertDialog open={!!revokeId} onOpenChange={(open) => !open && setRevokeId(null)}>
|
||||
<AlertDialog
|
||||
open={!!revokeId}
|
||||
onOpenChange={(open) => !open && setRevokeId(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revogar token?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
O dispositivo associado a este token será desconectado e não poderá mais
|
||||
enviar notificações. Esta ação não pode ser desfeita.
|
||||
O dispositivo associado a este token será desconectado e não
|
||||
poderá mais enviar notificações. Esta ação não pode ser desfeita.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isRevoking}>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={isRevoking}>
|
||||
Cancelar
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleRevoke}
|
||||
disabled={isRevoking}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"use client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { deleteAccountAction } from "@/app/(dashboard)/ajustes/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -12,9 +15,6 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function DeleteAccountForm() {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
"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";
|
||||
import { updatePreferencesAction } from "@/app/(dashboard)/ajustes/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
interface PreferencesFormProps {
|
||||
disableMagnetlines: boolean;
|
||||
}
|
||||
|
||||
export function PreferencesForm({
|
||||
disableMagnetlines,
|
||||
}: PreferencesFormProps) {
|
||||
export function PreferencesForm({ disableMagnetlines }: PreferencesFormProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [magnetlinesDisabled, setMagnetlinesDisabled] =
|
||||
@@ -44,10 +41,7 @@ export function PreferencesForm({
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col space-y-6"
|
||||
>
|
||||
<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">
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiEyeLine,
|
||||
RiEyeOffLine,
|
||||
} from "@remixicon/react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { updateEmailAction } from "@/app/(dashboard)/ajustes/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RiCheckLine, RiCloseLine, RiEyeLine, RiEyeOffLine } from "@remixicon/react";
|
||||
import { useState, useTransition, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type UpdateEmailFormProps = {
|
||||
currentEmail: string;
|
||||
authProvider?: string; // 'google' | 'credential' | undefined
|
||||
};
|
||||
|
||||
export function UpdateEmailForm({ currentEmail, authProvider }: UpdateEmailFormProps) {
|
||||
export function UpdateEmailForm({
|
||||
currentEmail,
|
||||
authProvider,
|
||||
}: UpdateEmailFormProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [password, setPassword] = useState("");
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
@@ -139,12 +147,19 @@ export function UpdateEmailForm({ currentEmail, authProvider }: UpdateEmailFormP
|
||||
aria-required="true"
|
||||
aria-describedby="new-email-help"
|
||||
aria-invalid={!isEmailDifferent}
|
||||
className={!isEmailDifferent ? "border-red-500 focus-visible:ring-red-500" : ""}
|
||||
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
|
||||
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 && (
|
||||
@@ -183,22 +198,35 @@ export function UpdateEmailForm({ currentEmail, authProvider }: UpdateEmailFormP
|
||||
{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" />
|
||||
<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" />
|
||||
<RiCloseLine
|
||||
className="h-5 w-5 text-red-500"
|
||||
aria-label="Os e-mails não coincidem"
|
||||
/>
|
||||
)}
|
||||
</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">
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { updateNameAction } from "@/app/(dashboard)/ajustes/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type UpdateNameFormProps = {
|
||||
currentName: string;
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiAlertLine,
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiEyeLine,
|
||||
RiEyeOffLine,
|
||||
} from "@remixicon/react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { updatePasswordAction } from "@/app/(dashboard)/ajustes/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import {
|
||||
RiEyeLine,
|
||||
RiEyeOffLine,
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiAlertLine,
|
||||
} from "@remixicon/react";
|
||||
import { useState, useTransition, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface PasswordValidation {
|
||||
hasLowercase: boolean;
|
||||
@@ -29,7 +29,7 @@ function validatePassword(password: string): PasswordValidation {
|
||||
const hasLowercase = /[a-z]/.test(password);
|
||||
const hasUppercase = /[A-Z]/.test(password);
|
||||
const hasNumber = /\d/.test(password);
|
||||
const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?`~]/.test(password);
|
||||
const hasSpecial = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?`~]/.test(password);
|
||||
const hasMinLength = password.length >= 7;
|
||||
const hasMaxLength = password.length <= 23;
|
||||
|
||||
@@ -55,7 +55,9 @@ function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-xs transition-colors",
|
||||
met ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground"
|
||||
met
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{met ? (
|
||||
@@ -93,7 +95,7 @@ export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) {
|
||||
// Validação de requisitos da senha
|
||||
const passwordValidation = useMemo(
|
||||
() => validatePassword(newPassword),
|
||||
[newPassword]
|
||||
[newPassword],
|
||||
);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { RiMoonClearLine, RiSunLine } from "@remixicon/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
@@ -8,7 +9,6 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import { RiMoonClearLine, RiSunLine } from "@remixicon/react";
|
||||
|
||||
interface AnimatedThemeTogglerProps
|
||||
extends React.ComponentPropsWithoutRef<"button"> {
|
||||
@@ -57,7 +57,7 @@ export const AnimatedThemeToggler = ({
|
||||
const y = top + height / 2;
|
||||
const maxRadius = Math.hypot(
|
||||
Math.max(left, window.innerWidth - left),
|
||||
Math.max(top, window.innerHeight - top)
|
||||
Math.max(top, window.innerHeight - top),
|
||||
);
|
||||
|
||||
document.documentElement.animate(
|
||||
@@ -71,7 +71,7 @@ export const AnimatedThemeToggler = ({
|
||||
duration,
|
||||
easing: "ease-in-out",
|
||||
pseudoElement: "::view-transition-new(root)",
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [isDark, duration]);
|
||||
|
||||
@@ -88,7 +88,7 @@ export const AnimatedThemeToggler = ({
|
||||
"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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import {
|
||||
RiArchiveLine,
|
||||
RiCheckLine,
|
||||
@@ -11,6 +9,8 @@ import {
|
||||
RiPencilLine,
|
||||
} from "@remixicon/react";
|
||||
import { useMemo } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import type { Note } from "./types";
|
||||
|
||||
const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { RiCheckLine } from "@remixicon/react";
|
||||
import { useMemo } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -11,8 +13,6 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { RiCheckLine } from "@remixicon/react";
|
||||
import { useMemo } from "react";
|
||||
import { Card } from "../ui/card";
|
||||
import type { Note } from "./types";
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createNoteAction,
|
||||
updateNoteAction,
|
||||
@@ -20,16 +30,6 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
import { useFormState } from "@/hooks/use-form-state";
|
||||
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Card } from "../ui/card";
|
||||
import type { Note, NoteFormValues, Task } from "./types";
|
||||
|
||||
@@ -76,7 +76,7 @@ export function NoteDialog({
|
||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||
open,
|
||||
false,
|
||||
onOpenChange
|
||||
onOpenChange,
|
||||
);
|
||||
|
||||
const initialState = buildInitialValues(note);
|
||||
@@ -126,7 +126,7 @@ export function NoteDialog({
|
||||
setDialogOpen(v);
|
||||
if (!v) setErrorMessage(null);
|
||||
},
|
||||
[setDialogOpen]
|
||||
[setDialogOpen],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
@@ -135,7 +135,7 @@ export function NoteDialog({
|
||||
(e.currentTarget as HTMLFormElement).requestSubmit();
|
||||
if (e.key === "Escape") handleOpenChange(false);
|
||||
},
|
||||
[handleOpenChange]
|
||||
[handleOpenChange],
|
||||
);
|
||||
|
||||
const handleAddTask = useCallback(() => {
|
||||
@@ -157,10 +157,10 @@ export function NoteDialog({
|
||||
(taskId: string) => {
|
||||
updateField(
|
||||
"tasks",
|
||||
(formState.tasks || []).filter((t) => t.id !== taskId)
|
||||
(formState.tasks || []).filter((t) => t.id !== taskId),
|
||||
);
|
||||
},
|
||||
[formState.tasks, updateField]
|
||||
[formState.tasks, updateField],
|
||||
);
|
||||
|
||||
const handleToggleTask = useCallback(
|
||||
@@ -168,11 +168,11 @@ export function NoteDialog({
|
||||
updateField(
|
||||
"tasks",
|
||||
(formState.tasks || []).map((t) =>
|
||||
t.id === taskId ? { ...t, completed: !t.completed } : t
|
||||
)
|
||||
t.id === taskId ? { ...t, completed: !t.completed } : t,
|
||||
),
|
||||
);
|
||||
},
|
||||
[formState.tasks, updateField]
|
||||
[formState.tasks, updateField],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
@@ -240,7 +240,7 @@ export function NoteDialog({
|
||||
onlySpaces,
|
||||
unchanged,
|
||||
invalidLen,
|
||||
]
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { arquivarAnotacaoAction, deleteNoteAction } from "@/app/(dashboard)/anotacoes/actions";
|
||||
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RiAddCircleLine, RiTodoLine } from "@remixicon/react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
arquivarAnotacaoAction,
|
||||
deleteNoteAction,
|
||||
} from "@/app/(dashboard)/anotacoes/actions";
|
||||
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "../ui/card";
|
||||
import { NoteCard } from "./note-card";
|
||||
import { NoteDetailsDialog } from "./note-details-dialog";
|
||||
@@ -33,9 +36,9 @@ export function NotesPage({ notes, isArquivadas = false }: NotesPageProps) {
|
||||
() =>
|
||||
[...notes].sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
),
|
||||
[notes]
|
||||
[notes],
|
||||
);
|
||||
|
||||
const handleCreateOpenChange = useCallback((open: boolean) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { RiTerminalLine } from "@remixicon/react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
||||
interface AuthErrorAlertProps {
|
||||
error: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RiLoader4Line } from "@remixicon/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface GoogleAuthButtonProps {
|
||||
onClick: () => void;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"use client";
|
||||
import { RiLoader4Line } from "@remixicon/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
@@ -11,10 +15,6 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient, googleSignInAvailable } from "@/lib/auth/client";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import { RiLoader4Line } from "@remixicon/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Logo } from "../logo";
|
||||
import { AuthErrorAlert } from "./auth-error-alert";
|
||||
import { AuthHeader } from "./auth-header";
|
||||
@@ -55,14 +55,19 @@ export function LoginForm({ className, ...props }: DivProps) {
|
||||
router.replace("/dashboard");
|
||||
},
|
||||
onError: (ctx) => {
|
||||
if (ctx.error.status === 500 && ctx.error.statusText === "Internal Server Error") {
|
||||
toast.error("Ocorreu uma falha na requisição. Tente novamente mais tarde.");
|
||||
if (
|
||||
ctx.error.status === 500 &&
|
||||
ctx.error.statusText === "Internal Server Error"
|
||||
) {
|
||||
toast.error(
|
||||
"Ocorreu uma falha na requisição. Tente novamente mais tarde.",
|
||||
);
|
||||
}
|
||||
|
||||
setError(ctx.error.message);
|
||||
setLoadingEmail(false);
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,7 +93,7 @@ export function LoginForm({ className, ...props }: DivProps) {
|
||||
setError(ctx.error.message);
|
||||
setLoadingGoogle(false);
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Spinner } from "../ui/spinner";
|
||||
|
||||
export default function LogoutButton() {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"use client";
|
||||
import { RiCheckLine, RiCloseLine, RiLoader4Line } from "@remixicon/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type FormEvent, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
@@ -11,16 +15,11 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient, googleSignInAvailable } from "@/lib/auth/client";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import { RiLoader4Line } from "@remixicon/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useMemo, type FormEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Logo } from "../logo";
|
||||
import { AuthErrorAlert } from "./auth-error-alert";
|
||||
import { AuthHeader } from "./auth-header";
|
||||
import AuthSidebar from "./auth-sidebar";
|
||||
import { GoogleAuthButton } from "./google-auth-button";
|
||||
import { RiCheckLine, RiCloseLine } from "@remixicon/react";
|
||||
|
||||
interface PasswordValidation {
|
||||
hasLowercase: boolean;
|
||||
@@ -36,7 +35,7 @@ function validatePassword(password: string): PasswordValidation {
|
||||
const hasLowercase = /[a-z]/.test(password);
|
||||
const hasUppercase = /[A-Z]/.test(password);
|
||||
const hasNumber = /\d/.test(password);
|
||||
const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?`~]/.test(password);
|
||||
const hasSpecial = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?`~]/.test(password);
|
||||
const hasMinLength = password.length >= 7;
|
||||
const hasMaxLength = password.length <= 23;
|
||||
|
||||
@@ -57,18 +56,14 @@ function validatePassword(password: string): PasswordValidation {
|
||||
};
|
||||
}
|
||||
|
||||
function PasswordRequirement({
|
||||
met,
|
||||
label,
|
||||
}: {
|
||||
met: boolean;
|
||||
label: string;
|
||||
}) {
|
||||
function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-xs transition-colors",
|
||||
met ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground"
|
||||
met
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{met ? (
|
||||
@@ -97,7 +92,7 @@ export function SignupForm({ className, ...props }: DivProps) {
|
||||
|
||||
const passwordValidation = useMemo(
|
||||
() => validatePassword(password),
|
||||
[password]
|
||||
[password],
|
||||
);
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
@@ -128,7 +123,7 @@ export function SignupForm({ className, ...props }: DivProps) {
|
||||
setError(ctx.error.message);
|
||||
setLoadingEmail(false);
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,7 +149,7 @@ export function SignupForm({ className, ...props }: DivProps) {
|
||||
setError(ctx.error.message);
|
||||
setLoadingGoogle(false);
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -211,7 +206,10 @@ export function SignupForm({ className, ...props }: DivProps) {
|
||||
placeholder="Crie uma senha forte"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
aria-invalid={!!error || (password.length > 0 && !passwordValidation.isValid)}
|
||||
aria-invalid={
|
||||
!!error ||
|
||||
(password.length > 0 && !passwordValidation.isValid)
|
||||
}
|
||||
maxLength={23}
|
||||
/>
|
||||
{password.length > 0 && (
|
||||
@@ -247,7 +245,11 @@ export function SignupForm({ className, ...props }: DivProps) {
|
||||
<Field>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loadingEmail || loadingGoogle || (password.length > 0 && !passwordValidation.isValid)}
|
||||
disabled={
|
||||
loadingEmail ||
|
||||
loadingGoogle ||
|
||||
(password.length > 0 && !passwordValidation.isValid)
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
{loadingEmail ? (
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { RiCalculatorFill, RiCalculatorLine } from "@remixicon/react";
|
||||
import * as React from "react";
|
||||
import Calculator from "@/components/calculadora/calculator";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -15,8 +17,6 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import { RiCalculatorFill, RiCalculatorLine } from "@remixicon/react";
|
||||
import * as React from "react";
|
||||
|
||||
type Variant = React.ComponentProps<typeof Button>["variant"];
|
||||
type Size = React.ComponentProps<typeof Button>["size"];
|
||||
@@ -55,13 +55,13 @@ export function CalculatorDialogButton({
|
||||
"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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<RiCalculatorLine
|
||||
className={cn(
|
||||
"size-4 transition-transform duration-200",
|
||||
open ? "scale-90" : "scale-100"
|
||||
open ? "scale-90" : "scale-100",
|
||||
)}
|
||||
/>
|
||||
<span className="sr-only">Calculadora</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RiCheckLine, RiFileCopyLine } from "@remixicon/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export type CalculatorDisplayProps = {
|
||||
history: string | null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { CalculatorButtonConfig } from "@/hooks/use-calculator-state";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import { type CalculatorButtonConfig } from "@/hooks/use-calculator-state";
|
||||
|
||||
type CalculatorKeypadProps = {
|
||||
buttons: CalculatorButtonConfig[][];
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import { DayCell } from "@/components/calendario/day-cell";
|
||||
|
||||
import type { CalendarDay } from "@/components/calendario/types";
|
||||
import { WEEK_DAYS_SHORT } from "@/components/calendario/utils";
|
||||
import { DayCell } from "@/components/calendario/day-cell";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
|
||||
type CalendarGridProps = {
|
||||
days: CalendarDay[];
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { RiAddLine } from "@remixicon/react";
|
||||
import type { KeyboardEvent, MouseEvent } from "react";
|
||||
import type { CalendarDay, CalendarEvent } from "@/components/calendario/types";
|
||||
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import { RiAddLine } from "@remixicon/react";
|
||||
import type { KeyboardEvent, MouseEvent } from "react";
|
||||
|
||||
type DayCellProps = {
|
||||
day: CalendarDay;
|
||||
@@ -103,7 +103,7 @@ const DayEventPreview = ({ event }: { event: CalendarEvent }) => {
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 rounded p-1 text-xs",
|
||||
style.wrapper
|
||||
style.wrapper,
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
@@ -145,7 +145,7 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
|
||||
className={cn(
|
||||
"flex h-full cursor-pointer flex-col gap-1.5 rounded-lg border border-transparent bg-card/80 p-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:border-primary/40 hover:bg-primary/5 dark:hover:bg-primary/10",
|
||||
!day.isCurrentMonth && "opacity-60",
|
||||
day.isToday && "border-primary/70 bg-primary/5 hover:border-primary"
|
||||
day.isToday && "border-primary/70 bg-primary/5 hover:border-primary",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -154,7 +154,7 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
|
||||
"text-sm font-semibold leading-none",
|
||||
day.isToday
|
||||
? "text-orange-100 bg-primary size-5 rounded-full flex items-center justify-center"
|
||||
: "text-foreground/90"
|
||||
: "text-foreground/90",
|
||||
)}
|
||||
>
|
||||
{day.label}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { EVENT_TYPE_STYLES } from "@/components/calendario/day-cell";
|
||||
import type { CalendarDay, CalendarEvent } from "@/components/calendario/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { friendlyDate, parseLocalDateString } from "@/lib/utils/date";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import type { ReactNode } from "react";
|
||||
import MoneyValues from "../money-values";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Card } from "../ui/card";
|
||||
@@ -49,7 +49,7 @@ const EventCard = ({
|
||||
};
|
||||
|
||||
const renderLancamento = (
|
||||
event: Extract<CalendarEvent, { type: "lancamento" }>
|
||||
event: Extract<CalendarEvent, { type: "lancamento" }>,
|
||||
) => {
|
||||
const isReceita = event.lancamento.transactionType === "Receita";
|
||||
const isPagamentoFatura =
|
||||
@@ -76,7 +76,9 @@ const renderLancamento = (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-semibold whitespace-nowrap",
|
||||
isReceita ? "text-green-600 dark:text-green-400" : "text-foreground"
|
||||
isReceita
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-foreground",
|
||||
)}
|
||||
>
|
||||
<MoneyValues
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user