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 { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { parsePeriodParam } from "@/lib/utils/period";
|
||||
import { fetchPendingInboxCount } from "./pre-lancamentos/data";
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
@@ -40,6 +41,9 @@ export default async function DashboardLayout({
|
||||
currentPeriod,
|
||||
);
|
||||
|
||||
// Buscar contagem de pré-lançamentos pendentes
|
||||
const preLancamentosCount = await fetchPendingInboxCount(session.user.id);
|
||||
|
||||
return (
|
||||
<PrivacyProvider>
|
||||
<SidebarProvider>
|
||||
@@ -52,6 +56,7 @@ export default async function DashboardLayout({
|
||||
avatarUrl: item.avatarUrl,
|
||||
canEdit: item.canEdit,
|
||||
}))}
|
||||
preLancamentosCount={preLancamentosCount}
|
||||
variant="sidebar"
|
||||
/>
|
||||
<SidebarInset>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { inboxItems } 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 { and, eq, inArray } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
@@ -15,7 +15,6 @@ const markProcessedSchema = z.object({
|
||||
|
||||
const discardInboxSchema = z.object({
|
||||
inboxItemId: z.string().uuid("ID do item inválido"),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
|
||||
const bulkDiscardSchema = z.object({
|
||||
@@ -23,7 +22,7 @@ const bulkDiscardSchema = z.object({
|
||||
});
|
||||
|
||||
function revalidateInbox() {
|
||||
revalidatePath("/caixa-de-entrada");
|
||||
revalidatePath("/pre-lancamentos");
|
||||
revalidatePath("/lancamentos");
|
||||
revalidatePath("/dashboard");
|
||||
}
|
||||
@@ -32,7 +31,7 @@ function revalidateInbox() {
|
||||
* Mark an inbox item as processed after a lancamento was created
|
||||
*/
|
||||
export async function markInboxAsProcessedAction(
|
||||
input: z.infer<typeof markProcessedSchema>
|
||||
input: z.infer<typeof markProcessedSchema>,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -46,8 +45,8 @@ export async function markInboxAsProcessedAction(
|
||||
and(
|
||||
eq(inboxItems.id, data.inboxItemId),
|
||||
eq(inboxItems.userId, user.id),
|
||||
eq(inboxItems.status, "pending")
|
||||
)
|
||||
eq(inboxItems.status, "pending"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
@@ -74,7 +73,7 @@ export async function markInboxAsProcessedAction(
|
||||
}
|
||||
|
||||
export async function discardInboxItemAction(
|
||||
input: z.infer<typeof discardInboxSchema>
|
||||
input: z.infer<typeof discardInboxSchema>,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -88,8 +87,8 @@ export async function discardInboxItemAction(
|
||||
and(
|
||||
eq(inboxItems.id, data.inboxItemId),
|
||||
eq(inboxItems.userId, user.id),
|
||||
eq(inboxItems.status, "pending")
|
||||
)
|
||||
eq(inboxItems.status, "pending"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
@@ -103,7 +102,6 @@ export async function discardInboxItemAction(
|
||||
.set({
|
||||
status: "discarded",
|
||||
discardedAt: new Date(),
|
||||
discardReason: data.reason,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(inboxItems.id, data.inboxItemId));
|
||||
@@ -117,7 +115,7 @@ export async function discardInboxItemAction(
|
||||
}
|
||||
|
||||
export async function bulkDiscardInboxItemsAction(
|
||||
input: z.infer<typeof bulkDiscardSchema>
|
||||
input: z.infer<typeof bulkDiscardSchema>,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -135,8 +133,8 @@ export async function bulkDiscardInboxItemsAction(
|
||||
and(
|
||||
inArray(inboxItems.id, data.inboxItemIds),
|
||||
eq(inboxItems.userId, user.id),
|
||||
eq(inboxItems.status, "pending")
|
||||
)
|
||||
eq(inboxItems.status, "pending"),
|
||||
),
|
||||
);
|
||||
|
||||
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 { inboxItems, categorias, contas, cartoes, lancamentos } from "@/db/schema";
|
||||
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 {
|
||||
fetchLancamentoFilterSources,
|
||||
buildSluggedFilters,
|
||||
@@ -1,8 +1,8 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiInbox2Line } from "@remixicon/react";
|
||||
import { RiInboxLine } from "@remixicon/react";
|
||||
|
||||
export const metadata = {
|
||||
title: "Caixa de Entrada | Opensheets",
|
||||
title: "Pré-Lançamentos | Opensheets",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -13,9 +13,9 @@ export default function RootLayout({
|
||||
return (
|
||||
<section className="space-y-6 px-6">
|
||||
<PageDescription
|
||||
icon={<RiInbox2Line />}
|
||||
title="Caixa de Entrada"
|
||||
subtitle="Visialize seus lançamentos pendentes"
|
||||
icon={<RiInboxLine />}
|
||||
title="Pré-Lançamentos"
|
||||
subtitle="Notificações capturadas aguardando processamento"
|
||||
/>
|
||||
{children}
|
||||
</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 { fetchInboxItems, fetchInboxDialogData } from "./data";
|
||||
import { fetchInboxDialogData, fetchInboxItems } from "./data";
|
||||
|
||||
export default async function Page() {
|
||||
const userId = await getUserId();
|
||||
@@ -5,12 +5,12 @@
|
||||
* Requer autenticação via API token (formato os_xxx).
|
||||
*/
|
||||
|
||||
import { apiTokens, inboxItems } from "@/db/schema";
|
||||
import { extractBearerToken, hashToken } from "@/lib/auth/api-token";
|
||||
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 { and, eq, isNull } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
// Rate limiting simples em memória
|
||||
@@ -51,7 +51,7 @@ export async function POST(request: Request) {
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: "Token não fornecido" },
|
||||
{ status: 401 }
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export async function POST(request: Request) {
|
||||
if (!token.startsWith("os_")) {
|
||||
return NextResponse.json(
|
||||
{ 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({
|
||||
where: and(
|
||||
eq(apiTokens.tokenHash, tokenHash),
|
||||
isNull(apiTokens.revokedAt)
|
||||
isNull(apiTokens.revokedAt),
|
||||
),
|
||||
});
|
||||
|
||||
if (!tokenRecord) {
|
||||
return NextResponse.json(
|
||||
{ error: "Token inválido ou revogado" },
|
||||
{ status: 401 }
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ export async function POST(request: Request) {
|
||||
if (!checkRateLimit(tokenRecord.userId)) {
|
||||
return NextResponse.json(
|
||||
{ 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,
|
||||
sourceApp: item.sourceApp,
|
||||
sourceAppName: item.sourceAppName,
|
||||
deviceId: item.deviceId,
|
||||
originalTitle: item.originalTitle,
|
||||
originalText: item.originalText,
|
||||
notificationTimestamp: item.notificationTimestamp,
|
||||
parsedName: item.parsedName,
|
||||
parsedAmount: item.parsedAmount?.toString(),
|
||||
parsedDate: item.parsedDate,
|
||||
parsedTransactionType: item.parsedTransactionType,
|
||||
status: "pending",
|
||||
})
|
||||
@@ -130,9 +128,10 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
// Atualizar último uso do token
|
||||
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)
|
||||
@@ -153,20 +152,20 @@ export async function POST(request: Request) {
|
||||
failed: failCount,
|
||||
results,
|
||||
},
|
||||
{ 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 batch inbox items:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erro ao processar notificações" },
|
||||
{ status: 500 }
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
* Requer autenticação via API token (formato os_xxx).
|
||||
*/
|
||||
|
||||
import { apiTokens, inboxItems } from "@/db/schema";
|
||||
import { extractBearerToken, hashToken } from "@/lib/auth/api-token";
|
||||
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 { 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)
|
||||
@@ -44,7 +44,7 @@ export async function POST(request: Request) {
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: "Token não fornecido" },
|
||||
{ status: 401 }
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export async function POST(request: Request) {
|
||||
if (!token.startsWith("os_")) {
|
||||
return NextResponse.json(
|
||||
{ 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({
|
||||
where: and(
|
||||
eq(apiTokens.tokenHash, tokenHash),
|
||||
isNull(apiTokens.revokedAt)
|
||||
isNull(apiTokens.revokedAt),
|
||||
),
|
||||
});
|
||||
|
||||
if (!tokenRecord) {
|
||||
return NextResponse.json(
|
||||
{ error: "Token inválido ou revogado" },
|
||||
{ status: 401 }
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export async function POST(request: Request) {
|
||||
if (!checkRateLimit(tokenRecord.userId)) {
|
||||
return NextResponse.json(
|
||||
{ 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,
|
||||
sourceApp: data.sourceApp,
|
||||
sourceAppName: data.sourceAppName,
|
||||
deviceId: data.deviceId,
|
||||
originalTitle: data.originalTitle,
|
||||
originalText: data.originalText,
|
||||
notificationTimestamp: data.notificationTimestamp,
|
||||
parsedName: data.parsedName,
|
||||
parsedAmount: data.parsedAmount?.toString(),
|
||||
parsedDate: data.parsedDate,
|
||||
parsedTransactionType: data.parsedTransactionType,
|
||||
status: "pending",
|
||||
})
|
||||
.returning({ id: inboxItems.id });
|
||||
|
||||
// Atualizar último uso do token
|
||||
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)
|
||||
@@ -123,20 +122,20 @@ export async function POST(request: Request) {
|
||||
clientId: data.clientId,
|
||||
message: "Notificação recebida",
|
||||
},
|
||||
{ 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 inbox item:", error);
|
||||
return NextResponse.json(
|
||||
{ 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 { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import { CardContent, CardDescription, CardHeader } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -16,8 +11,6 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getPaymentMethodIcon } from "@/lib/utils/icons";
|
||||
import { parseLocalDateString } from "@/lib/utils/date";
|
||||
import {
|
||||
currencyFormatter,
|
||||
formatCondition,
|
||||
@@ -25,6 +18,8 @@ import {
|
||||
formatPeriod,
|
||||
getTransactionBadgeVariant,
|
||||
} from "@/lib/lancamentos/formatting-helpers";
|
||||
import { parseLocalDateString } from "@/lib/utils/date";
|
||||
import { getPaymentMethodIcon } from "@/lib/utils/icons";
|
||||
import { InstallmentTimeline } from "../shared/installment-timeline";
|
||||
import type { LancamentoItem } from "../types";
|
||||
|
||||
@@ -59,7 +54,7 @@ export function LancamentoDetailsDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<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">
|
||||
<div>
|
||||
<DialogTitle className="group flex items-center gap-2 text-lg">
|
||||
@@ -112,7 +107,7 @@ export function LancamentoDetailsDialog({
|
||||
variant={getTransactionBadgeVariant(
|
||||
lancamento.categoriaName === "Saldo inicial"
|
||||
? "Saldo inicial"
|
||||
: lancamento.transactionType
|
||||
: lancamento.transactionType,
|
||||
)}
|
||||
>
|
||||
{lancamento.categoriaName === "Saldo inicial"
|
||||
@@ -148,7 +143,9 @@ export function LancamentoDetailsDialog({
|
||||
{isInstallment && (
|
||||
<li className="mt-4">
|
||||
<InstallmentTimeline
|
||||
purchaseDate={parseLocalDateString(lancamento.purchaseDate)}
|
||||
purchaseDate={parseLocalDateString(
|
||||
lancamento.purchaseDate,
|
||||
)}
|
||||
currentInstallment={parcelaAtual}
|
||||
totalInstallments={totalParcelas}
|
||||
period={lancamento.period}
|
||||
@@ -194,7 +191,7 @@ export function LancamentoDetailsDialog({
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</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";
|
||||
|
||||
import MoneyValues from "@/components/money-values";
|
||||
import { TypeBadge } from "@/components/type-badge";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import type { InboxItem } from "./types";
|
||||
@@ -28,12 +31,8 @@ export function InboxDetailsDialog({
|
||||
}: InboxDetailsDialogProps) {
|
||||
if (!item) return null;
|
||||
|
||||
const formattedAmount = item.parsedAmount
|
||||
? new Intl.NumberFormat("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
}).format(parseFloat(item.parsedAmount))
|
||||
: "Não extraído";
|
||||
const amount = item.parsedAmount ? parseFloat(item.parsedAmount) : null;
|
||||
const isReceita = item.parsedTransactionType === "Receita";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -45,10 +44,11 @@ export function InboxDetailsDialog({
|
||||
<div className="space-y-4">
|
||||
{/* Dados da fonte */}
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-muted-foreground">
|
||||
Fonte
|
||||
</h4>
|
||||
<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">
|
||||
<span className="text-muted-foreground">App</span>
|
||||
<span>{item.sourceAppName || item.sourceApp}</span>
|
||||
@@ -57,12 +57,6 @@ export function InboxDetailsDialog({
|
||||
<span className="text-muted-foreground">Package</span>
|
||||
<span className="font-mono text-xs">{item.sourceApp}</span>
|
||||
</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>
|
||||
|
||||
@@ -70,58 +64,51 @@ export function InboxDetailsDialog({
|
||||
|
||||
{/* Texto original */}
|
||||
<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
|
||||
</h4>
|
||||
|
||||
{item.originalTitle && (
|
||||
<p className="mb-1 font-medium">{item.originalTitle}</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>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Dados parseados */}
|
||||
<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="flex justify-between">
|
||||
<span className="text-muted-foreground">Estabelecimento</span>
|
||||
<span>{item.parsedName || "Não extraído"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Valor</span>
|
||||
<Badge
|
||||
variant={
|
||||
item.parsedTransactionType === "Receita"
|
||||
? "success"
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{formattedAmount}
|
||||
</Badge>
|
||||
{amount !== null ? (
|
||||
<MoneyValues
|
||||
amount={isReceita ? amount : -amount}
|
||||
showPositiveSign={isReceita}
|
||||
className={cn(
|
||||
"text-sm",
|
||||
isReceita
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-foreground",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Não extraído</span>
|
||||
)}
|
||||
</div>
|
||||
{item.parsedDate && (
|
||||
<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">
|
||||
<div className="flex justify-between items-center">
|
||||
<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>
|
||||
@@ -130,14 +117,7 @@ export function InboxDetailsDialog({
|
||||
|
||||
{/* Metadados */}
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-muted-foreground">
|
||||
Metadados
|
||||
</h4>
|
||||
<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">
|
||||
<span className="text-muted-foreground">Status</span>
|
||||
<Badge variant="outline">{item.status}</Badge>
|
||||
@@ -154,7 +134,9 @@ export function InboxDetailsDialog({
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button>Fechar</Button>
|
||||
<Button className="w-full mt-2" type="button">
|
||||
Entendi
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -3,7 +3,7 @@
|
||||
import {
|
||||
discardInboxItemAction,
|
||||
markInboxAsProcessedAction,
|
||||
} from "@/app/(dashboard)/caixa-de-entrada/actions";
|
||||
} from "@/app/(dashboard)/pre-lancamentos/actions";
|
||||
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog";
|
||||
@@ -122,17 +122,16 @@ export function InboxPage({
|
||||
}, [itemToProcess]);
|
||||
|
||||
// Prepare default values from inbox item
|
||||
// Use parsedDate if available, otherwise fall back to notificationTimestamp
|
||||
const getDateString = (date: Date | string | null | undefined): string | null => {
|
||||
const getDateString = (
|
||||
date: Date | string | null | undefined,
|
||||
): string | null => {
|
||||
if (!date) return null;
|
||||
if (typeof date === "string") return date.slice(0, 10);
|
||||
return date.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
const defaultPurchaseDate =
|
||||
getDateString(itemToProcess?.parsedDate) ??
|
||||
getDateString(itemToProcess?.notificationTimestamp) ??
|
||||
null;
|
||||
getDateString(itemToProcess?.notificationTimestamp) ?? 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">
|
||||
<EmptyState
|
||||
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."
|
||||
/>
|
||||
</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";
|
||||
@@ -8,19 +8,16 @@ export interface InboxItem {
|
||||
id: string;
|
||||
sourceApp: string;
|
||||
sourceAppName: string | null;
|
||||
deviceId: string | null;
|
||||
originalTitle: string | null;
|
||||
originalText: string;
|
||||
notificationTimestamp: Date;
|
||||
parsedName: string | null;
|
||||
parsedAmount: string | null;
|
||||
parsedDate: Date | null;
|
||||
parsedTransactionType: string | null;
|
||||
status: string;
|
||||
lancamentoId: string | null;
|
||||
processedAt: Date | null;
|
||||
discardedAt: Date | null;
|
||||
discardReason: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -41,7 +38,6 @@ export interface ProcessInboxInput {
|
||||
|
||||
export interface DiscardInboxInput {
|
||||
inboxItemId: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// Re-export the lancamentos SelectOption for use in inbox components
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import * as React from "react";
|
||||
import { createSidebarNavData, type PagadorLike } from "./nav-link";
|
||||
import { createSidebarNavData, type PagadorLike, type SidebarNavOptions } from "./nav-link";
|
||||
|
||||
type AppUser = {
|
||||
id: string;
|
||||
@@ -27,12 +27,14 @@ interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
user: AppUser;
|
||||
pagadorAvatarUrl: string | null;
|
||||
pagadores: PagadorLike[];
|
||||
preLancamentosCount?: number;
|
||||
}
|
||||
|
||||
export function AppSidebar({
|
||||
user,
|
||||
pagadorAvatarUrl,
|
||||
pagadores,
|
||||
preLancamentosCount = 0,
|
||||
...props
|
||||
}: AppSidebarProps) {
|
||||
if (!user) {
|
||||
@@ -40,8 +42,8 @@ export function AppSidebar({
|
||||
}
|
||||
|
||||
const navigation = React.useMemo(
|
||||
() => createSidebarNavData(pagadores),
|
||||
[pagadores]
|
||||
() => createSidebarNavData({ pagadores, preLancamentosCount }),
|
||||
[pagadores, preLancamentosCount]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -25,6 +25,7 @@ export type SidebarSubItem = {
|
||||
isShared?: boolean;
|
||||
key?: string;
|
||||
icon?: RemixiconComponentType;
|
||||
badge?: number;
|
||||
};
|
||||
|
||||
export type SidebarItem = {
|
||||
@@ -56,7 +57,13 @@ export interface PagadorLike {
|
||||
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
|
||||
.map((pagador) => ({
|
||||
title: pagador.name?.trim().length
|
||||
@@ -88,15 +95,19 @@ export function createSidebarNavData(pagadores: PagadorLike[]): SidebarNavData {
|
||||
{
|
||||
title: "Gestão Financeira",
|
||||
items: [
|
||||
{
|
||||
title: "Caixa de Entrada",
|
||||
url: "/caixa-de-entrada",
|
||||
icon: RiInboxLine,
|
||||
},
|
||||
{
|
||||
title: "Lançamentos",
|
||||
url: "/lancamentos",
|
||||
icon: RiArrowLeftRightLine,
|
||||
items: [
|
||||
{
|
||||
title: "Pré-Lançamentos",
|
||||
url: "/pre-lancamentos",
|
||||
key: "pre-lancamentos",
|
||||
icon: RiInboxLine,
|
||||
badge: preLancamentosCount > 0 ? preLancamentosCount : undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Calendário",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@@ -40,6 +41,7 @@ type NavItem = {
|
||||
isShared?: boolean;
|
||||
key?: string;
|
||||
icon?: RemixiconComponentType;
|
||||
badge?: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
@@ -181,6 +183,11 @@ export function NavMain({ sections }: { sections: NavSection[] }) {
|
||||
</Avatar>
|
||||
) : null}
|
||||
<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 ? (
|
||||
<RiUserSharedLine className="size-3.5 text-muted-foreground" />
|
||||
) : null}
|
||||
|
||||
13
db/schema.ts
13
db/schema.ts
@@ -464,7 +464,6 @@ export const inboxItems = pgTable(
|
||||
// Informações da fonte
|
||||
sourceApp: text("source_app").notNull(), // Ex: "com.nu.production"
|
||||
sourceAppName: text("source_app_name"), // Ex: "Nubank"
|
||||
deviceId: text("device_id"), // Identificador do dispositivo
|
||||
|
||||
// Dados originais da notificação
|
||||
originalTitle: text("original_title"),
|
||||
@@ -477,7 +476,6 @@ export const inboxItems = pgTable(
|
||||
// Dados parseados (editáveis pelo usuário antes de processar)
|
||||
parsedName: text("parsed_name"), // Nome do estabelecimento
|
||||
parsedAmount: numeric("parsed_amount", { precision: 12, scale: 2 }),
|
||||
parsedDate: date("parsed_date", { mode: "date" }),
|
||||
parsedTransactionType: text("parsed_transaction_type"), // Despesa, Receita
|
||||
|
||||
// Status de processamento
|
||||
@@ -489,9 +487,14 @@ export const inboxItems = pgTable(
|
||||
}),
|
||||
|
||||
// Metadados de processamento
|
||||
processedAt: timestamp("processed_at", { mode: "date", withTimezone: true }),
|
||||
discardedAt: timestamp("discarded_at", { mode: "date", withTimezone: true }),
|
||||
discardReason: text("discard_reason"),
|
||||
processedAt: timestamp("processed_at", {
|
||||
mode: "date",
|
||||
withTimezone: true,
|
||||
}),
|
||||
discardedAt: timestamp("discarded_at", {
|
||||
mode: "date",
|
||||
withTimezone: true,
|
||||
}),
|
||||
|
||||
// Timestamps
|
||||
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,
|
||||
"tag": "0010_lame_psynapse",
|
||||
"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"],
|
||||
anotacoes: ["/anotacoes", "/anotacoes/arquivadas"],
|
||||
lancamentos: ["/lancamentos", "/contas"],
|
||||
inbox: ["/caixa-de-entrada", "/lancamentos", "/dashboard"],
|
||||
inbox: ["/pre-lancamentos", "/lancamentos", "/dashboard"],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,13 +7,11 @@ import { z } from "zod";
|
||||
export const inboxItemSchema = z.object({
|
||||
sourceApp: z.string().min(1, "sourceApp é obrigatório"),
|
||||
sourceAppName: z.string().optional(),
|
||||
deviceId: z.string().optional(),
|
||||
originalTitle: z.string().optional(),
|
||||
originalText: z.string().min(1, "originalText é obrigatório"),
|
||||
notificationTimestamp: z.string().transform((val) => new Date(val)),
|
||||
parsedName: z.string().optional(),
|
||||
parsedAmount: z.coerce.number().optional(),
|
||||
parsedDate: z.string().optional().transform((val) => (val ? new Date(val) : undefined)),
|
||||
parsedTransactionType: z.enum(["Despesa", "Receita"]).optional(),
|
||||
clientId: z.string().optional(), // ID local do app para rastreamento
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user