feat(dados-client): adotar react query em leituras do app

This commit is contained in:
Felipe Coutinho
2026-04-03 18:10:34 +00:00
parent e4c6a91350
commit acaf9d5c27
14 changed files with 409 additions and 150 deletions

View File

@@ -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>

View File

@@ -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}

View File

@@ -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 };
} }

View File

@@ -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">

View 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,
});
}

View 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(),
};
}

View File

@@ -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}
/> />

View File

@@ -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>

View File

@@ -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 () => {

View File

@@ -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,
});
}

View File

@@ -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,
});
}

View File

@@ -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";

View 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>
);
}

View 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>;
}