mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
feat(dados-client): adotar react query em leituras do app
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import { QueryProvider } from "@/shared/components/providers/query-provider";
|
||||||
import { ThemeProvider } from "@/shared/components/providers/theme-provider";
|
import { ThemeProvider } from "@/shared/components/providers/theme-provider";
|
||||||
import { Toaster } from "@/shared/components/ui/sonner";
|
import { Toaster } from "@/shared/components/ui/sonner";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
@@ -21,6 +22,7 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
|
data-scroll-behavior="smooth"
|
||||||
lang="pt-BR"
|
lang="pt-BR"
|
||||||
className={`${america.variable} ${america.className} `}
|
className={`${america.variable} ${america.className} `}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
@@ -36,8 +38,10 @@ export default function RootLayout({
|
|||||||
</head>
|
</head>
|
||||||
<body className="antialiased" suppressHydrationWarning>
|
<body className="antialiased" suppressHydrationWarning>
|
||||||
<ThemeProvider attribute="class" defaultTheme="light">
|
<ThemeProvider attribute="class" defaultTheme="light">
|
||||||
<Suspense>{children}</Suspense>
|
<QueryProvider>
|
||||||
<Toaster position="top-right" />
|
<Suspense>{children}</Suspense>
|
||||||
|
<Toaster position="top-right" />
|
||||||
|
</QueryProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
RiExternalLinkLine,
|
RiExternalLinkLine,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useAttachmentUrlQuery } from "@/features/attachments/hooks/use-attachment-url";
|
||||||
import type { AttachmentForPeriod } from "@/features/attachments/queries";
|
import type { AttachmentForPeriod } from "@/features/attachments/queries";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -30,7 +31,6 @@ export function AttachmentPreview({
|
|||||||
onClose,
|
onClose,
|
||||||
}: AttachmentPreviewProps) {
|
}: AttachmentPreviewProps) {
|
||||||
const [currentIndex, setCurrentIndex] = useState(selectedIndex);
|
const [currentIndex, setCurrentIndex] = useState(selectedIndex);
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
|
||||||
const open = selectedIndex >= 0;
|
const open = selectedIndex >= 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -52,17 +52,11 @@ export function AttachmentPreview({
|
|||||||
|
|
||||||
const attachment = attachments[currentIndex];
|
const attachment = attachments[currentIndex];
|
||||||
const attachmentId = attachment?.attachmentId;
|
const attachmentId = attachment?.attachmentId;
|
||||||
|
const {
|
||||||
// Busca URL fresca a cada troca de anexo
|
data: previewUrl,
|
||||||
useEffect(() => {
|
isLoading: isPreviewLoading,
|
||||||
if (!attachmentId) return;
|
isError: isPreviewError,
|
||||||
setPreviewUrl(null);
|
} = useAttachmentUrlQuery(attachmentId ?? "", open && Boolean(attachmentId));
|
||||||
|
|
||||||
fetch(`/api/attachments/${attachmentId}/presign`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data: { url: string }) => setPreviewUrl(data.url))
|
|
||||||
.catch(() => {});
|
|
||||||
}, [attachmentId]);
|
|
||||||
|
|
||||||
if (!attachment) return null;
|
if (!attachment) return null;
|
||||||
|
|
||||||
@@ -170,11 +164,16 @@ export function AttachmentPreview({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="min-h-0 min-w-0 flex-1">
|
<div className="min-h-0 min-w-0 flex-1">
|
||||||
{!previewUrl && (
|
{isPreviewLoading && (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-foreground" />
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isPreviewError && (
|
||||||
|
<div className="flex h-full w-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
|
||||||
|
Não foi possível carregar a visualização deste anexo.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isPdf && previewUrl && (
|
{isPdf && previewUrl && (
|
||||||
<iframe
|
<iframe
|
||||||
key={attachment.attachmentId}
|
key={attachment.attachmentId}
|
||||||
|
|||||||
@@ -1,13 +1,37 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { fetchJson } from "@/shared/lib/fetch-json";
|
||||||
|
|
||||||
|
const ATTACHMENT_URL_STALE_TIME = 4 * 60 * 1000;
|
||||||
|
|
||||||
|
export const attachmentUrlQueryKey = (attachmentId: string) =>
|
||||||
|
["attachments", "url", attachmentId] as const;
|
||||||
|
|
||||||
|
export function useAttachmentUrlQuery(attachmentId: string, enabled: boolean) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: attachmentUrlQueryKey(attachmentId),
|
||||||
|
queryFn: async () => {
|
||||||
|
const payload = await fetchJson<{ url: string }>(
|
||||||
|
`/api/attachments/${attachmentId}/presign`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return payload.url;
|
||||||
|
},
|
||||||
|
enabled: enabled && Boolean(attachmentId),
|
||||||
|
staleTime: ATTACHMENT_URL_STALE_TIME,
|
||||||
|
gcTime: ATTACHMENT_URL_STALE_TIME * 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useAttachmentUrl(attachmentId: string) {
|
export function useAttachmentUrl(attachmentId: string) {
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUrl(null);
|
void attachmentId;
|
||||||
|
setIsVisible(false);
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
@@ -15,10 +39,7 @@ export function useAttachmentUrl(attachmentId: string) {
|
|||||||
(entries) => {
|
(entries) => {
|
||||||
if (!entries[0].isIntersecting) return;
|
if (!entries[0].isIntersecting) return;
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
fetch(`/api/attachments/${attachmentId}/presign`)
|
setIsVisible(true);
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data: { url: string }) => setUrl(data.url))
|
|
||||||
.catch(() => {});
|
|
||||||
},
|
},
|
||||||
{ rootMargin: "150px" },
|
{ rootMargin: "150px" },
|
||||||
);
|
);
|
||||||
@@ -27,5 +48,7 @@ export function useAttachmentUrl(attachmentId: string) {
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [attachmentId]);
|
}, [attachmentId]);
|
||||||
|
|
||||||
return { url, containerRef };
|
const { data: url } = useAttachmentUrlQuery(attachmentId, isVisible);
|
||||||
|
|
||||||
|
return { url: url ?? null, containerRef };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
RiSaveLine,
|
RiSaveLine,
|
||||||
RiSparklingLine,
|
RiSparklingLine,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
@@ -13,10 +14,13 @@ import { toast } from "sonner";
|
|||||||
import {
|
import {
|
||||||
deleteSavedInsightsAction,
|
deleteSavedInsightsAction,
|
||||||
generateInsightsAction,
|
generateInsightsAction,
|
||||||
loadSavedInsightsAction,
|
|
||||||
saveInsightsAction,
|
saveInsightsAction,
|
||||||
} from "@/features/insights/actions";
|
} from "@/features/insights/actions";
|
||||||
import { DEFAULT_MODEL } from "@/features/insights/constants";
|
import { DEFAULT_MODEL } from "@/features/insights/constants";
|
||||||
|
import {
|
||||||
|
savedInsightsQueryKey,
|
||||||
|
useSavedInsights,
|
||||||
|
} from "@/features/insights/hooks/use-saved-insights";
|
||||||
import { EmptyState } from "@/shared/components/empty-state";
|
import { EmptyState } from "@/shared/components/empty-state";
|
||||||
import { Alert, AlertDescription } from "@/shared/components/ui/alert";
|
import { Alert, AlertDescription } from "@/shared/components/ui/alert";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
@@ -32,47 +36,47 @@ interface InsightsPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
||||||
const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL);
|
const queryClient = useQueryClient();
|
||||||
const [insights, setInsights] = useState<InsightsResponse | null>(null);
|
const savedInsightsQuery = useSavedInsights(period);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [isSaving, startSaveTransition] = useTransition();
|
const [isSaving, startSaveTransition] = useTransition();
|
||||||
|
const [draftInsights, setDraftInsights] = useState<InsightsResponse | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [selectedModelOverride, setSelectedModelOverride] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isSaved, setIsSaved] = useState(false);
|
const savedInsights = savedInsightsQuery.data ?? null;
|
||||||
const [savedDate, setSavedDate] = useState<Date | null>(null);
|
const insights = draftInsights ?? savedInsights?.insights ?? null;
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const selectedModel =
|
||||||
|
selectedModelOverride ?? savedInsights?.modelId ?? DEFAULT_MODEL;
|
||||||
|
const isSaved = draftInsights === null && savedInsights !== null;
|
||||||
|
const savedDate = isSaved ? (savedInsights?.createdAt ?? null) : null;
|
||||||
|
const isLoadingSavedInsights =
|
||||||
|
savedInsightsQuery.isLoading && draftInsights === null;
|
||||||
|
const savedInsightsError =
|
||||||
|
draftInsights === null && savedInsightsQuery.error instanceof Error
|
||||||
|
? savedInsightsQuery.error.message
|
||||||
|
: null;
|
||||||
|
|
||||||
// Carregar insights salvos ao montar o componente
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSaved = async () => {
|
void period;
|
||||||
try {
|
setDraftInsights(null);
|
||||||
const result = await loadSavedInsightsAction(period);
|
setSelectedModelOverride(null);
|
||||||
if (result.success && result.data) {
|
setError(null);
|
||||||
setInsights(result.data.insights);
|
|
||||||
setSelectedModel(result.data.modelId);
|
|
||||||
setIsSaved(true);
|
|
||||||
setSavedDate(result.data.createdAt);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error loading saved insights:", err);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadSaved();
|
|
||||||
}, [period]);
|
}, [period]);
|
||||||
|
|
||||||
const handleAnalyze = () => {
|
const handleAnalyze = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsSaved(false);
|
|
||||||
setSavedDate(null);
|
|
||||||
onAnalyze?.();
|
onAnalyze?.();
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await generateInsightsAction(period, selectedModel);
|
const result = await generateInsightsAction(period, selectedModel);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setInsights(result.data);
|
setDraftInsights(result.data);
|
||||||
|
setSelectedModelOverride(selectedModel);
|
||||||
toast.success("Insights gerados com sucesso!");
|
toast.success("Insights gerados com sucesso!");
|
||||||
} else {
|
} else {
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
@@ -99,8 +103,13 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setIsSaved(true);
|
queryClient.setQueryData(savedInsightsQueryKey(period), {
|
||||||
setSavedDate(result.data.createdAt);
|
insights,
|
||||||
|
modelId: selectedModel,
|
||||||
|
createdAt: result.data.createdAt.toISOString(),
|
||||||
|
});
|
||||||
|
setDraftInsights(null);
|
||||||
|
setSelectedModelOverride(null);
|
||||||
toast.success("Análise salva com sucesso!");
|
toast.success("Análise salva com sucesso!");
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
@@ -113,13 +122,16 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
|
if (!insights) return;
|
||||||
|
|
||||||
startSaveTransition(async () => {
|
startSaveTransition(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await deleteSavedInsightsAction(period);
|
const result = await deleteSavedInsightsAction(period);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setIsSaved(false);
|
queryClient.setQueryData(savedInsightsQueryKey(period), null);
|
||||||
setSavedDate(null);
|
setDraftInsights(insights);
|
||||||
|
setSelectedModelOverride(selectedModel);
|
||||||
toast.success("Análise removida com sucesso!");
|
toast.success("Análise removida com sucesso!");
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
@@ -148,7 +160,7 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
|||||||
{/* Model Selector */}
|
{/* Model Selector */}
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
value={selectedModel}
|
value={selectedModel}
|
||||||
onValueChange={setSelectedModel}
|
onValueChange={setSelectedModelOverride}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -156,7 +168,7 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
|||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleAnalyze}
|
onClick={handleAnalyze}
|
||||||
disabled={isPending || isLoading}
|
disabled={isPending || isLoadingSavedInsights}
|
||||||
className="bg-linear-to-r from-primary via-violet-400 to-cyan-400 dark:from-primary-dark dark:to-cyan-600"
|
className="bg-linear-to-r from-primary via-violet-400 to-cyan-400 dark:from-primary-dark dark:to-cyan-600"
|
||||||
>
|
>
|
||||||
<RiSparklingLine className="mr-2 size-5" aria-hidden="true" />
|
<RiSparklingLine className="mr-2 size-5" aria-hidden="true" />
|
||||||
@@ -166,7 +178,7 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
|||||||
{insights && !error && (
|
{insights && !error && (
|
||||||
<Button
|
<Button
|
||||||
onClick={isSaved ? handleDelete : handleSave}
|
onClick={isSaved ? handleDelete : handleSave}
|
||||||
disabled={isSaving || isPending || isLoading}
|
disabled={isSaving || isPending || isLoadingSavedInsights}
|
||||||
variant={isSaved ? "destructive" : "outline"}
|
variant={isSaved ? "destructive" : "outline"}
|
||||||
>
|
>
|
||||||
{isSaved ? (
|
{isSaved ? (
|
||||||
@@ -195,23 +207,43 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
|||||||
|
|
||||||
{/* Content Area */}
|
{/* Content Area */}
|
||||||
<div className="min-h-[400px]">
|
<div className="min-h-[400px]">
|
||||||
{(isPending || isLoading) && <LoadingState />}
|
{(isPending || isLoadingSavedInsights) && <LoadingState />}
|
||||||
{!isPending && !isLoading && !insights && !error && (
|
{!isPending &&
|
||||||
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
|
!isLoadingSavedInsights &&
|
||||||
<EmptyState
|
!insights &&
|
||||||
media={<RiSparklingLine className="size-6 text-primary" />}
|
!error &&
|
||||||
title="Nenhuma análise realizada"
|
!savedInsightsError && (
|
||||||
description="Clique no botão acima para gerar insights inteligentes sobre seus
|
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
|
||||||
|
<EmptyState
|
||||||
|
media={<RiSparklingLine className="size-6 text-primary" />}
|
||||||
|
title="Nenhuma análise realizada"
|
||||||
|
description="Clique no botão acima para gerar insights inteligentes sobre seus
|
||||||
dados financeiros do mês selecionado."
|
dados financeiros do mês selecionado."
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{!isPending && !isLoadingSavedInsights && error && (
|
||||||
|
<ErrorState
|
||||||
|
title="Erro ao gerar insights"
|
||||||
|
error={error}
|
||||||
|
onRetry={handleAnalyze}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isPending &&
|
||||||
|
!isLoadingSavedInsights &&
|
||||||
|
!error &&
|
||||||
|
savedInsightsError && (
|
||||||
|
<ErrorState
|
||||||
|
title="Erro ao carregar insights salvos"
|
||||||
|
error={savedInsightsError}
|
||||||
|
onRetry={() => void savedInsightsQuery.refetch()}
|
||||||
/>
|
/>
|
||||||
</Card>
|
)}
|
||||||
)}
|
{!isPending &&
|
||||||
{!isPending && !isLoading && error && (
|
!isLoadingSavedInsights &&
|
||||||
<ErrorState error={error} onRetry={handleAnalyze} />
|
insights &&
|
||||||
)}
|
!error &&
|
||||||
{!isPending && !isLoading && insights && !error && (
|
!savedInsightsError && <InsightsGrid insights={insights} />}
|
||||||
<InsightsGrid insights={insights} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -258,18 +290,18 @@ function LoadingState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ErrorState({
|
function ErrorState({
|
||||||
|
title,
|
||||||
error,
|
error,
|
||||||
onRetry,
|
onRetry,
|
||||||
}: {
|
}: {
|
||||||
|
title: string;
|
||||||
error: string;
|
error: string;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-4 py-12 px-4 text-center">
|
<div className="flex flex-col items-center justify-center gap-4 py-12 px-4 text-center">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h3 className="text-lg font-medium text-destructive">
|
<h3 className="text-lg font-medium text-destructive">{title}</h3>
|
||||||
Erro ao gerar insights
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground max-w-md">{error}</p>
|
<p className="text-sm text-muted-foreground max-w-md">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={onRetry} variant="outline">
|
<Button onClick={onRetry} variant="outline">
|
||||||
|
|||||||
32
src/features/insights/hooks/use-saved-insights.ts
Normal file
32
src/features/insights/hooks/use-saved-insights.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { SavedInsightsRecord } from "@/features/insights/queries";
|
||||||
|
import { fetchJson } from "@/shared/lib/fetch-json";
|
||||||
|
import { InsightsResponseSchema } from "@/shared/lib/schemas/insights";
|
||||||
|
|
||||||
|
const savedInsightsRecordSchema = z.object({
|
||||||
|
insights: InsightsResponseSchema,
|
||||||
|
modelId: z.string().min(1),
|
||||||
|
createdAt: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const savedInsightsQueryKey = (period: string) =>
|
||||||
|
["insights", "saved", period] as const;
|
||||||
|
|
||||||
|
export function useSavedInsights(period: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: savedInsightsQueryKey(period),
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = new URLSearchParams({ period });
|
||||||
|
const payload = await fetchJson<SavedInsightsRecord | null>(
|
||||||
|
`/api/insights/saved?${params.toString()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return payload === null ? null : savedInsightsRecordSchema.parse(payload);
|
||||||
|
},
|
||||||
|
enabled: Boolean(period),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
49
src/features/insights/queries.ts
Normal file
49
src/features/insights/queries.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { savedInsights } from "@/db/schema";
|
||||||
|
import { db } from "@/shared/lib/db";
|
||||||
|
import {
|
||||||
|
type InsightsResponse,
|
||||||
|
InsightsResponseSchema,
|
||||||
|
} from "@/shared/lib/schemas/insights";
|
||||||
|
|
||||||
|
export const savedInsightsPeriodSchema = z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}$/, "Período inválido (formato esperado: YYYY-MM)");
|
||||||
|
|
||||||
|
export type SavedInsightsRecord = {
|
||||||
|
insights: InsightsResponse;
|
||||||
|
modelId: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchSavedInsights(
|
||||||
|
userId: string,
|
||||||
|
period: string,
|
||||||
|
): Promise<SavedInsightsRecord | null> {
|
||||||
|
const validatedPeriod = savedInsightsPeriodSchema.parse(period);
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.select()
|
||||||
|
.from(savedInsights)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(savedInsights.userId, userId),
|
||||||
|
eq(savedInsights.period, validatedPeriod),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = result[0];
|
||||||
|
const insights = InsightsResponseSchema.parse(JSON.parse(saved.data));
|
||||||
|
|
||||||
|
return {
|
||||||
|
insights,
|
||||||
|
modelId: saved.modelId,
|
||||||
|
createdAt: saved.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,21 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiFileAddLine } from "@remixicon/react";
|
import { RiFileAddLine } from "@remixicon/react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { fetchTransactionAttachmentsAction } from "@/features/transactions/actions/attachments";
|
import { useEffect } from "react";
|
||||||
|
import {
|
||||||
|
transactionAttachmentsQueryKey,
|
||||||
|
useTransactionAttachments,
|
||||||
|
} from "@/features/transactions/hooks/use-transaction-attachments";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { AttachmentItem } from "./attachment-item";
|
import { AttachmentItem } from "./attachment-item";
|
||||||
import { AttachmentUpload } from "./attachment-upload";
|
import { AttachmentUpload } from "./attachment-upload";
|
||||||
|
|
||||||
type AttachmentRow = {
|
|
||||||
attachmentId: string;
|
|
||||||
fileName: string;
|
|
||||||
fileSize: number;
|
|
||||||
mimeType: string;
|
|
||||||
createdAt: Date;
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface AttachmentSectionProps {
|
interface AttachmentSectionProps {
|
||||||
transactionId: string;
|
transactionId: string;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
@@ -41,28 +36,35 @@ export function AttachmentSection({
|
|||||||
onCancelPendingUpload,
|
onCancelPendingUpload,
|
||||||
maxSizeMb,
|
maxSizeMb,
|
||||||
}: AttachmentSectionProps) {
|
}: AttachmentSectionProps) {
|
||||||
const [items, setItems] = useState<AttachmentRow[]>([]);
|
const queryClient = useQueryClient();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const {
|
||||||
|
data: items = [],
|
||||||
const load = useCallback(async () => {
|
isLoading,
|
||||||
setIsLoading(true);
|
isError,
|
||||||
try {
|
} = useTransactionAttachments(transactionId);
|
||||||
const data = await fetchTransactionAttachmentsAction(transactionId);
|
|
||||||
setItems(data);
|
|
||||||
onLoaded?.(data.length);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [transactionId, onLoaded]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
onLoaded?.(items.length);
|
||||||
}, [load]);
|
}, [items.length, onLoaded]);
|
||||||
|
|
||||||
|
const invalidateAttachments = () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: transactionAttachmentsQueryKey(transactionId),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <p className="text-xs text-muted-foreground">Carregando...</p>;
|
return <p className="text-xs text-muted-foreground">Carregando...</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Não foi possível carregar os anexos.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const hasPendingUploads = (pendingUploadFiles?.length ?? 0) > 0;
|
const hasPendingUploads = (pendingUploadFiles?.length ?? 0) > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -82,7 +84,7 @@ export function AttachmentSection({
|
|||||||
fileSize={item.fileSize}
|
fileSize={item.fileSize}
|
||||||
mimeType={item.mimeType}
|
mimeType={item.mimeType}
|
||||||
url={item.url}
|
url={item.url}
|
||||||
onDeleted={load}
|
onDeleted={invalidateAttachments}
|
||||||
readonly={readonly}
|
readonly={readonly}
|
||||||
isPendingDelete={pendingDetachIds?.includes(item.attachmentId)}
|
isPendingDelete={pendingDetachIds?.includes(item.attachmentId)}
|
||||||
onPendingDelete={onPendingDetach}
|
onPendingDelete={onPendingDetach}
|
||||||
@@ -119,7 +121,7 @@ export function AttachmentSection({
|
|||||||
{!readonly && (
|
{!readonly && (
|
||||||
<AttachmentUpload
|
<AttachmentUpload
|
||||||
transactionId={transactionId}
|
transactionId={transactionId}
|
||||||
onUploaded={load}
|
onUploaded={invalidateAttachments}
|
||||||
onPendingUpload={onPendingUpload}
|
onPendingUpload={onPendingUpload}
|
||||||
maxSizeMb={maxSizeMb}
|
maxSizeMb={maxSizeMb}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiCalendarCheckLine, RiLoader4Line } from "@remixicon/react";
|
import { RiCalendarCheckLine, RiLoader4Line } from "@remixicon/react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import {
|
||||||
import { getInstallmentAnticipationsAction } from "@/features/transactions/anticipation-actions";
|
installmentAnticipationsQueryKey,
|
||||||
|
useInstallmentAnticipations,
|
||||||
|
} from "@/features/transactions/hooks/use-installment-anticipations";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -20,7 +23,6 @@ import {
|
|||||||
EmptyTitle,
|
EmptyTitle,
|
||||||
} from "@/shared/components/ui/empty";
|
} from "@/shared/components/ui/empty";
|
||||||
import { useControlledState } from "@/shared/hooks/use-controlled-state";
|
import { useControlledState } from "@/shared/hooks/use-controlled-state";
|
||||||
import type { InstallmentAnticipationWithRelations } from "@/shared/lib/installments/anticipation-types";
|
|
||||||
import { AnticipationCard } from "../../shared/anticipation-card";
|
import { AnticipationCard } from "../../shared/anticipation-card";
|
||||||
|
|
||||||
interface AnticipationHistoryDialogProps {
|
interface AnticipationHistoryDialogProps {
|
||||||
@@ -40,53 +42,23 @@ export function AnticipationHistoryDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onViewLancamento,
|
onViewLancamento,
|
||||||
}: AnticipationHistoryDialogProps) {
|
}: AnticipationHistoryDialogProps) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const queryClient = useQueryClient();
|
||||||
const [anticipations, setAnticipations] = useState<
|
|
||||||
InstallmentAnticipationWithRelations[]
|
|
||||||
>([]);
|
|
||||||
|
|
||||||
// Use controlled state hook for dialog open state
|
|
||||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||||
open,
|
open,
|
||||||
false,
|
false,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
);
|
);
|
||||||
|
const {
|
||||||
// Define loadAnticipations before it's used in useEffect
|
data: anticipations = [],
|
||||||
const loadAnticipations = useCallback(async () => {
|
isLoading,
|
||||||
setIsLoading(true);
|
isError,
|
||||||
|
refetch,
|
||||||
try {
|
} = useInstallmentAnticipations(seriesId, dialogOpen);
|
||||||
const result = await getInstallmentAnticipationsAction(seriesId);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
toast.error(
|
|
||||||
result.error || "Erro ao carregar histórico de antecipações",
|
|
||||||
);
|
|
||||||
setAnticipations([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAnticipations(result.data ?? []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Erro ao buscar antecipações:", error);
|
|
||||||
toast.error("Erro ao carregar histórico de antecipações");
|
|
||||||
setAnticipations([]);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [seriesId]);
|
|
||||||
|
|
||||||
// Buscar antecipações ao abrir o dialog
|
|
||||||
useEffect(() => {
|
|
||||||
if (dialogOpen) {
|
|
||||||
loadAnticipations();
|
|
||||||
}
|
|
||||||
}, [dialogOpen, loadAnticipations]);
|
|
||||||
|
|
||||||
const handleCanceled = () => {
|
const handleCanceled = () => {
|
||||||
// Recarregar lista após cancelamento
|
void queryClient.invalidateQueries({
|
||||||
loadAnticipations();
|
queryKey: installmentAnticipationsQueryKey(seriesId),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -106,6 +78,26 @@ export function AnticipationHistoryDialog({
|
|||||||
Carregando histórico...
|
Carregando histórico...
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<Empty>
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia variant="icon">
|
||||||
|
<RiCalendarCheckLine className="size-6 text-muted-foreground" />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>Não foi possível carregar</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
O histórico de antecipações não pôde ser carregado agora.
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="mx-auto"
|
||||||
|
onClick={() => void refetch()}
|
||||||
|
>
|
||||||
|
Tentar novamente
|
||||||
|
</Button>
|
||||||
|
</Empty>
|
||||||
) : anticipations.length === 0 ? (
|
) : anticipations.length === 0 ? (
|
||||||
<Empty>
|
<Empty>
|
||||||
<EmptyHeader>
|
<EmptyHeader>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ptBR } from "date-fns/locale";
|
|||||||
import { useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cancelInstallmentAnticipationAction } from "@/features/transactions/anticipation-actions";
|
import { cancelInstallmentAnticipationAction } from "@/features/transactions/anticipation-actions";
|
||||||
|
import type { InstallmentAnticipationListItem } from "@/features/transactions/hooks/use-installment-anticipations";
|
||||||
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
@@ -18,11 +19,10 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/shared/components/ui/card";
|
} from "@/shared/components/ui/card";
|
||||||
import type { InstallmentAnticipationWithRelations } from "@/shared/lib/installments/anticipation-types";
|
|
||||||
import { displayPeriod } from "@/shared/utils/period";
|
import { displayPeriod } from "@/shared/utils/period";
|
||||||
|
|
||||||
interface AnticipationCardProps {
|
interface AnticipationCardProps {
|
||||||
anticipation: InstallmentAnticipationWithRelations;
|
anticipation: InstallmentAnticipationListItem;
|
||||||
onViewLancamento?: (transactionId: string) => void;
|
onViewLancamento?: (transactionId: string) => void;
|
||||||
onCanceled?: () => void;
|
onCanceled?: () => void;
|
||||||
}
|
}
|
||||||
@@ -37,8 +37,8 @@ export function AnticipationCard({
|
|||||||
const isSettled = anticipation.transaction?.isSettled === true;
|
const isSettled = anticipation.transaction?.isSettled === true;
|
||||||
const canCancel = !isSettled;
|
const canCancel = !isSettled;
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
const formatDate = (date: string) => {
|
||||||
return format(date, "dd 'de' MMMM 'de' yyyy", { locale: ptBR });
|
return format(new Date(date), "dd 'de' MMMM 'de' yyyy", { locale: ptBR });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = async () => {
|
const handleCancel = async () => {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fetchJson } from "@/shared/lib/fetch-json";
|
||||||
|
|
||||||
|
const anticipationItemSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
anticipationPeriod: z.string().regex(/^\d{4}-\d{2}$/),
|
||||||
|
anticipationDate: z.string().min(1),
|
||||||
|
installmentCount: z.number().int(),
|
||||||
|
totalAmount: z.string(),
|
||||||
|
discount: z.string(),
|
||||||
|
transactionId: z.string().uuid(),
|
||||||
|
note: z.string().nullable(),
|
||||||
|
transaction: z
|
||||||
|
.object({
|
||||||
|
isSettled: z.boolean().nullable(),
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
payer: z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
category: z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InstallmentAnticipationListItem = z.infer<
|
||||||
|
typeof anticipationItemSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const installmentAnticipationsQueryKey = (seriesId: string) =>
|
||||||
|
["transactions", "installment-anticipations", seriesId] as const;
|
||||||
|
|
||||||
|
export function useInstallmentAnticipations(
|
||||||
|
seriesId: string,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: installmentAnticipationsQueryKey(seriesId),
|
||||||
|
queryFn: async () => {
|
||||||
|
const payload = await fetchJson<unknown>(
|
||||||
|
`/api/transactions/installments/${seriesId}/anticipations`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return z.array(anticipationItemSchema).parse(payload);
|
||||||
|
},
|
||||||
|
enabled: enabled && Boolean(seriesId),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { TransactionAttachmentListItem } from "@/features/transactions/attachment-queries";
|
||||||
|
import { fetchJson } from "@/shared/lib/fetch-json";
|
||||||
|
|
||||||
|
export const transactionAttachmentsQueryKey = (transactionId: string) =>
|
||||||
|
["transactions", "attachments", transactionId] as const;
|
||||||
|
|
||||||
|
export function useTransactionAttachments(transactionId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: transactionAttachmentsQueryKey(transactionId),
|
||||||
|
queryFn: () =>
|
||||||
|
fetchJson<TransactionAttachmentListItem[]>(
|
||||||
|
`/api/transactions/${transactionId}/attachments`,
|
||||||
|
),
|
||||||
|
enabled: Boolean(transactionId),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export { PrivacyProvider } from "./privacy-provider";
|
export { PrivacyProvider } from "./privacy-provider";
|
||||||
|
export { QueryProvider } from "./query-provider";
|
||||||
export { ThemeProvider } from "./theme-provider";
|
export { ThemeProvider } from "./theme-provider";
|
||||||
|
|||||||
23
src/shared/components/providers/query-provider.tsx
Normal file
23
src/shared/components/providers/query-provider.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
function makeQueryClient() {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [queryClient] = useState(makeQueryClient);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/shared/lib/fetch-json.ts
Normal file
26
src/shared/lib/fetch-json.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export async function fetchJson<T>(
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await fetch(input, {
|
||||||
|
cache: "no-store",
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = `Erro na requisição (${response.status})`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = (await response.json()) as { error?: string };
|
||||||
|
if (payload.error) {
|
||||||
|
message = payload.error;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user