mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-11-06 22:05:14 +00:00
chore: preparar versão 2.7.13
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { connection } from "next/server";
|
||||
import { AttachmentsPage } from "@/features/attachments/components/attachments-page";
|
||||
import { fetchAttachmentsPageData } from "@/features/attachments/queries";
|
||||
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
import { parsePeriodParam } from "@/shared/utils/period";
|
||||
|
||||
@@ -19,7 +20,18 @@ const getSingleParam = (
|
||||
return Array.isArray(value) ? (value[0] ?? null) : value;
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
export default function Page({ searchParams }: PageProps) {
|
||||
return (
|
||||
<ContentErrorBoundary
|
||||
title="Não foi possível carregar os anexos"
|
||||
description="Os documentos e comprovantes não puderam ser carregados agora."
|
||||
>
|
||||
<AttachmentsContent searchParams={searchParams} />
|
||||
</ContentErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
async function AttachmentsContent({ searchParams }: PageProps) {
|
||||
await connection();
|
||||
const userId = await getUserId();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { extractDashboardLogoNames } from "@/features/dashboard/lib/extract-logo
|
||||
import { fetchDashboardPageData } from "@/features/dashboard/page-data-queries";
|
||||
import { getSingleParam } from "@/features/transactions/lib/page-helpers";
|
||||
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
|
||||
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
|
||||
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
||||
import { getUser } from "@/shared/lib/auth/server";
|
||||
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
|
||||
@@ -17,7 +18,18 @@ type PageProps = {
|
||||
searchParams?: PageSearchParams;
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
export default function Page({ searchParams }: PageProps) {
|
||||
return (
|
||||
<ContentErrorBoundary
|
||||
title="Não foi possível carregar o dashboard"
|
||||
description="Seus dados financeiros não puderam ser carregados agora."
|
||||
>
|
||||
<DashboardContent searchParams={searchParams} />
|
||||
</ContentErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
async function DashboardContent({ searchParams }: PageProps) {
|
||||
await connection();
|
||||
const user = await getUser();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
@@ -41,19 +53,29 @@ export default async function Page({ searchParams }: PageProps) {
|
||||
<main className="flex flex-col gap-4">
|
||||
<DashboardWelcome name={user.name} />
|
||||
<MonthNavigation />
|
||||
<DashboardMetricsCards
|
||||
metrics={dashboardData.metrics}
|
||||
period={selectedPeriod}
|
||||
adminPayerSlug={adminPayerSlug}
|
||||
/>
|
||||
<LogoPrefetchProvider mappings={logoMappings}>
|
||||
<DashboardGridEditable
|
||||
data={dashboardData}
|
||||
<ContentErrorBoundary
|
||||
title="Não foi possível exibir o resumo"
|
||||
description="Os indicadores do período não puderam ser exibidos agora."
|
||||
>
|
||||
<DashboardMetricsCards
|
||||
metrics={dashboardData.metrics}
|
||||
period={selectedPeriod}
|
||||
initialPreferences={dashboardWidgets}
|
||||
quickActionOptions={quickActionOptions}
|
||||
adminPayerSlug={adminPayerSlug}
|
||||
/>
|
||||
</LogoPrefetchProvider>
|
||||
</ContentErrorBoundary>
|
||||
<ContentErrorBoundary
|
||||
title="Não foi possível exibir os widgets"
|
||||
description="Os detalhes do dashboard não puderam ser exibidos agora."
|
||||
>
|
||||
<LogoPrefetchProvider mappings={logoMappings}>
|
||||
<DashboardGridEditable
|
||||
data={dashboardData}
|
||||
period={selectedPeriod}
|
||||
initialPreferences={dashboardWidgets}
|
||||
quickActionOptions={quickActionOptions}
|
||||
/>
|
||||
</LogoPrefetchProvider>
|
||||
</ContentErrorBoundary>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { connection } from "next/server";
|
||||
import { InsightsPage } from "@/features/insights/components/insights-page";
|
||||
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
|
||||
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
||||
import { parsePeriodParam } from "@/shared/utils/period";
|
||||
|
||||
@@ -18,7 +19,18 @@ const getSingleParam = (
|
||||
return Array.isArray(value) ? (value[0] ?? null) : value;
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
export default function Page({ searchParams }: PageProps) {
|
||||
return (
|
||||
<ContentErrorBoundary
|
||||
title="Não foi possível carregar os Insights"
|
||||
description="As análises financeiras não puderam ser carregadas agora."
|
||||
>
|
||||
<InsightsContent searchParams={searchParams} />
|
||||
</ContentErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
async function InsightsContent({ searchParams }: PageProps) {
|
||||
await connection();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||
|
||||
@@ -6,6 +6,7 @@ import { CardTopExpenses } from "@/features/reports/components/cards/card-top-ex
|
||||
import { CardUsageChart } from "@/features/reports/components/cards/card-usage-chart";
|
||||
import { CardsOverview } from "@/features/reports/components/cards/cards-overview";
|
||||
import { fetchCartoesReportData } from "@/features/reports/lib/cards-report-queries";
|
||||
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
|
||||
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
||||
import { Card } from "@/shared/components/ui/card";
|
||||
import { getUser } from "@/shared/lib/auth/server";
|
||||
@@ -26,9 +27,18 @@ const getSingleParam = (
|
||||
return Array.isArray(value) ? (value[0] ?? null) : value;
|
||||
};
|
||||
|
||||
export default async function RelatorioCartoesPage({
|
||||
searchParams,
|
||||
}: PageProps) {
|
||||
export default function CardUsagePage({ searchParams }: PageProps) {
|
||||
return (
|
||||
<ContentErrorBoundary
|
||||
title="Não foi possível carregar o uso dos cartões"
|
||||
description="Os dados deste relatório não puderam ser carregados agora."
|
||||
>
|
||||
<CardUsageContent searchParams={searchParams} />
|
||||
</ContentErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
async function CardUsageContent({ searchParams }: PageProps) {
|
||||
await connection();
|
||||
const user = await getUser();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { fetchCategoryChartData } from "@/features/reports/lib/category-chart-qu
|
||||
import { fetchCategoryReport } from "@/features/reports/lib/category-report-queries";
|
||||
import { fetchUserCategories } from "@/features/reports/lib/category-trends-queries";
|
||||
import { validateDateRange } from "@/features/reports/lib/utils";
|
||||
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
import type { CategoryReportFilters } from "@/shared/lib/types/reports";
|
||||
import { addMonthsToPeriod, getCurrentPeriod } from "@/shared/utils/period";
|
||||
@@ -29,7 +30,18 @@ const getSingleParam = (
|
||||
return Array.isArray(value) ? (value[0] ?? null) : value;
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
export default function Page({ searchParams }: PageProps) {
|
||||
return (
|
||||
<ContentErrorBoundary
|
||||
title="Não foi possível carregar as tendências"
|
||||
description="A evolução das categorias não pôde ser carregada agora."
|
||||
>
|
||||
<CategoryTrendsContent searchParams={searchParams} />
|
||||
</ContentErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
async function CategoryTrendsContent({ searchParams }: PageProps) {
|
||||
await connection();
|
||||
// Get authenticated user
|
||||
const userId = await getUserId();
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
fetchTopEstablishmentsData,
|
||||
type PeriodFilter,
|
||||
} from "@/features/reports/establishments/queries";
|
||||
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
|
||||
import { Card } from "@/shared/components/ui/card";
|
||||
import { getUser } from "@/shared/lib/auth/server";
|
||||
import { parsePeriodParam } from "@/shared/utils/period";
|
||||
@@ -34,9 +35,18 @@ const validatePeriodFilter = (value: string | null): PeriodFilter => {
|
||||
return "6";
|
||||
};
|
||||
|
||||
export default async function TopEstablishmentsPage({
|
||||
searchParams,
|
||||
}: PageProps) {
|
||||
export default function EstablishmentsPage({ searchParams }: PageProps) {
|
||||
return (
|
||||
<ContentErrorBoundary
|
||||
title="Não foi possível carregar os estabelecimentos"
|
||||
description="Os dados deste relatório não puderam ser carregados agora."
|
||||
>
|
||||
<EstablishmentsContent searchParams={searchParams} />
|
||||
</ContentErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
async function EstablishmentsContent({ searchParams }: PageProps) {
|
||||
await connection();
|
||||
const user = await getUser();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
|
||||
@@ -2,10 +2,22 @@ import { connection } from "next/server";
|
||||
import { InstallmentAnalysisPage } from "@/features/dashboard/components/installment-analysis/installment-analysis-page";
|
||||
import { fetchInstallmentAnalysis } from "@/features/dashboard/expenses/installment-analysis-queries";
|
||||
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
|
||||
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
|
||||
import { getUser } from "@/shared/lib/auth/server";
|
||||
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
|
||||
|
||||
export default async function Page() {
|
||||
export default function Page() {
|
||||
return (
|
||||
<ContentErrorBoundary
|
||||
title="Não foi possível carregar a análise de parcelas"
|
||||
description="Os parcelamentos não puderam ser analisados agora."
|
||||
>
|
||||
<InstallmentAnalysisContent />
|
||||
</ContentErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
async function InstallmentAnalysisContent() {
|
||||
await connection();
|
||||
const user = await getUser();
|
||||
const data = await fetchInstallmentAnalysis(user.id);
|
||||
|
||||
@@ -4,11 +4,10 @@ import {
|
||||
RiShieldCheckLine,
|
||||
RiSmartphoneLine,
|
||||
} from "@remixicon/react";
|
||||
import { headers } from "next/headers";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { AnimateOnScroll } from "@/features/landing/components/animate-on-scroll";
|
||||
import { MobileNav } from "@/features/landing/components/mobile-nav";
|
||||
import { LandingNavbar } from "@/features/landing/components/landing-navbar";
|
||||
import { SetupTabs } from "@/features/landing/components/setup-tabs";
|
||||
import {
|
||||
companionBanks,
|
||||
@@ -16,98 +15,31 @@ import {
|
||||
extraFeatures,
|
||||
getMetricsItems,
|
||||
mainFeatures,
|
||||
navLinks,
|
||||
pwaHighlights,
|
||||
stackItems,
|
||||
whoIsItForItems,
|
||||
} from "@/features/landing/constants";
|
||||
import { landingImages } from "@/features/landing/images";
|
||||
import { fetchGitHubStats } from "@/features/landing/queries";
|
||||
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
|
||||
import {
|
||||
fetchGitHubStats,
|
||||
getLandingCopyrightYear,
|
||||
} from "@/features/landing/queries";
|
||||
import { Logo } from "@/shared/components/brand/logo";
|
||||
import { NavbarShell } from "@/shared/components/navigation/navbar/navbar-shell";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
import { getOptionalUserSession } from "@/shared/lib/auth/server";
|
||||
import { isSignupDisabled } from "@/shared/lib/auth/signup";
|
||||
|
||||
export default async function Page() {
|
||||
const [session, headersList, githubStats] = await Promise.all([
|
||||
getOptionalUserSession(),
|
||||
headers(),
|
||||
const [githubStats, copyrightYear] = await Promise.all([
|
||||
fetchGitHubStats(),
|
||||
getLandingCopyrightYear(),
|
||||
]);
|
||||
const hostname = headersList.get("host")?.replace(/:\d+$/, "");
|
||||
const publicDomain = process.env.PUBLIC_DOMAIN?.replace(
|
||||
/^https?:\/\//,
|
||||
"",
|
||||
).replace(/:\d+$/, "");
|
||||
const isPublicDomain = !!(publicDomain && hostname === publicDomain);
|
||||
const signupDisabled = isSignupDisabled();
|
||||
const metricsItems = getMetricsItems(githubStats.stars, githubStats.forks);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
{/* Navigation */}
|
||||
<NavbarShell>
|
||||
{/* Center Navigation Links */}
|
||||
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
{navLinks.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-sm font-medium leading-none text-primary-foreground/75 transition-colors hover:bg-primary-foreground/10 hover:text-primary-foreground dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<nav className="ml-auto flex items-center gap-1">
|
||||
<AnimatedThemeToggler variant="navbar" />
|
||||
{!isPublicDomain &&
|
||||
(session?.user ? (
|
||||
<Link prefetch href="/dashboard" className="hidden md:block">
|
||||
<Button
|
||||
variant="navbar"
|
||||
size="sm"
|
||||
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
<Link href="/login">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
Entrar
|
||||
</Button>
|
||||
</Link>
|
||||
{!signupDisabled && (
|
||||
<Link href="/signup">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
Começar
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<MobileNav
|
||||
isPublicDomain={isPublicDomain}
|
||||
isLoggedIn={!!session?.user}
|
||||
signupDisabled={signupDisabled}
|
||||
/>
|
||||
</nav>
|
||||
</NavbarShell>
|
||||
<LandingNavbar />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0">
|
||||
@@ -698,8 +630,7 @@ export default async function Page() {
|
||||
|
||||
<div className="border-t mt-8 md:mt-12 pt-6 md:pt-8 flex flex-col md:flex-row justify-between items-center gap-3 md:gap-4 text-sm text-muted-foreground">
|
||||
<p>
|
||||
© {new Date().getFullYear()} openmonetis. Projeto open source
|
||||
sob licença.
|
||||
© {copyrightYear} openmonetis. Projeto open source sob licença.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<RiShieldCheckLine className="size-4 text-primary" />
|
||||
|
||||
@@ -682,6 +682,7 @@ export const transactions = pgTable(
|
||||
splitGroupId: uuid("split_group_id"),
|
||||
transferId: uuid("transfer_id"),
|
||||
ofxFitId: text("ofx_fit_id"),
|
||||
ofxImportFingerprint: text("ofx_import_fingerprint"),
|
||||
importBatchId: text("import_batch_id"),
|
||||
},
|
||||
(table) => ({
|
||||
@@ -735,10 +736,12 @@ export const transactions = pgTable(
|
||||
anticipationIdIdx: index("lancamentos_antecipacao_id_idx").on(
|
||||
table.anticipationId,
|
||||
),
|
||||
// Dedup OFX: garante FITID único por usuário
|
||||
ofxFitIdUserIdIdx: uniqueIndex("lancamentos_ofx_fit_id_user_id_idx")
|
||||
.on(table.userId, table.ofxFitId)
|
||||
.where(sql`ofx_fit_id IS NOT NULL`),
|
||||
// Dedup OFX: identifica a transação completa sem assumir FITID único
|
||||
ofxImportFingerprintUserIdIdx: uniqueIndex(
|
||||
"lancamentos_ofx_import_fingerprint_user_id_idx",
|
||||
)
|
||||
.on(table.userId, table.ofxImportFingerprint)
|
||||
.where(sql`ofx_import_fingerprint IS NOT NULL`),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
103
src/features/landing/components/landing-navbar.tsx
Normal file
103
src/features/landing/components/landing-navbar.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { headers } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
|
||||
import { NavbarShell } from "@/shared/components/navigation/navbar/navbar-shell";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { getOptionalUserSession } from "@/shared/lib/auth/server";
|
||||
import { isSignupDisabled } from "@/shared/lib/auth/signup";
|
||||
import { navLinks } from "../constants";
|
||||
import { MobileNav } from "./mobile-nav";
|
||||
|
||||
async function LandingNavbarControls() {
|
||||
const [session, headersList] = await Promise.all([
|
||||
getOptionalUserSession(),
|
||||
headers(),
|
||||
]);
|
||||
const hostname = headersList.get("host")?.replace(/:\d+$/, "");
|
||||
const publicDomain = process.env.PUBLIC_DOMAIN?.replace(
|
||||
/^https?:\/\//,
|
||||
"",
|
||||
).replace(/:\d+$/, "");
|
||||
const isPublicDomain = !!(publicDomain && hostname === publicDomain);
|
||||
const signupDisabled = isSignupDisabled();
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isPublicDomain &&
|
||||
(session?.user ? (
|
||||
<Link prefetch href="/dashboard" className="hidden md:block">
|
||||
<Button
|
||||
variant="navbar"
|
||||
size="sm"
|
||||
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
<Link href="/login">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
Entrar
|
||||
</Button>
|
||||
</Link>
|
||||
{!signupDisabled && (
|
||||
<Link href="/signup">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
Começar
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<MobileNav
|
||||
isPublicDomain={isPublicDomain}
|
||||
isLoggedIn={!!session?.user}
|
||||
signupDisabled={signupDisabled}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LandingNavbarControlsFallback() {
|
||||
return (
|
||||
<>
|
||||
<div className="hidden h-9 w-36 md:block" aria-hidden="true" />
|
||||
<MobileNav isPublicDomain isLoggedIn={false} signupDisabled />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function LandingNavbar() {
|
||||
return (
|
||||
<NavbarShell>
|
||||
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
{navLinks.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-sm font-medium leading-none text-primary-foreground/75 transition-colors hover:bg-primary-foreground/10 hover:text-primary-foreground dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<nav className="ml-auto flex items-center gap-1">
|
||||
<AnimatedThemeToggler variant="navbar" />
|
||||
<Suspense fallback={<LandingNavbarControlsFallback />}>
|
||||
<LandingNavbarControls />
|
||||
</Suspense>
|
||||
</nav>
|
||||
</NavbarShell>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,22 @@
|
||||
import { cacheLife } from "next/cache";
|
||||
|
||||
export async function getLandingCopyrightYear(): Promise<number> {
|
||||
"use cache";
|
||||
cacheLife({ revalidate: 86_400 });
|
||||
|
||||
return new Date().getFullYear();
|
||||
}
|
||||
|
||||
export async function fetchGitHubStats(): Promise<{
|
||||
stars: number;
|
||||
forks: number;
|
||||
}> {
|
||||
"use cache";
|
||||
cacheLife({ revalidate: 3600 });
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
"https://api.github.com/repos/felipegcoutinho/openmonetis",
|
||||
{ next: { revalidate: 3600 } },
|
||||
);
|
||||
if (!res.ok) return { stars: 200, forks: 60 };
|
||||
const data = await res.json();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { transactions } from "@/db/schema";
|
||||
import {
|
||||
@@ -9,23 +9,49 @@ import {
|
||||
validateCartaoOwnership,
|
||||
validateContaOwnership,
|
||||
} from "@/features/transactions/actions/core";
|
||||
import { createOfxImportFingerprint } from "@/features/transactions/lib/ofx-import-fingerprint";
|
||||
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import {
|
||||
normalizeOfxIdentityText,
|
||||
type OfxIdentityRow,
|
||||
type OfxImportDestination,
|
||||
} from "@/shared/lib/import/ofx-identity";
|
||||
import { uuidSchema } from "@/shared/lib/schemas/common";
|
||||
import { parseLocalDateString } from "@/shared/utils/date";
|
||||
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
|
||||
import { parseLocalDateString, toDateOnlyString } from "@/shared/utils/date";
|
||||
|
||||
const importRowSchema = z.object({
|
||||
const ofxIdentityRowSchema = z.object({
|
||||
externalId: z.string().nullable(),
|
||||
externalIdOccurrence: z.number().int().nonnegative(),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Data inválida."),
|
||||
amount: z.number().positive(),
|
||||
description: z.string().min(1, "Descrição obrigatória."),
|
||||
transactionType: z.enum(["income", "expense"]),
|
||||
sourceDescription: z.string(),
|
||||
});
|
||||
|
||||
const ofxImportDestinationSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("account"), id: uuidSchema("Conta") }),
|
||||
z.object({ type: z.literal("card"), id: uuidSchema("Cartão") }),
|
||||
]);
|
||||
|
||||
const duplicateCheckSchema = z.object({
|
||||
source: z.string().min(1),
|
||||
accountNumber: z.string().nullable(),
|
||||
destination: ofxImportDestinationSchema,
|
||||
rows: z.array(ofxIdentityRowSchema),
|
||||
});
|
||||
|
||||
const importRowSchema = ofxIdentityRowSchema.extend({
|
||||
description: z.string().min(1, "Descrição obrigatória."),
|
||||
categoryId: uuidSchema("Category").nullable().optional(),
|
||||
payerId: uuidSchema("Payer").nullable().optional(),
|
||||
});
|
||||
|
||||
const importSchema = z.object({
|
||||
source: z.string().min(1),
|
||||
accountNumber: z.string().nullable(),
|
||||
rows: z.array(importRowSchema).min(1, "Selecione ao menos uma transação."),
|
||||
payerId: uuidSchema("Payer").nullable().optional(),
|
||||
accountId: uuidSchema("FinancialAccount").nullable().optional(),
|
||||
@@ -44,22 +70,197 @@ type ImportResult =
|
||||
| { success: true; imported: number; skipped: number; importBatchId: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
// Retorna os externalIds que já existem para o usuário (para marcar duplicatas)
|
||||
export async function checkDuplicateFitIds(
|
||||
fitIds: string[],
|
||||
): Promise<string[]> {
|
||||
const userId = await getUserId();
|
||||
const ids = fitIds.filter(Boolean);
|
||||
if (ids.length === 0) return [];
|
||||
type ImportMatch = {
|
||||
fingerprint: string | null;
|
||||
existingTransactionId: string | null;
|
||||
};
|
||||
|
||||
const rows = await db
|
||||
.select({ ofxFitId: transactions.ofxFitId })
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(eq(transactions.userId, userId), inArray(transactions.ofxFitId, ids)),
|
||||
type LegacyImportCandidate = {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: string;
|
||||
purchaseDate: Date;
|
||||
transactionType: string;
|
||||
ofxFitId: string | null;
|
||||
accountId: string | null;
|
||||
cardId: string | null;
|
||||
};
|
||||
|
||||
function isDestinationMatch(
|
||||
candidate: LegacyImportCandidate,
|
||||
destination: OfxImportDestination,
|
||||
): boolean {
|
||||
return destination.type === "card"
|
||||
? candidate.cardId === destination.id
|
||||
: candidate.accountId === destination.id;
|
||||
}
|
||||
|
||||
function isLegacyImportMatch(
|
||||
candidate: LegacyImportCandidate,
|
||||
row: OfxIdentityRow,
|
||||
destination: OfxImportDestination,
|
||||
): boolean {
|
||||
if (
|
||||
!row.externalId ||
|
||||
row.externalIdOccurrence !== 0 ||
|
||||
candidate.ofxFitId !== row.externalId ||
|
||||
!isDestinationMatch(candidate, destination)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedType = row.transactionType === "income" ? "Receita" : "Despesa";
|
||||
const signedAmount =
|
||||
row.transactionType === "expense" ? -row.amount : row.amount;
|
||||
|
||||
return (
|
||||
toDateOnlyString(candidate.purchaseDate) === row.date &&
|
||||
formatDecimalForDbRequired(Number(candidate.amount)) ===
|
||||
formatDecimalForDbRequired(signedAmount) &&
|
||||
candidate.transactionType === expectedType &&
|
||||
normalizeOfxIdentityText(candidate.name) ===
|
||||
normalizeOfxIdentityText(row.sourceDescription)
|
||||
);
|
||||
}
|
||||
|
||||
async function findExistingImports(
|
||||
userId: string,
|
||||
source: string,
|
||||
accountNumber: string | null,
|
||||
destination: OfxImportDestination,
|
||||
rows: OfxIdentityRow[],
|
||||
): Promise<ImportMatch[]> {
|
||||
const fingerprints = rows.map((row) =>
|
||||
createOfxImportFingerprint({
|
||||
source,
|
||||
accountNumber,
|
||||
destination,
|
||||
row,
|
||||
}),
|
||||
);
|
||||
const fingerprintValues = [
|
||||
...new Set(
|
||||
fingerprints.filter(
|
||||
(fingerprint): fingerprint is string => fingerprint !== null,
|
||||
),
|
||||
),
|
||||
];
|
||||
const fitIds = [
|
||||
...new Set(
|
||||
rows
|
||||
.map((row) => row.externalId)
|
||||
.filter((fitId): fitId is string => fitId !== null),
|
||||
),
|
||||
];
|
||||
|
||||
const [fingerprintMatches, legacyCandidates] = await Promise.all([
|
||||
fingerprintValues.length > 0
|
||||
? db
|
||||
.select({
|
||||
id: transactions.id,
|
||||
fingerprint: transactions.ofxImportFingerprint,
|
||||
})
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
inArray(transactions.ofxImportFingerprint, fingerprintValues),
|
||||
),
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
fitIds.length > 0
|
||||
? db
|
||||
.select({
|
||||
id: transactions.id,
|
||||
name: transactions.name,
|
||||
amount: transactions.amount,
|
||||
purchaseDate: transactions.purchaseDate,
|
||||
transactionType: transactions.transactionType,
|
||||
ofxFitId: transactions.ofxFitId,
|
||||
accountId: transactions.accountId,
|
||||
cardId: transactions.cardId,
|
||||
})
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
isNull(transactions.ofxImportFingerprint),
|
||||
inArray(transactions.ofxFitId, fitIds),
|
||||
),
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const transactionIdByFingerprint = new Map(
|
||||
fingerprintMatches.flatMap((match) =>
|
||||
match.fingerprint ? [[match.fingerprint, match.id] as const] : [],
|
||||
),
|
||||
);
|
||||
const consumedLegacyIds = new Set<string>();
|
||||
|
||||
return rows.map((row, index) => {
|
||||
const fingerprint = fingerprints[index] ?? null;
|
||||
const currentTransactionId = fingerprint
|
||||
? (transactionIdByFingerprint.get(fingerprint) ?? null)
|
||||
: null;
|
||||
|
||||
if (currentTransactionId) {
|
||||
return { fingerprint, existingTransactionId: currentTransactionId };
|
||||
}
|
||||
|
||||
const legacyMatch = legacyCandidates.find(
|
||||
(candidate) =>
|
||||
!consumedLegacyIds.has(candidate.id) &&
|
||||
isLegacyImportMatch(candidate, row, destination),
|
||||
);
|
||||
|
||||
return rows.map((r) => r.ofxFitId).filter((id): id is string => id !== null);
|
||||
if (legacyMatch) {
|
||||
consumedLegacyIds.add(legacyMatch.id);
|
||||
}
|
||||
|
||||
return {
|
||||
fingerprint,
|
||||
existingTransactionId: legacyMatch?.id ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function validateDestinationOwnership(
|
||||
userId: string,
|
||||
destination: OfxImportDestination,
|
||||
): Promise<boolean> {
|
||||
return destination.type === "card"
|
||||
? validateCartaoOwnership(userId, destination.id)
|
||||
: validateContaOwnership(userId, destination.id);
|
||||
}
|
||||
|
||||
export async function checkDuplicateOfxTransactions(
|
||||
input: unknown,
|
||||
): Promise<
|
||||
{ success: true; rows: ImportMatch[] } | { success: false; error: string }
|
||||
> {
|
||||
const userId = await getUserId();
|
||||
const parsed = duplicateCheckSchema.safeParse(input);
|
||||
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: "Dados do arquivo inválidos." };
|
||||
}
|
||||
|
||||
const { source, accountNumber, destination, rows } = parsed.data;
|
||||
if (!(await validateDestinationOwnership(userId, destination))) {
|
||||
return { success: false, error: "Conta ou cartão não encontrado." };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
rows: await findExistingImports(
|
||||
userId,
|
||||
source,
|
||||
accountNumber,
|
||||
destination,
|
||||
rows,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function importTransactionsAction(
|
||||
@@ -75,8 +276,25 @@ export async function importTransactionsAction(
|
||||
};
|
||||
}
|
||||
|
||||
const { rows, payerId, accountId, cardId, paymentMethod, invoicePeriod } =
|
||||
parsed.data;
|
||||
const {
|
||||
source,
|
||||
accountNumber,
|
||||
rows,
|
||||
payerId,
|
||||
accountId,
|
||||
cardId,
|
||||
paymentMethod,
|
||||
invoicePeriod,
|
||||
} = parsed.data;
|
||||
const destination: OfxImportDestination | null = cardId
|
||||
? { type: "card", id: cardId }
|
||||
: accountId
|
||||
? { type: "account", id: accountId }
|
||||
: null;
|
||||
|
||||
if (!destination || (accountId && cardId)) {
|
||||
return { success: false, error: "Selecione uma conta ou cartão." };
|
||||
}
|
||||
|
||||
const payerIdsByRow = rows.map((row) => row.payerId ?? payerId ?? null);
|
||||
|
||||
@@ -109,8 +327,33 @@ export async function importTransactionsAction(
|
||||
if (!accountOk) return { success: false, error: "Conta não encontrada." };
|
||||
if (!cardOk) return { success: false, error: "Cartão não encontrado." };
|
||||
|
||||
if (rows.length === 0) {
|
||||
return { success: true, imported: 0, skipped: 0, importBatchId: "" };
|
||||
const importMatches = await findExistingImports(
|
||||
userId,
|
||||
source,
|
||||
accountNumber,
|
||||
destination,
|
||||
rows,
|
||||
);
|
||||
const rowsToImport = rows.flatMap((row, index) => {
|
||||
const match = importMatches[index];
|
||||
return match?.existingTransactionId
|
||||
? []
|
||||
: [
|
||||
{
|
||||
row,
|
||||
payerId: payerIdsByRow[index],
|
||||
fingerprint: match?.fingerprint ?? null,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
if (rowsToImport.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
imported: 0,
|
||||
skipped: rows.length,
|
||||
importBatchId: "",
|
||||
};
|
||||
}
|
||||
|
||||
const importBatchId = crypto.randomUUID();
|
||||
@@ -118,7 +361,7 @@ export async function importTransactionsAction(
|
||||
// Cartão de crédito: fatura pode ainda não ter sido paga
|
||||
const isSettled = paymentMethod !== "Cartão de crédito";
|
||||
|
||||
const records = rows.map((row, index) => {
|
||||
const records = rowsToImport.map(({ row, payerId, fingerprint }) => {
|
||||
const purchaseDate = parseLocalDateString(row.date);
|
||||
const period =
|
||||
invoicePeriod ??
|
||||
@@ -137,21 +380,24 @@ export async function importTransactionsAction(
|
||||
period,
|
||||
isSettled,
|
||||
userId,
|
||||
payerId: payerIdsByRow[index],
|
||||
payerId,
|
||||
accountId: accountId ?? null,
|
||||
cardId: cardId ?? null,
|
||||
categoryId: row.categoryId ?? null,
|
||||
ofxFitId: row.externalId,
|
||||
ofxImportFingerprint: fingerprint,
|
||||
importBatchId,
|
||||
};
|
||||
});
|
||||
|
||||
// onConflictDoNothing usa o uniqueIndex (userId, ofxFitId) WHERE ofxFitId IS NOT NULL
|
||||
// eliminando o SELECT prévio de checkDuplicateFitIds
|
||||
// O índice de fingerprint protege contra importações concorrentes do mesmo OFX.
|
||||
const inserted = await db
|
||||
.insert(transactions)
|
||||
.values(records)
|
||||
.onConflictDoNothing()
|
||||
.onConflictDoNothing({
|
||||
target: [transactions.userId, transactions.ofxImportFingerprint],
|
||||
where: sql`ofx_import_fingerprint IS NOT NULL`,
|
||||
})
|
||||
.returning({ id: transactions.id });
|
||||
|
||||
await revalidateForEntity("transactions", userId);
|
||||
@@ -159,23 +405,31 @@ export async function importTransactionsAction(
|
||||
return {
|
||||
success: true,
|
||||
imported: inserted.length,
|
||||
skipped: records.length - inserted.length,
|
||||
skipped: rows.length - inserted.length,
|
||||
importBatchId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteTransactionByFitId(
|
||||
fitId: string,
|
||||
export async function deleteImportedTransaction(
|
||||
transactionId: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
if (!fitId) return { success: false, error: "FITID inválido." };
|
||||
const parsedId = uuidSchema("Lançamento").safeParse(transactionId);
|
||||
if (!parsedId.success) {
|
||||
return { success: false, error: "Lançamento inválido." };
|
||||
}
|
||||
|
||||
const userId = await getUserId();
|
||||
|
||||
await db
|
||||
const deleted = await db
|
||||
.delete(transactions)
|
||||
.where(
|
||||
and(eq(transactions.userId, userId), eq(transactions.ofxFitId, fitId)),
|
||||
);
|
||||
and(eq(transactions.userId, userId), eq(transactions.id, parsedId.data)),
|
||||
)
|
||||
.returning({ id: transactions.id });
|
||||
|
||||
if (deleted.length === 0) {
|
||||
return { success: false, error: "Lançamento não encontrado." };
|
||||
}
|
||||
|
||||
await revalidateForEntity("transactions", userId);
|
||||
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useTransition,
|
||||
} from "react";
|
||||
import { useCallback, useMemo, useRef, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
fetchCategoryMappings,
|
||||
saveCategoryMappings,
|
||||
} from "@/features/transactions/actions/category-memory-action";
|
||||
import {
|
||||
checkDuplicateFitIds,
|
||||
deleteTransactionByFitId,
|
||||
checkDuplicateOfxTransactions,
|
||||
deleteImportedTransaction,
|
||||
importTransactionsAction,
|
||||
undoImportAction,
|
||||
} from "@/features/transactions/actions/import-action";
|
||||
@@ -43,6 +37,7 @@ import {
|
||||
} from "@/shared/components/ui/card";
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||
import type { ImportStatement } from "@/shared/lib/import/types";
|
||||
import { createClientSafeId } from "@/shared/utils/id";
|
||||
|
||||
const categoryGroupByTransactionType = {
|
||||
expense: "despesa",
|
||||
@@ -69,6 +64,7 @@ export function ImportPage({
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const duplicateCheckRequestId = useRef(0);
|
||||
|
||||
const [statement, setStatement] = useState<ImportStatement | null>(null);
|
||||
const [rows, setRows] = useState<ReviewRow[]>([]);
|
||||
@@ -95,23 +91,45 @@ export function ImportPage({
|
||||
|
||||
const handleParsed = useCallback(
|
||||
async (stmt: ImportStatement) => {
|
||||
const requestId = ++duplicateCheckRequestId.current;
|
||||
setStatement(stmt);
|
||||
setIsChecking(true);
|
||||
const defaultAccountCardValue = stmt.isCreditCard
|
||||
? cardOptions[0]
|
||||
? encodeAccountCard("card", cardOptions[0].value)
|
||||
: null
|
||||
: accountOptions[0]
|
||||
? encodeAccountCard("account", accountOptions[0].value)
|
||||
: null;
|
||||
const destination = defaultAccountCardValue
|
||||
? decodeAccountCard(defaultAccountCardValue)
|
||||
: null;
|
||||
setAccountCardValue(defaultAccountCardValue);
|
||||
|
||||
try {
|
||||
const fitIds = stmt.transactions
|
||||
.map((t) => t.externalId)
|
||||
.filter((id): id is string => id !== null);
|
||||
|
||||
const [duplicates, categoryMappings] = await Promise.all([
|
||||
checkDuplicateFitIds(fitIds).then((ids) => new Set(ids)),
|
||||
const [duplicateResult, categoryMappings] = await Promise.all([
|
||||
destination
|
||||
? checkDuplicateOfxTransactions({
|
||||
source: stmt.source,
|
||||
accountNumber: stmt.accountNumber,
|
||||
destination,
|
||||
rows: stmt.transactions,
|
||||
})
|
||||
: Promise.resolve({ success: true as const, rows: [] }),
|
||||
fetchCategoryMappings(stmt.transactions.map((t) => t.description)),
|
||||
]);
|
||||
if (requestId !== duplicateCheckRequestId.current) return;
|
||||
if (!duplicateResult.success) {
|
||||
toast.error(duplicateResult.error);
|
||||
}
|
||||
|
||||
setRows(
|
||||
stmt.transactions.map((t) => {
|
||||
stmt.transactions.map((t, index) => {
|
||||
let mappedCategoryId =
|
||||
categoryMappings[normalizeDescriptionKey(t.description)] ?? null;
|
||||
const existingTransactionId = duplicateResult.success
|
||||
? (duplicateResult.rows[index]?.existingTransactionId ?? null)
|
||||
: null;
|
||||
|
||||
if (t.categoryRaw) {
|
||||
const categoryRaw = normalizeCategoryName(t.categoryRaw);
|
||||
@@ -125,8 +143,10 @@ export function ImportPage({
|
||||
|
||||
return {
|
||||
...t,
|
||||
isDuplicate: t.externalId ? duplicates.has(t.externalId) : false,
|
||||
selected: t.externalId ? !duplicates.has(t.externalId) : true,
|
||||
reviewId: createClientSafeId(),
|
||||
existingTransactionId,
|
||||
isDuplicate: existingTransactionId !== null,
|
||||
selected: existingTransactionId === null,
|
||||
payerId,
|
||||
categoryId: isCategoryCompatible(
|
||||
mappedCategoryId,
|
||||
@@ -138,23 +158,64 @@ export function ImportPage({
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
if (requestId === duplicateCheckRequestId.current) {
|
||||
setIsChecking(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[isCategoryCompatible, payerId, categoryOptions],
|
||||
[
|
||||
accountOptions,
|
||||
cardOptions,
|
||||
categoryOptions,
|
||||
isCategoryCompatible,
|
||||
payerId,
|
||||
],
|
||||
);
|
||||
|
||||
// Pré-seleciona cartão ou conta com base no tipo detectado no OFX
|
||||
useEffect(() => {
|
||||
if (!statement || accountCardValue) return;
|
||||
if (statement.isCreditCard && cardOptions[0]) {
|
||||
setAccountCardValue(encodeAccountCard("card", cardOptions[0].value));
|
||||
} else if (!statement.isCreditCard && accountOptions[0]) {
|
||||
setAccountCardValue(
|
||||
encodeAccountCard("account", accountOptions[0].value),
|
||||
);
|
||||
const handleAccountCardChange = async (value: string | null) => {
|
||||
const requestId = ++duplicateCheckRequestId.current;
|
||||
setAccountCardValue(value);
|
||||
const destination = value ? decodeAccountCard(value) : null;
|
||||
if (!statement || !destination) {
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
}, [statement, cardOptions, accountOptions, accountCardValue]);
|
||||
|
||||
setIsChecking(true);
|
||||
try {
|
||||
const result = await checkDuplicateOfxTransactions({
|
||||
source: statement.source,
|
||||
accountNumber: statement.accountNumber,
|
||||
destination,
|
||||
rows,
|
||||
});
|
||||
if (requestId !== duplicateCheckRequestId.current) return;
|
||||
if (!result.success) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setRows((previousRows) =>
|
||||
previousRows.map((row, index) => {
|
||||
const existingTransactionId =
|
||||
result.rows[index]?.existingTransactionId ?? null;
|
||||
const isDuplicate = existingTransactionId !== null;
|
||||
|
||||
return {
|
||||
...row,
|
||||
existingTransactionId,
|
||||
selected:
|
||||
row.isDuplicate === isDuplicate ? row.selected : !isDuplicate,
|
||||
isDuplicate,
|
||||
};
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
if (requestId === duplicateCheckRequestId.current) {
|
||||
setIsChecking(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRow = (index: number) => {
|
||||
setRows((prev) =>
|
||||
@@ -184,9 +245,9 @@ export function ImportPage({
|
||||
|
||||
const handleUndoDuplicate = async (index: number) => {
|
||||
const row = rows[index];
|
||||
if (!row?.externalId) return;
|
||||
if (!row?.existingTransactionId) return;
|
||||
|
||||
const result = await deleteTransactionByFitId(row.externalId);
|
||||
const result = await deleteImportedTransaction(row.existingTransactionId);
|
||||
if (!result.success) {
|
||||
toast.error("Não foi possível desfazer a importação anterior.");
|
||||
return;
|
||||
@@ -194,7 +255,14 @@ export function ImportPage({
|
||||
|
||||
setRows((prev) =>
|
||||
prev.map((r, i) =>
|
||||
i === index ? { ...r, isDuplicate: false, selected: true } : r,
|
||||
i === index
|
||||
? {
|
||||
...r,
|
||||
existingTransactionId: null,
|
||||
isDuplicate: false,
|
||||
selected: true,
|
||||
}
|
||||
: r,
|
||||
),
|
||||
);
|
||||
toast.success("Importação anterior removida.");
|
||||
@@ -261,11 +329,15 @@ export function ImportPage({
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await importTransactionsAction({
|
||||
source: statement.source,
|
||||
accountNumber: statement.accountNumber,
|
||||
rows: selectedRows.map((r) => ({
|
||||
externalId: r.externalId,
|
||||
externalIdOccurrence: r.externalIdOccurrence,
|
||||
date: r.date,
|
||||
amount: r.amount,
|
||||
description: r.description,
|
||||
sourceDescription: r.sourceDescription,
|
||||
transactionType: r.transactionType,
|
||||
categoryId: r.categoryId,
|
||||
payerId: r.payerId,
|
||||
@@ -369,7 +441,7 @@ export function ImportPage({
|
||||
accountCardValue={accountCardValue}
|
||||
payerId={payerId}
|
||||
invoicePeriod={invoicePeriod}
|
||||
onAccountCardChange={setAccountCardValue}
|
||||
onAccountCardChange={handleAccountCardChange}
|
||||
onPayerChange={handleBulkPayerChange}
|
||||
onInvoicePeriodChange={setInvoicePeriod}
|
||||
onBulkCategoryChange={handleBulkCategoryChange}
|
||||
|
||||
@@ -43,8 +43,10 @@ const categoryGroupByTransactionType: Record<
|
||||
};
|
||||
|
||||
export type ReviewRow = ImportedTransaction & {
|
||||
reviewId: string;
|
||||
selected: boolean;
|
||||
isDuplicate: boolean;
|
||||
existingTransactionId: string | null;
|
||||
categoryId: string | null;
|
||||
payerId: string | null;
|
||||
};
|
||||
@@ -80,6 +82,7 @@ export function ReviewTable({
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
getItemKey: (index) => rows[index]?.reviewId ?? index,
|
||||
estimateSize: () => 44,
|
||||
overscan: 8,
|
||||
});
|
||||
@@ -141,7 +144,7 @@ export function ReviewTable({
|
||||
);
|
||||
return (
|
||||
<TableRow
|
||||
key={row.externalId ?? `${row.date}-${index}`}
|
||||
key={row.reviewId}
|
||||
className={
|
||||
row.isDuplicate && !row.selected ? "opacity-50" : ""
|
||||
}
|
||||
|
||||
24
src/features/transactions/lib/ofx-import-fingerprint.ts
Normal file
24
src/features/transactions/lib/ofx-import-fingerprint.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import "server-only";
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
buildOfxFingerprintPayload,
|
||||
type OfxIdentityRow,
|
||||
type OfxImportDestination,
|
||||
} from "@/shared/lib/import/ofx-identity";
|
||||
|
||||
type CreateOfxImportFingerprintInput = {
|
||||
source: string;
|
||||
accountNumber: string | null;
|
||||
destination: OfxImportDestination;
|
||||
row: OfxIdentityRow;
|
||||
};
|
||||
|
||||
export function createOfxImportFingerprint(
|
||||
input: CreateOfxImportFingerprintInput,
|
||||
): string | null {
|
||||
const payload = buildOfxFingerprintPayload(input);
|
||||
if (!payload) return null;
|
||||
|
||||
return createHash("sha256").update(payload).digest("hex");
|
||||
}
|
||||
38
src/shared/components/feedback/content-error-boundary.tsx
Normal file
38
src/shared/components/feedback/content-error-boundary.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { RiErrorWarningLine } from "@remixicon/react";
|
||||
import { catchError, type ErrorInfo } from "next/error";
|
||||
import { EmptyState } from "@/shared/components/feedback/empty-state";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
|
||||
type ContentErrorBoundaryProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
function ContentErrorFallback(
|
||||
{ title, description }: ContentErrorBoundaryProps,
|
||||
{ retry }: ErrorInfo,
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex min-h-64 w-full items-center justify-center"
|
||||
>
|
||||
<EmptyState
|
||||
title={title}
|
||||
description={description}
|
||||
media={<RiErrorWarningLine className="size-5 text-destructive" />}
|
||||
mediaVariant="icon"
|
||||
className="min-h-64 border border-dashed"
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={retry}>
|
||||
Tentar novamente
|
||||
</Button>
|
||||
</EmptyState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ContentErrorBoundary =
|
||||
catchError<ContentErrorBoundaryProps>(ContentErrorFallback);
|
||||
69
src/shared/lib/import/ofx-identity.ts
Normal file
69
src/shared/lib/import/ofx-identity.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
|
||||
|
||||
export type OfxImportDestination = {
|
||||
type: "account" | "card";
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type OfxIdentityRow = {
|
||||
externalId: string | null;
|
||||
externalIdOccurrence: number;
|
||||
date: string;
|
||||
amount: number;
|
||||
transactionType: "income" | "expense";
|
||||
sourceDescription: string;
|
||||
};
|
||||
|
||||
type OfxIdentityInput = {
|
||||
source: string;
|
||||
accountNumber: string | null;
|
||||
destination: OfxImportDestination;
|
||||
row: OfxIdentityRow;
|
||||
};
|
||||
|
||||
export function normalizeOfxIdentityText(value: string): string {
|
||||
return value.normalize("NFKC").trim().replace(/\s+/g, " ").toLowerCase();
|
||||
}
|
||||
|
||||
export function buildOfxOccurrenceKey(
|
||||
row: Omit<OfxIdentityRow, "externalIdOccurrence">,
|
||||
): string | null {
|
||||
if (!row.externalId) return null;
|
||||
|
||||
const signedAmount =
|
||||
row.transactionType === "expense" ? -row.amount : row.amount;
|
||||
|
||||
return JSON.stringify([
|
||||
row.externalId.trim(),
|
||||
row.date,
|
||||
formatDecimalForDbRequired(signedAmount),
|
||||
row.transactionType,
|
||||
normalizeOfxIdentityText(row.sourceDescription),
|
||||
]);
|
||||
}
|
||||
|
||||
export function buildOfxFingerprintPayload({
|
||||
source,
|
||||
accountNumber,
|
||||
destination,
|
||||
row,
|
||||
}: OfxIdentityInput): string | null {
|
||||
if (!row.externalId) return null;
|
||||
|
||||
const signedAmount =
|
||||
row.transactionType === "expense" ? -row.amount : row.amount;
|
||||
|
||||
return JSON.stringify([
|
||||
"openmonetis-ofx-v1",
|
||||
normalizeOfxIdentityText(source),
|
||||
normalizeOfxIdentityText(accountNumber ?? ""),
|
||||
destination.type,
|
||||
destination.id,
|
||||
row.externalId.trim(),
|
||||
row.date,
|
||||
formatDecimalForDbRequired(signedAmount),
|
||||
row.transactionType,
|
||||
normalizeOfxIdentityText(row.sourceDescription),
|
||||
row.externalIdOccurrence,
|
||||
]);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { buildOfxOccurrenceKey } from "./ofx-identity";
|
||||
import type { ImportedTransaction, ImportStatement } from "./types";
|
||||
|
||||
// Extrai o valor de uma tag leaf do OFX SGML: <TAG>valor
|
||||
@@ -32,6 +33,7 @@ export function parseOfx(content: string): ImportStatement {
|
||||
|
||||
// Transações
|
||||
const blocks = xml.match(/<STMTTRN>[\s\S]*?<\/STMTTRN>/g) ?? [];
|
||||
const occurrenceCounts = new Map<string, number>();
|
||||
const transactions: ImportedTransaction[] = blocks.map((block) => {
|
||||
const trnType = getField(block, "TRNTYPE") ?? "DEBIT";
|
||||
const dtPosted = getField(block, "DTPOSTED") ?? "";
|
||||
@@ -41,16 +43,30 @@ export function parseOfx(content: string): ImportStatement {
|
||||
const name = getField(block, "NAME");
|
||||
|
||||
const amount = Number.parseFloat(trnAmt.replace(",", "."));
|
||||
const transactionType =
|
||||
const transactionType: ImportedTransaction["transactionType"] =
|
||||
amount > 0 || trnType === "CREDIT" ? "income" : "expense";
|
||||
|
||||
return {
|
||||
const description = memo ?? name ?? "";
|
||||
const transaction = {
|
||||
externalId: fitId,
|
||||
date: parseOfxDate(dtPosted),
|
||||
amount: Math.abs(amount),
|
||||
description: memo ?? name ?? "",
|
||||
description,
|
||||
sourceDescription: description,
|
||||
transactionType,
|
||||
};
|
||||
const occurrenceKey = buildOfxOccurrenceKey(transaction);
|
||||
const externalIdOccurrence = occurrenceKey
|
||||
? (occurrenceCounts.get(occurrenceKey) ?? 0)
|
||||
: 0;
|
||||
|
||||
if (occurrenceKey) {
|
||||
occurrenceCounts.set(occurrenceKey, externalIdOccurrence + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
...transaction,
|
||||
externalIdOccurrence,
|
||||
};
|
||||
});
|
||||
|
||||
const isCreditCard = xml.includes("<CREDITCARDMSGSRSV1>");
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
export type ImportedTransaction = {
|
||||
externalId: string | null; // FITID do OFX
|
||||
externalIdOccurrence: number; // posição entre registros OFX idênticos
|
||||
date: string; // YYYY-MM-DD
|
||||
amount: number; // positivo = receita, negativo = despesa
|
||||
description: string; // MEMO ou NAME
|
||||
sourceDescription: string; // descrição original, preservada para deduplicação
|
||||
transactionType: "income" | "expense";
|
||||
categoryRaw?: string | null;
|
||||
};
|
||||
|
||||
@@ -105,9 +105,11 @@ export async function parseXls(buffer: ArrayBuffer): Promise<ImportStatement> {
|
||||
|
||||
transactions.push({
|
||||
externalId: null,
|
||||
externalIdOccurrence: 0,
|
||||
date,
|
||||
amount,
|
||||
description,
|
||||
sourceDescription: description,
|
||||
transactionType,
|
||||
categoryRaw,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user