refactor(inbox): rename caixa-de-entrada to pre-lancamentos e remove colunas não utilizadas
BREAKING CHANGES: - Renomeia rota /caixa-de-entrada para /pre-lancamentos - Remove colunas device_id, parsed_date e discard_reason da tabela inbox_items Mudanças: - Move componentes de caixa-de-entrada para pre-lancamentos - Atualiza sidebar e navegação para nova rota - Remove campos não utilizados do schema, types e APIs - Adiciona migration 0011 para remover colunas do banco - Simplifica lógica de data padrão usando notificationTimestamp
This commit is contained in:
@@ -7,6 +7,7 @@ import { fetchDashboardNotifications } from "@/lib/dashboard/notifications";
|
|||||||
import { fetchPagadoresWithAccess } from "@/lib/pagadores/access";
|
import { fetchPagadoresWithAccess } from "@/lib/pagadores/access";
|
||||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||||
import { parsePeriodParam } from "@/lib/utils/period";
|
import { parsePeriodParam } from "@/lib/utils/period";
|
||||||
|
import { fetchPendingInboxCount } from "./pre-lancamentos/data";
|
||||||
|
|
||||||
export default async function DashboardLayout({
|
export default async function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
@@ -40,6 +41,9 @@ export default async function DashboardLayout({
|
|||||||
currentPeriod,
|
currentPeriod,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Buscar contagem de pré-lançamentos pendentes
|
||||||
|
const preLancamentosCount = await fetchPendingInboxCount(session.user.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PrivacyProvider>
|
<PrivacyProvider>
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
@@ -52,6 +56,7 @@ export default async function DashboardLayout({
|
|||||||
avatarUrl: item.avatarUrl,
|
avatarUrl: item.avatarUrl,
|
||||||
canEdit: item.canEdit,
|
canEdit: item.canEdit,
|
||||||
}))}
|
}))}
|
||||||
|
preLancamentosCount={preLancamentosCount}
|
||||||
variant="sidebar"
|
variant="sidebar"
|
||||||
/>
|
/>
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { inboxItems } from "@/db/schema";
|
import { inboxItems } from "@/db/schema";
|
||||||
import { handleActionError } from "@/lib/actions/helpers";
|
import { handleActionError } from "@/lib/actions/helpers";
|
||||||
import type { ActionResult } from "@/lib/actions/types";
|
import type { ActionResult } from "@/lib/actions/types";
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { getUser } from "@/lib/auth/server";
|
import { getUser } from "@/lib/auth/server";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -15,7 +15,6 @@ const markProcessedSchema = z.object({
|
|||||||
|
|
||||||
const discardInboxSchema = z.object({
|
const discardInboxSchema = z.object({
|
||||||
inboxItemId: z.string().uuid("ID do item inválido"),
|
inboxItemId: z.string().uuid("ID do item inválido"),
|
||||||
reason: z.string().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const bulkDiscardSchema = z.object({
|
const bulkDiscardSchema = z.object({
|
||||||
@@ -23,7 +22,7 @@ const bulkDiscardSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function revalidateInbox() {
|
function revalidateInbox() {
|
||||||
revalidatePath("/caixa-de-entrada");
|
revalidatePath("/pre-lancamentos");
|
||||||
revalidatePath("/lancamentos");
|
revalidatePath("/lancamentos");
|
||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
}
|
}
|
||||||
@@ -32,7 +31,7 @@ function revalidateInbox() {
|
|||||||
* Mark an inbox item as processed after a lancamento was created
|
* Mark an inbox item as processed after a lancamento was created
|
||||||
*/
|
*/
|
||||||
export async function markInboxAsProcessedAction(
|
export async function markInboxAsProcessedAction(
|
||||||
input: z.infer<typeof markProcessedSchema>
|
input: z.infer<typeof markProcessedSchema>,
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
@@ -46,8 +45,8 @@ export async function markInboxAsProcessedAction(
|
|||||||
and(
|
and(
|
||||||
eq(inboxItems.id, data.inboxItemId),
|
eq(inboxItems.id, data.inboxItemId),
|
||||||
eq(inboxItems.userId, user.id),
|
eq(inboxItems.userId, user.id),
|
||||||
eq(inboxItems.status, "pending")
|
eq(inboxItems.status, "pending"),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
@@ -74,7 +73,7 @@ export async function markInboxAsProcessedAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function discardInboxItemAction(
|
export async function discardInboxItemAction(
|
||||||
input: z.infer<typeof discardInboxSchema>
|
input: z.infer<typeof discardInboxSchema>,
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
@@ -88,8 +87,8 @@ export async function discardInboxItemAction(
|
|||||||
and(
|
and(
|
||||||
eq(inboxItems.id, data.inboxItemId),
|
eq(inboxItems.id, data.inboxItemId),
|
||||||
eq(inboxItems.userId, user.id),
|
eq(inboxItems.userId, user.id),
|
||||||
eq(inboxItems.status, "pending")
|
eq(inboxItems.status, "pending"),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
@@ -103,7 +102,6 @@ export async function discardInboxItemAction(
|
|||||||
.set({
|
.set({
|
||||||
status: "discarded",
|
status: "discarded",
|
||||||
discardedAt: new Date(),
|
discardedAt: new Date(),
|
||||||
discardReason: data.reason,
|
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(inboxItems.id, data.inboxItemId));
|
.where(eq(inboxItems.id, data.inboxItemId));
|
||||||
@@ -117,7 +115,7 @@ export async function discardInboxItemAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function bulkDiscardInboxItemsAction(
|
export async function bulkDiscardInboxItemsAction(
|
||||||
input: z.infer<typeof bulkDiscardSchema>
|
input: z.infer<typeof bulkDiscardSchema>,
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
@@ -135,8 +133,8 @@ export async function bulkDiscardInboxItemsAction(
|
|||||||
and(
|
and(
|
||||||
inArray(inboxItems.id, data.inboxItemIds),
|
inArray(inboxItems.id, data.inboxItemIds),
|
||||||
eq(inboxItems.userId, user.id),
|
eq(inboxItems.userId, user.id),
|
||||||
eq(inboxItems.status, "pending")
|
eq(inboxItems.status, "pending"),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
revalidateInbox();
|
revalidateInbox();
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Data fetching functions for Caixa de Entrada
|
* Data fetching functions for Pré-Lançamentos
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { inboxItems, categorias, contas, cartoes, lancamentos } from "@/db/schema";
|
import { inboxItems, categorias, contas, cartoes, lancamentos } from "@/db/schema";
|
||||||
import { eq, desc, and, gte } from "drizzle-orm";
|
import { eq, desc, and, gte } from "drizzle-orm";
|
||||||
import type { InboxItem, SelectOption } from "@/components/caixa-de-entrada/types";
|
import type { InboxItem, SelectOption } from "@/components/pre-lancamentos/types";
|
||||||
import {
|
import {
|
||||||
fetchLancamentoFilterSources,
|
fetchLancamentoFilterSources,
|
||||||
buildSluggedFilters,
|
buildSluggedFilters,
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import PageDescription from "@/components/page-description";
|
import PageDescription from "@/components/page-description";
|
||||||
import { RiInbox2Line } from "@remixicon/react";
|
import { RiInboxLine } from "@remixicon/react";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Caixa de Entrada | Opensheets",
|
title: "Pré-Lançamentos | Opensheets",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -13,9 +13,9 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<section className="space-y-6 px-6">
|
<section className="space-y-6 px-6">
|
||||||
<PageDescription
|
<PageDescription
|
||||||
icon={<RiInbox2Line />}
|
icon={<RiInboxLine />}
|
||||||
title="Caixa de Entrada"
|
title="Pré-Lançamentos"
|
||||||
subtitle="Visialize seus lançamentos pendentes"
|
subtitle="Notificações capturadas aguardando processamento"
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { InboxPage } from "@/components/caixa-de-entrada/inbox-page";
|
import { InboxPage } from "@/components/pre-lancamentos/inbox-page";
|
||||||
import { getUserId } from "@/lib/auth/server";
|
import { getUserId } from "@/lib/auth/server";
|
||||||
import { fetchInboxItems, fetchInboxDialogData } from "./data";
|
import { fetchInboxDialogData, fetchInboxItems } from "./data";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
@@ -5,12 +5,12 @@
|
|||||||
* Requer autenticação via API token (formato os_xxx).
|
* Requer autenticação via API token (formato os_xxx).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { apiTokens, inboxItems } from "@/db/schema";
|
||||||
import { extractBearerToken, hashToken } from "@/lib/auth/api-token";
|
import { extractBearerToken, hashToken } from "@/lib/auth/api-token";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { apiTokens, inboxItems } from "@/db/schema";
|
|
||||||
import { eq, and, isNull } from "drizzle-orm";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { inboxBatchSchema } from "@/lib/schemas/inbox";
|
import { inboxBatchSchema } from "@/lib/schemas/inbox";
|
||||||
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
// Rate limiting simples em memória
|
// Rate limiting simples em memória
|
||||||
@@ -51,7 +51,7 @@ export async function POST(request: Request) {
|
|||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Token não fornecido" },
|
{ error: "Token não fornecido" },
|
||||||
{ status: 401 }
|
{ status: 401 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ export async function POST(request: Request) {
|
|||||||
if (!token.startsWith("os_")) {
|
if (!token.startsWith("os_")) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Formato de token inválido" },
|
{ error: "Formato de token inválido" },
|
||||||
{ status: 401 }
|
{ status: 401 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,14 +69,14 @@ export async function POST(request: Request) {
|
|||||||
const tokenRecord = await db.query.apiTokens.findFirst({
|
const tokenRecord = await db.query.apiTokens.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(apiTokens.tokenHash, tokenHash),
|
eq(apiTokens.tokenHash, tokenHash),
|
||||||
isNull(apiTokens.revokedAt)
|
isNull(apiTokens.revokedAt),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!tokenRecord) {
|
if (!tokenRecord) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Token inválido ou revogado" },
|
{ error: "Token inválido ou revogado" },
|
||||||
{ status: 401 }
|
{ status: 401 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ export async function POST(request: Request) {
|
|||||||
if (!checkRateLimit(tokenRecord.userId)) {
|
if (!checkRateLimit(tokenRecord.userId)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Limite de requisições excedido", retryAfter: 60 },
|
{ error: "Limite de requisições excedido", retryAfter: 60 },
|
||||||
{ status: 429 }
|
{ status: 429 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,13 +103,11 @@ export async function POST(request: Request) {
|
|||||||
userId: tokenRecord.userId,
|
userId: tokenRecord.userId,
|
||||||
sourceApp: item.sourceApp,
|
sourceApp: item.sourceApp,
|
||||||
sourceAppName: item.sourceAppName,
|
sourceAppName: item.sourceAppName,
|
||||||
deviceId: item.deviceId,
|
|
||||||
originalTitle: item.originalTitle,
|
originalTitle: item.originalTitle,
|
||||||
originalText: item.originalText,
|
originalText: item.originalText,
|
||||||
notificationTimestamp: item.notificationTimestamp,
|
notificationTimestamp: item.notificationTimestamp,
|
||||||
parsedName: item.parsedName,
|
parsedName: item.parsedName,
|
||||||
parsedAmount: item.parsedAmount?.toString(),
|
parsedAmount: item.parsedAmount?.toString(),
|
||||||
parsedDate: item.parsedDate,
|
|
||||||
parsedTransactionType: item.parsedTransactionType,
|
parsedTransactionType: item.parsedTransactionType,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
})
|
})
|
||||||
@@ -130,9 +128,10 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Atualizar último uso do token
|
// Atualizar último uso do token
|
||||||
const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|
const clientIp =
|
||||||
|| request.headers.get("x-real-ip")
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||||
|| null;
|
request.headers.get("x-real-ip") ||
|
||||||
|
null;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(apiTokens)
|
.update(apiTokens)
|
||||||
@@ -153,20 +152,20 @@ export async function POST(request: Request) {
|
|||||||
failed: failCount,
|
failed: failCount,
|
||||||
results,
|
results,
|
||||||
},
|
},
|
||||||
{ status: 201 }
|
{ status: 201 },
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: error.issues[0]?.message ?? "Dados inválidos" },
|
{ error: error.issues[0]?.message ?? "Dados inválidos" },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error("[API] Error creating batch inbox items:", error);
|
console.error("[API] Error creating batch inbox items:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Erro ao processar notificações" },
|
{ error: "Erro ao processar notificações" },
|
||||||
{ status: 500 }
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
* Requer autenticação via API token (formato os_xxx).
|
* Requer autenticação via API token (formato os_xxx).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { apiTokens, inboxItems } from "@/db/schema";
|
||||||
import { extractBearerToken, hashToken } from "@/lib/auth/api-token";
|
import { extractBearerToken, hashToken } from "@/lib/auth/api-token";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { apiTokens, inboxItems } from "@/db/schema";
|
|
||||||
import { eq, and, isNull } from "drizzle-orm";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { inboxItemSchema } from "@/lib/schemas/inbox";
|
import { inboxItemSchema } from "@/lib/schemas/inbox";
|
||||||
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
// Rate limiting simples em memória (em produção, use Redis)
|
// Rate limiting simples em memória (em produção, use Redis)
|
||||||
@@ -44,7 +44,7 @@ export async function POST(request: Request) {
|
|||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Token não fornecido" },
|
{ error: "Token não fornecido" },
|
||||||
{ status: 401 }
|
{ status: 401 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ export async function POST(request: Request) {
|
|||||||
if (!token.startsWith("os_")) {
|
if (!token.startsWith("os_")) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Formato de token inválido" },
|
{ error: "Formato de token inválido" },
|
||||||
{ status: 401 }
|
{ status: 401 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,14 +62,14 @@ export async function POST(request: Request) {
|
|||||||
const tokenRecord = await db.query.apiTokens.findFirst({
|
const tokenRecord = await db.query.apiTokens.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(apiTokens.tokenHash, tokenHash),
|
eq(apiTokens.tokenHash, tokenHash),
|
||||||
isNull(apiTokens.revokedAt)
|
isNull(apiTokens.revokedAt),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!tokenRecord) {
|
if (!tokenRecord) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Token inválido ou revogado" },
|
{ error: "Token inválido ou revogado" },
|
||||||
{ status: 401 }
|
{ status: 401 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ export async function POST(request: Request) {
|
|||||||
if (!checkRateLimit(tokenRecord.userId)) {
|
if (!checkRateLimit(tokenRecord.userId)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Limite de requisições excedido", retryAfter: 60 },
|
{ error: "Limite de requisições excedido", retryAfter: 60 },
|
||||||
{ status: 429 }
|
{ status: 429 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,22 +92,21 @@ export async function POST(request: Request) {
|
|||||||
userId: tokenRecord.userId,
|
userId: tokenRecord.userId,
|
||||||
sourceApp: data.sourceApp,
|
sourceApp: data.sourceApp,
|
||||||
sourceAppName: data.sourceAppName,
|
sourceAppName: data.sourceAppName,
|
||||||
deviceId: data.deviceId,
|
|
||||||
originalTitle: data.originalTitle,
|
originalTitle: data.originalTitle,
|
||||||
originalText: data.originalText,
|
originalText: data.originalText,
|
||||||
notificationTimestamp: data.notificationTimestamp,
|
notificationTimestamp: data.notificationTimestamp,
|
||||||
parsedName: data.parsedName,
|
parsedName: data.parsedName,
|
||||||
parsedAmount: data.parsedAmount?.toString(),
|
parsedAmount: data.parsedAmount?.toString(),
|
||||||
parsedDate: data.parsedDate,
|
|
||||||
parsedTransactionType: data.parsedTransactionType,
|
parsedTransactionType: data.parsedTransactionType,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
})
|
})
|
||||||
.returning({ id: inboxItems.id });
|
.returning({ id: inboxItems.id });
|
||||||
|
|
||||||
// Atualizar último uso do token
|
// Atualizar último uso do token
|
||||||
const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|
const clientIp =
|
||||||
|| request.headers.get("x-real-ip")
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||||
|| null;
|
request.headers.get("x-real-ip") ||
|
||||||
|
null;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(apiTokens)
|
.update(apiTokens)
|
||||||
@@ -123,20 +122,20 @@ export async function POST(request: Request) {
|
|||||||
clientId: data.clientId,
|
clientId: data.clientId,
|
||||||
message: "Notificação recebida",
|
message: "Notificação recebida",
|
||||||
},
|
},
|
||||||
{ status: 201 }
|
{ status: 201 },
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: error.issues[0]?.message ?? "Dados inválidos" },
|
{ error: error.issues[0]?.message ?? "Dados inválidos" },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error("[API] Error creating inbox item:", error);
|
console.error("[API] Error creating inbox item:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Erro ao processar notificação" },
|
{ error: "Erro ao processar notificação" },
|
||||||
{ status: 500 }
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
RiCheckLine,
|
|
||||||
RiDeleteBinLine,
|
|
||||||
RiEyeLine,
|
|
||||||
RiMoreLine,
|
|
||||||
RiSmartphoneLine,
|
|
||||||
} from "@remixicon/react";
|
|
||||||
import { formatDistanceToNow } from "date-fns";
|
|
||||||
import { ptBR } from "date-fns/locale";
|
|
||||||
import type { InboxItem } from "./types";
|
|
||||||
|
|
||||||
interface InboxCardProps {
|
|
||||||
item: InboxItem;
|
|
||||||
onProcess: (item: InboxItem) => void;
|
|
||||||
onDiscard: (item: InboxItem) => void;
|
|
||||||
onViewDetails: (item: InboxItem) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InboxCard({
|
|
||||||
item,
|
|
||||||
onProcess,
|
|
||||||
onDiscard,
|
|
||||||
onViewDetails,
|
|
||||||
}: InboxCardProps) {
|
|
||||||
const formattedAmount = item.parsedAmount
|
|
||||||
? new Intl.NumberFormat("pt-BR", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "BRL",
|
|
||||||
}).format(parseFloat(item.parsedAmount))
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const timeAgo = formatDistanceToNow(new Date(item.notificationTimestamp), {
|
|
||||||
addSuffix: true,
|
|
||||||
locale: ptBR,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="flex flex-col">
|
|
||||||
<CardHeader className="flex flex-row items-start justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<RiSmartphoneLine className="size-4 text-muted-foreground" />
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{item.sourceAppName || item.sourceApp}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{formattedAmount && (
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
item.parsedTransactionType === "Receita"
|
|
||||||
? "success"
|
|
||||||
: "destructive"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{formattedAmount}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="size-8">
|
|
||||||
<RiMoreLine className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => onViewDetails(item)}>
|
|
||||||
<RiEyeLine className="mr-2 size-4" />
|
|
||||||
Ver detalhes
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => onProcess(item)}>
|
|
||||||
<RiCheckLine className="mr-2 size-4" />
|
|
||||||
Processar
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => onDiscard(item)}
|
|
||||||
className="text-destructive"
|
|
||||||
>
|
|
||||||
<RiDeleteBinLine className="mr-2 size-4" />
|
|
||||||
Descartar
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="flex flex-1 flex-col gap-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
{item.originalTitle && (
|
|
||||||
<p className="font-medium">{item.originalTitle}</p>
|
|
||||||
)}
|
|
||||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
|
|
||||||
{item.originalText}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs text-muted-foreground">{timeAgo}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button size="sm" className="flex-1" onClick={() => onProcess(item)}>
|
|
||||||
<RiCheckLine className="mr-1 size-4" />
|
|
||||||
Processar
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => onDiscard(item)}>
|
|
||||||
<RiDeleteBinLine className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,12 +2,7 @@
|
|||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import { CardContent, CardDescription, CardHeader } from "@/components/ui/card";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
@@ -16,8 +11,6 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { getPaymentMethodIcon } from "@/lib/utils/icons";
|
|
||||||
import { parseLocalDateString } from "@/lib/utils/date";
|
|
||||||
import {
|
import {
|
||||||
currencyFormatter,
|
currencyFormatter,
|
||||||
formatCondition,
|
formatCondition,
|
||||||
@@ -25,6 +18,8 @@ import {
|
|||||||
formatPeriod,
|
formatPeriod,
|
||||||
getTransactionBadgeVariant,
|
getTransactionBadgeVariant,
|
||||||
} from "@/lib/lancamentos/formatting-helpers";
|
} from "@/lib/lancamentos/formatting-helpers";
|
||||||
|
import { parseLocalDateString } from "@/lib/utils/date";
|
||||||
|
import { getPaymentMethodIcon } from "@/lib/utils/icons";
|
||||||
import { InstallmentTimeline } from "../shared/installment-timeline";
|
import { InstallmentTimeline } from "../shared/installment-timeline";
|
||||||
import type { LancamentoItem } from "../types";
|
import type { LancamentoItem } from "../types";
|
||||||
|
|
||||||
@@ -59,7 +54,7 @@ export function LancamentoDetailsDialog({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="p-0 sm:max-w-xl">
|
<DialogContent className="p-0 sm:max-w-xl">
|
||||||
<Card className="gap-2 space-y-4">
|
<div className="gap-2 space-y-4 py-6">
|
||||||
<CardHeader className="flex flex-row items-start border-b">
|
<CardHeader className="flex flex-row items-start border-b">
|
||||||
<div>
|
<div>
|
||||||
<DialogTitle className="group flex items-center gap-2 text-lg">
|
<DialogTitle className="group flex items-center gap-2 text-lg">
|
||||||
@@ -112,7 +107,7 @@ export function LancamentoDetailsDialog({
|
|||||||
variant={getTransactionBadgeVariant(
|
variant={getTransactionBadgeVariant(
|
||||||
lancamento.categoriaName === "Saldo inicial"
|
lancamento.categoriaName === "Saldo inicial"
|
||||||
? "Saldo inicial"
|
? "Saldo inicial"
|
||||||
: lancamento.transactionType
|
: lancamento.transactionType,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{lancamento.categoriaName === "Saldo inicial"
|
{lancamento.categoriaName === "Saldo inicial"
|
||||||
@@ -148,7 +143,9 @@ export function LancamentoDetailsDialog({
|
|||||||
{isInstallment && (
|
{isInstallment && (
|
||||||
<li className="mt-4">
|
<li className="mt-4">
|
||||||
<InstallmentTimeline
|
<InstallmentTimeline
|
||||||
purchaseDate={parseLocalDateString(lancamento.purchaseDate)}
|
purchaseDate={parseLocalDateString(
|
||||||
|
lancamento.purchaseDate,
|
||||||
|
)}
|
||||||
currentInstallment={parcelaAtual}
|
currentInstallment={parcelaAtual}
|
||||||
totalInstallments={totalParcelas}
|
totalInstallments={totalParcelas}
|
||||||
period={lancamento.period}
|
period={lancamento.period}
|
||||||
@@ -194,7 +191,7 @@ export function LancamentoDetailsDialog({
|
|||||||
</DialogClose>
|
</DialogClose>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
153
components/pre-lancamentos/inbox-card.tsx
Normal file
153
components/pre-lancamentos/inbox-card.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import MoneyValues from "@/components/money-values";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardAction,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { cn } from "@/lib/utils/ui";
|
||||||
|
import {
|
||||||
|
RiCheckLine,
|
||||||
|
RiDeleteBinLine,
|
||||||
|
RiEyeLine,
|
||||||
|
RiMoreLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { ptBR } from "date-fns/locale";
|
||||||
|
import type { InboxItem } from "./types";
|
||||||
|
|
||||||
|
interface InboxCardProps {
|
||||||
|
item: InboxItem;
|
||||||
|
onProcess: (item: InboxItem) => void;
|
||||||
|
onDiscard: (item: InboxItem) => void;
|
||||||
|
onViewDetails: (item: InboxItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InboxCard({
|
||||||
|
item,
|
||||||
|
onProcess,
|
||||||
|
onDiscard,
|
||||||
|
onViewDetails,
|
||||||
|
}: InboxCardProps) {
|
||||||
|
const amount = item.parsedAmount ? parseFloat(item.parsedAmount) : null;
|
||||||
|
const isReceita = item.parsedTransactionType === "Receita";
|
||||||
|
|
||||||
|
// O timestamp vem do app Android em horário local mas salvo como UTC
|
||||||
|
// Precisamos interpretar o valor UTC como se fosse horário de Brasília
|
||||||
|
const rawDate = new Date(item.notificationTimestamp);
|
||||||
|
|
||||||
|
// Ajusta adicionando o offset de Brasília (3 horas) para corrigir o cálculo do "há X tempo"
|
||||||
|
const BRASILIA_OFFSET_MS = 3 * 60 * 60 * 1000;
|
||||||
|
const notificationDate = new Date(rawDate.getTime() + BRASILIA_OFFSET_MS);
|
||||||
|
|
||||||
|
const timeAgo = formatDistanceToNow(notificationDate, {
|
||||||
|
addSuffix: true,
|
||||||
|
locale: ptBR,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Para exibição, usa UTC pois o valor já representa horário de Brasília
|
||||||
|
const formattedTime = new Intl.DateTimeFormat("pt-BR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
timeZone: "UTC",
|
||||||
|
}).format(rawDate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="flex flex-col gap-0 py-0 h-54">
|
||||||
|
{/* Header com app e valor */}
|
||||||
|
<CardHeader className="pt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-md">
|
||||||
|
{item.sourceAppName || item.sourceApp}
|
||||||
|
{" "}
|
||||||
|
<span className="text-xs font-normal text-muted-foreground">
|
||||||
|
{timeAgo}
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
{amount !== null && (
|
||||||
|
<MoneyValues
|
||||||
|
amount={isReceita ? amount : -amount}
|
||||||
|
showPositiveSign={isReceita}
|
||||||
|
className={cn(
|
||||||
|
"text-sm",
|
||||||
|
isReceita
|
||||||
|
? "text-green-600 dark:text-green-400"
|
||||||
|
: "text-foreground"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardAction>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 -mr-2 -mt-1"
|
||||||
|
>
|
||||||
|
<RiMoreLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => onViewDetails(item)}>
|
||||||
|
<RiEyeLine className="mr-2 size-4" />
|
||||||
|
Ver detalhes
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onProcess(item)}>
|
||||||
|
<RiCheckLine className="mr-2 size-4" />
|
||||||
|
Processar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onDiscard(item)}
|
||||||
|
className="text-destructive"
|
||||||
|
>
|
||||||
|
<RiDeleteBinLine className="mr-2 size-4" />
|
||||||
|
Descartar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{/* Conteúdo da notificação */}
|
||||||
|
<CardContent className="flex-1 py-2">
|
||||||
|
{item.originalTitle && (
|
||||||
|
<p className="mb-1 text-sm font-bold">{item.originalTitle}</p>
|
||||||
|
)}
|
||||||
|
<p className="whitespace-pre-wrap text-sm text-muted-foreground line-clamp-4">
|
||||||
|
{item.originalText}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{/* Botões de ação */}
|
||||||
|
<CardFooter className="gap-2 pt-3 pb-4">
|
||||||
|
<Button size="sm" className="flex-1" onClick={() => onProcess(item)}>
|
||||||
|
<RiCheckLine className="mr-1.5 size-4" />
|
||||||
|
Processar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onDiscard(item)}
|
||||||
|
className="text-muted-foreground hover:text-destructive hover:border-destructive"
|
||||||
|
>
|
||||||
|
<RiDeleteBinLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import MoneyValues from "@/components/money-values";
|
||||||
|
import { TypeBadge } from "@/components/type-badge";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +13,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { cn } from "@/lib/utils/ui";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
import type { InboxItem } from "./types";
|
import type { InboxItem } from "./types";
|
||||||
@@ -28,12 +31,8 @@ export function InboxDetailsDialog({
|
|||||||
}: InboxDetailsDialogProps) {
|
}: InboxDetailsDialogProps) {
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
|
|
||||||
const formattedAmount = item.parsedAmount
|
const amount = item.parsedAmount ? parseFloat(item.parsedAmount) : null;
|
||||||
? new Intl.NumberFormat("pt-BR", {
|
const isReceita = item.parsedTransactionType === "Receita";
|
||||||
style: "currency",
|
|
||||||
currency: "BRL",
|
|
||||||
}).format(parseFloat(item.parsedAmount))
|
|
||||||
: "Não extraído";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -45,10 +44,11 @@ export function InboxDetailsDialog({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Dados da fonte */}
|
{/* Dados da fonte */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="mb-2 text-sm font-medium text-muted-foreground">
|
|
||||||
Fonte
|
|
||||||
</h4>
|
|
||||||
<div className="grid gap-2 text-sm">
|
<div className="grid gap-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">ID</span>
|
||||||
|
<span className="font-mono text-xs">{item.id}</span>
|
||||||
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">App</span>
|
<span className="text-muted-foreground">App</span>
|
||||||
<span>{item.sourceAppName || item.sourceApp}</span>
|
<span>{item.sourceAppName || item.sourceApp}</span>
|
||||||
@@ -57,12 +57,6 @@ export function InboxDetailsDialog({
|
|||||||
<span className="text-muted-foreground">Package</span>
|
<span className="text-muted-foreground">Package</span>
|
||||||
<span className="font-mono text-xs">{item.sourceApp}</span>
|
<span className="font-mono text-xs">{item.sourceApp}</span>
|
||||||
</div>
|
</div>
|
||||||
{item.deviceId && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">Dispositivo</span>
|
|
||||||
<span className="font-mono text-xs">{item.deviceId}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -70,58 +64,51 @@ export function InboxDetailsDialog({
|
|||||||
|
|
||||||
{/* Texto original */}
|
{/* Texto original */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="mb-2 text-sm font-medium text-muted-foreground">
|
<h4 className="mb-1 text-sm font-medium text-muted-foreground">
|
||||||
Notificação Original
|
Notificação Original
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
{item.originalTitle && (
|
{item.originalTitle && (
|
||||||
<p className="mb-1 font-medium">{item.originalTitle}</p>
|
<p className="mb-1 font-medium">{item.originalTitle}</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm">{item.originalText}</p>
|
<p className="text-sm">{item.originalText}</p>
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
|
||||||
Recebida em{" "}
|
|
||||||
{format(new Date(item.notificationTimestamp), "PPpp", {
|
|
||||||
locale: ptBR,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Dados parseados */}
|
{/* Dados parseados */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="mb-2 text-sm font-medium text-muted-foreground">
|
|
||||||
Dados Extraídos
|
|
||||||
</h4>
|
|
||||||
<div className="grid gap-2 text-sm">
|
<div className="grid gap-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Estabelecimento</span>
|
<span className="text-muted-foreground">Estabelecimento</span>
|
||||||
<span>{item.parsedName || "Não extraído"}</span>
|
<span>{item.parsedName || "Não extraído"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-muted-foreground">Valor</span>
|
<span className="text-muted-foreground">Valor</span>
|
||||||
<Badge
|
{amount !== null ? (
|
||||||
variant={
|
<MoneyValues
|
||||||
item.parsedTransactionType === "Receita"
|
amount={isReceita ? amount : -amount}
|
||||||
? "success"
|
showPositiveSign={isReceita}
|
||||||
: "destructive"
|
className={cn(
|
||||||
}
|
"text-sm",
|
||||||
>
|
isReceita
|
||||||
{formattedAmount}
|
? "text-green-600 dark:text-green-400"
|
||||||
</Badge>
|
: "text-foreground",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">Não extraído</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{item.parsedDate && (
|
<div className="flex justify-between items-center">
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">Data</span>
|
|
||||||
<span>
|
|
||||||
{format(new Date(item.parsedDate), "dd/MM/yyyy", {
|
|
||||||
locale: ptBR,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">Tipo</span>
|
<span className="text-muted-foreground">Tipo</span>
|
||||||
<span>{item.parsedTransactionType || "Não identificado"}</span>
|
{item.parsedTransactionType ? (
|
||||||
|
<TypeBadge type={item.parsedTransactionType} />
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Não identificado
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,14 +117,7 @@ export function InboxDetailsDialog({
|
|||||||
|
|
||||||
{/* Metadados */}
|
{/* Metadados */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="mb-2 text-sm font-medium text-muted-foreground">
|
|
||||||
Metadados
|
|
||||||
</h4>
|
|
||||||
<div className="grid gap-2 text-sm">
|
<div className="grid gap-2 text-sm">
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">ID</span>
|
|
||||||
<span className="font-mono text-xs">{item.id}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Status</span>
|
<span className="text-muted-foreground">Status</span>
|
||||||
<Badge variant="outline">{item.status}</Badge>
|
<Badge variant="outline">{item.status}</Badge>
|
||||||
@@ -154,7 +134,9 @@ export function InboxDetailsDialog({
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button>Fechar</Button>
|
<Button className="w-full mt-2" type="button">
|
||||||
|
Entendi
|
||||||
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import {
|
import {
|
||||||
discardInboxItemAction,
|
discardInboxItemAction,
|
||||||
markInboxAsProcessedAction,
|
markInboxAsProcessedAction,
|
||||||
} from "@/app/(dashboard)/caixa-de-entrada/actions";
|
} from "@/app/(dashboard)/pre-lancamentos/actions";
|
||||||
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
|
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
|
||||||
import { EmptyState } from "@/components/empty-state";
|
import { EmptyState } from "@/components/empty-state";
|
||||||
import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog";
|
import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog";
|
||||||
@@ -122,17 +122,16 @@ export function InboxPage({
|
|||||||
}, [itemToProcess]);
|
}, [itemToProcess]);
|
||||||
|
|
||||||
// Prepare default values from inbox item
|
// Prepare default values from inbox item
|
||||||
// Use parsedDate if available, otherwise fall back to notificationTimestamp
|
const getDateString = (
|
||||||
const getDateString = (date: Date | string | null | undefined): string | null => {
|
date: Date | string | null | undefined,
|
||||||
|
): string | null => {
|
||||||
if (!date) return null;
|
if (!date) return null;
|
||||||
if (typeof date === "string") return date.slice(0, 10);
|
if (typeof date === "string") return date.slice(0, 10);
|
||||||
return date.toISOString().slice(0, 10);
|
return date.toISOString().slice(0, 10);
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultPurchaseDate =
|
const defaultPurchaseDate =
|
||||||
getDateString(itemToProcess?.parsedDate) ??
|
getDateString(itemToProcess?.notificationTimestamp) ?? null;
|
||||||
getDateString(itemToProcess?.notificationTimestamp) ??
|
|
||||||
null;
|
|
||||||
|
|
||||||
const defaultName = itemToProcess?.parsedName ?? null;
|
const defaultName = itemToProcess?.parsedName ?? null;
|
||||||
|
|
||||||
@@ -150,7 +149,7 @@ export function InboxPage({
|
|||||||
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
|
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
media={<RiInboxLine className="size-6 text-primary" />}
|
media={<RiInboxLine className="size-6 text-primary" />}
|
||||||
title="Caixa de entrada vazia"
|
title="Nenhum pré-lançamento"
|
||||||
description="As notificações capturadas pelo app OpenSheets Companion aparecerão aqui para você processar."
|
description="As notificações capturadas pelo app OpenSheets Companion aparecerão aqui para você processar."
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Types for Caixa de Entrada (Inbox) feature
|
* Types for Pré-Lançamentos feature
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { SelectOption as LancamentoSelectOption } from "@/components/lancamentos/types";
|
import type { SelectOption as LancamentoSelectOption } from "@/components/lancamentos/types";
|
||||||
@@ -8,19 +8,16 @@ export interface InboxItem {
|
|||||||
id: string;
|
id: string;
|
||||||
sourceApp: string;
|
sourceApp: string;
|
||||||
sourceAppName: string | null;
|
sourceAppName: string | null;
|
||||||
deviceId: string | null;
|
|
||||||
originalTitle: string | null;
|
originalTitle: string | null;
|
||||||
originalText: string;
|
originalText: string;
|
||||||
notificationTimestamp: Date;
|
notificationTimestamp: Date;
|
||||||
parsedName: string | null;
|
parsedName: string | null;
|
||||||
parsedAmount: string | null;
|
parsedAmount: string | null;
|
||||||
parsedDate: Date | null;
|
|
||||||
parsedTransactionType: string | null;
|
parsedTransactionType: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
lancamentoId: string | null;
|
lancamentoId: string | null;
|
||||||
processedAt: Date | null;
|
processedAt: Date | null;
|
||||||
discardedAt: Date | null;
|
discardedAt: Date | null;
|
||||||
discardReason: string | null;
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -41,7 +38,6 @@ export interface ProcessInboxInput {
|
|||||||
|
|
||||||
export interface DiscardInboxInput {
|
export interface DiscardInboxInput {
|
||||||
inboxItemId: string;
|
inboxItemId: string;
|
||||||
reason?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export the lancamentos SelectOption for use in inbox components
|
// Re-export the lancamentos SelectOption for use in inbox components
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { createSidebarNavData, type PagadorLike } from "./nav-link";
|
import { createSidebarNavData, type PagadorLike, type SidebarNavOptions } from "./nav-link";
|
||||||
|
|
||||||
type AppUser = {
|
type AppUser = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,12 +27,14 @@ interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
|||||||
user: AppUser;
|
user: AppUser;
|
||||||
pagadorAvatarUrl: string | null;
|
pagadorAvatarUrl: string | null;
|
||||||
pagadores: PagadorLike[];
|
pagadores: PagadorLike[];
|
||||||
|
preLancamentosCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppSidebar({
|
export function AppSidebar({
|
||||||
user,
|
user,
|
||||||
pagadorAvatarUrl,
|
pagadorAvatarUrl,
|
||||||
pagadores,
|
pagadores,
|
||||||
|
preLancamentosCount = 0,
|
||||||
...props
|
...props
|
||||||
}: AppSidebarProps) {
|
}: AppSidebarProps) {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -40,8 +42,8 @@ export function AppSidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const navigation = React.useMemo(
|
const navigation = React.useMemo(
|
||||||
() => createSidebarNavData(pagadores),
|
() => createSidebarNavData({ pagadores, preLancamentosCount }),
|
||||||
[pagadores]
|
[pagadores, preLancamentosCount]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export type SidebarSubItem = {
|
|||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
key?: string;
|
key?: string;
|
||||||
icon?: RemixiconComponentType;
|
icon?: RemixiconComponentType;
|
||||||
|
badge?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SidebarItem = {
|
export type SidebarItem = {
|
||||||
@@ -56,7 +57,13 @@ export interface PagadorLike {
|
|||||||
canEdit?: boolean;
|
canEdit?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSidebarNavData(pagadores: PagadorLike[]): SidebarNavData {
|
export interface SidebarNavOptions {
|
||||||
|
pagadores: PagadorLike[];
|
||||||
|
preLancamentosCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSidebarNavData(options: SidebarNavOptions): SidebarNavData {
|
||||||
|
const { pagadores, preLancamentosCount = 0 } = options;
|
||||||
const pagadorItems = pagadores
|
const pagadorItems = pagadores
|
||||||
.map((pagador) => ({
|
.map((pagador) => ({
|
||||||
title: pagador.name?.trim().length
|
title: pagador.name?.trim().length
|
||||||
@@ -88,15 +95,19 @@ export function createSidebarNavData(pagadores: PagadorLike[]): SidebarNavData {
|
|||||||
{
|
{
|
||||||
title: "Gestão Financeira",
|
title: "Gestão Financeira",
|
||||||
items: [
|
items: [
|
||||||
{
|
|
||||||
title: "Caixa de Entrada",
|
|
||||||
url: "/caixa-de-entrada",
|
|
||||||
icon: RiInboxLine,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Lançamentos",
|
title: "Lançamentos",
|
||||||
url: "/lancamentos",
|
url: "/lancamentos",
|
||||||
icon: RiArrowLeftRightLine,
|
icon: RiArrowLeftRightLine,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Pré-Lançamentos",
|
||||||
|
url: "/pre-lancamentos",
|
||||||
|
key: "pre-lancamentos",
|
||||||
|
icon: RiInboxLine,
|
||||||
|
badge: preLancamentosCount > 0 ? preLancamentosCount : undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Calendário",
|
title: "Calendário",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
@@ -40,6 +41,7 @@ type NavItem = {
|
|||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
key?: string;
|
key?: string;
|
||||||
icon?: RemixiconComponentType;
|
icon?: RemixiconComponentType;
|
||||||
|
badge?: number;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -181,6 +183,11 @@ export function NavMain({ sections }: { sections: NavSection[] }) {
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
) : null}
|
) : null}
|
||||||
<span>{subItem.title}</span>
|
<span>{subItem.title}</span>
|
||||||
|
{subItem.badge ? (
|
||||||
|
<Badge variant="destructive" className="ml-auto h-5 min-w-5 px-1.5 text-xs">
|
||||||
|
{subItem.badge}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
{subItem.isShared ? (
|
{subItem.isShared ? (
|
||||||
<RiUserSharedLine className="size-3.5 text-muted-foreground" />
|
<RiUserSharedLine className="size-3.5 text-muted-foreground" />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
13
db/schema.ts
13
db/schema.ts
@@ -464,7 +464,6 @@ export const inboxItems = pgTable(
|
|||||||
// Informações da fonte
|
// Informações da fonte
|
||||||
sourceApp: text("source_app").notNull(), // Ex: "com.nu.production"
|
sourceApp: text("source_app").notNull(), // Ex: "com.nu.production"
|
||||||
sourceAppName: text("source_app_name"), // Ex: "Nubank"
|
sourceAppName: text("source_app_name"), // Ex: "Nubank"
|
||||||
deviceId: text("device_id"), // Identificador do dispositivo
|
|
||||||
|
|
||||||
// Dados originais da notificação
|
// Dados originais da notificação
|
||||||
originalTitle: text("original_title"),
|
originalTitle: text("original_title"),
|
||||||
@@ -477,7 +476,6 @@ export const inboxItems = pgTable(
|
|||||||
// Dados parseados (editáveis pelo usuário antes de processar)
|
// Dados parseados (editáveis pelo usuário antes de processar)
|
||||||
parsedName: text("parsed_name"), // Nome do estabelecimento
|
parsedName: text("parsed_name"), // Nome do estabelecimento
|
||||||
parsedAmount: numeric("parsed_amount", { precision: 12, scale: 2 }),
|
parsedAmount: numeric("parsed_amount", { precision: 12, scale: 2 }),
|
||||||
parsedDate: date("parsed_date", { mode: "date" }),
|
|
||||||
parsedTransactionType: text("parsed_transaction_type"), // Despesa, Receita
|
parsedTransactionType: text("parsed_transaction_type"), // Despesa, Receita
|
||||||
|
|
||||||
// Status de processamento
|
// Status de processamento
|
||||||
@@ -489,9 +487,14 @@ export const inboxItems = pgTable(
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
// Metadados de processamento
|
// Metadados de processamento
|
||||||
processedAt: timestamp("processed_at", { mode: "date", withTimezone: true }),
|
processedAt: timestamp("processed_at", {
|
||||||
discardedAt: timestamp("discarded_at", { mode: "date", withTimezone: true }),
|
mode: "date",
|
||||||
discardReason: text("discard_reason"),
|
withTimezone: true,
|
||||||
|
}),
|
||||||
|
discardedAt: timestamp("discarded_at", {
|
||||||
|
mode: "date",
|
||||||
|
withTimezone: true,
|
||||||
|
}),
|
||||||
|
|
||||||
// Timestamps
|
// Timestamps
|
||||||
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
|
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
|
||||||
|
|||||||
39
drizzle/0011_remove_unused_inbox_columns.sql
Normal file
39
drizzle/0011_remove_unused_inbox_columns.sql
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
CREATE TABLE "api_tokens" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"token_hash" text NOT NULL,
|
||||||
|
"token_prefix" text NOT NULL,
|
||||||
|
"last_used_at" timestamp with time zone,
|
||||||
|
"last_used_ip" text,
|
||||||
|
"expires_at" timestamp with time zone,
|
||||||
|
"revoked_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "inbox_items" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"source_app" text NOT NULL,
|
||||||
|
"source_app_name" text,
|
||||||
|
"original_title" text,
|
||||||
|
"original_text" text NOT NULL,
|
||||||
|
"notification_timestamp" timestamp with time zone NOT NULL,
|
||||||
|
"parsed_name" text,
|
||||||
|
"parsed_amount" numeric(12, 2),
|
||||||
|
"parsed_transaction_type" text,
|
||||||
|
"status" text DEFAULT 'pending' NOT NULL,
|
||||||
|
"lancamento_id" uuid,
|
||||||
|
"processed_at" timestamp with time zone,
|
||||||
|
"discarded_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "inbox_items" ADD CONSTRAINT "inbox_items_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "inbox_items" ADD CONSTRAINT "inbox_items_lancamento_id_lancamentos_id_fk" FOREIGN KEY ("lancamento_id") REFERENCES "public"."lancamentos"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "api_tokens_user_id_idx" ON "api_tokens" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "api_tokens_token_hash_idx" ON "api_tokens" USING btree ("token_hash");--> statement-breakpoint
|
||||||
|
CREATE INDEX "inbox_items_user_id_status_idx" ON "inbox_items" USING btree ("user_id","status");--> statement-breakpoint
|
||||||
|
CREATE INDEX "inbox_items_user_id_created_at_idx" ON "inbox_items" USING btree ("user_id","created_at");
|
||||||
2261
drizzle/meta/0011_snapshot.json
Normal file
2261
drizzle/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,13 @@
|
|||||||
"when": 1769369834242,
|
"when": 1769369834242,
|
||||||
"tag": "0010_lame_psynapse",
|
"tag": "0010_lame_psynapse",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1769447087678,
|
||||||
|
"tag": "0011_remove_unused_inbox_columns",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,7 @@ export const revalidateConfig = {
|
|||||||
pagadores: ["/pagadores"],
|
pagadores: ["/pagadores"],
|
||||||
anotacoes: ["/anotacoes", "/anotacoes/arquivadas"],
|
anotacoes: ["/anotacoes", "/anotacoes/arquivadas"],
|
||||||
lancamentos: ["/lancamentos", "/contas"],
|
lancamentos: ["/lancamentos", "/contas"],
|
||||||
inbox: ["/caixa-de-entrada", "/lancamentos", "/dashboard"],
|
inbox: ["/pre-lancamentos", "/lancamentos", "/dashboard"],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,13 +7,11 @@ import { z } from "zod";
|
|||||||
export const inboxItemSchema = z.object({
|
export const inboxItemSchema = z.object({
|
||||||
sourceApp: z.string().min(1, "sourceApp é obrigatório"),
|
sourceApp: z.string().min(1, "sourceApp é obrigatório"),
|
||||||
sourceAppName: z.string().optional(),
|
sourceAppName: z.string().optional(),
|
||||||
deviceId: z.string().optional(),
|
|
||||||
originalTitle: z.string().optional(),
|
originalTitle: z.string().optional(),
|
||||||
originalText: z.string().min(1, "originalText é obrigatório"),
|
originalText: z.string().min(1, "originalText é obrigatório"),
|
||||||
notificationTimestamp: z.string().transform((val) => new Date(val)),
|
notificationTimestamp: z.string().transform((val) => new Date(val)),
|
||||||
parsedName: z.string().optional(),
|
parsedName: z.string().optional(),
|
||||||
parsedAmount: z.coerce.number().optional(),
|
parsedAmount: z.coerce.number().optional(),
|
||||||
parsedDate: z.string().optional().transform((val) => (val ? new Date(val) : undefined)),
|
|
||||||
parsedTransactionType: z.enum(["Despesa", "Receita"]).optional(),
|
parsedTransactionType: z.enum(["Despesa", "Receita"]).optional(),
|
||||||
clientId: z.string().optional(), // ID local do app para rastreamento
|
clientId: z.string().optional(), // ID local do app para rastreamento
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user