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:
@@ -8,6 +8,7 @@ import {
|
||||
RiExternalLinkLine,
|
||||
} from "@remixicon/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAttachmentUrlQuery } from "@/features/attachments/hooks/use-attachment-url";
|
||||
import type { AttachmentForPeriod } from "@/features/attachments/queries";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
@@ -30,7 +31,6 @@ export function AttachmentPreview({
|
||||
onClose,
|
||||
}: AttachmentPreviewProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(selectedIndex);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const open = selectedIndex >= 0;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -52,17 +52,11 @@ export function AttachmentPreview({
|
||||
|
||||
const attachment = attachments[currentIndex];
|
||||
const attachmentId = attachment?.attachmentId;
|
||||
|
||||
// Busca URL fresca a cada troca de anexo
|
||||
useEffect(() => {
|
||||
if (!attachmentId) return;
|
||||
setPreviewUrl(null);
|
||||
|
||||
fetch(`/api/attachments/${attachmentId}/presign`)
|
||||
.then((r) => r.json())
|
||||
.then((data: { url: string }) => setPreviewUrl(data.url))
|
||||
.catch(() => {});
|
||||
}, [attachmentId]);
|
||||
const {
|
||||
data: previewUrl,
|
||||
isLoading: isPreviewLoading,
|
||||
isError: isPreviewError,
|
||||
} = useAttachmentUrlQuery(attachmentId ?? "", open && Boolean(attachmentId));
|
||||
|
||||
if (!attachment) return null;
|
||||
|
||||
@@ -170,11 +164,16 @@ export function AttachmentPreview({
|
||||
</DialogHeader>
|
||||
|
||||
<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="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-foreground" />
|
||||
</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 && (
|
||||
<iframe
|
||||
key={attachment.attachmentId}
|
||||
|
||||
@@ -1,13 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
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) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setUrl(null);
|
||||
void attachmentId;
|
||||
setIsVisible(false);
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
@@ -15,10 +39,7 @@ export function useAttachmentUrl(attachmentId: string) {
|
||||
(entries) => {
|
||||
if (!entries[0].isIntersecting) return;
|
||||
observer.disconnect();
|
||||
fetch(`/api/attachments/${attachmentId}/presign`)
|
||||
.then((r) => r.json())
|
||||
.then((data: { url: string }) => setUrl(data.url))
|
||||
.catch(() => {});
|
||||
setIsVisible(true);
|
||||
},
|
||||
{ rootMargin: "150px" },
|
||||
);
|
||||
@@ -27,5 +48,7 @@ export function useAttachmentUrl(attachmentId: string) {
|
||||
return () => observer.disconnect();
|
||||
}, [attachmentId]);
|
||||
|
||||
return { url, containerRef };
|
||||
const { data: url } = useAttachmentUrlQuery(attachmentId, isVisible);
|
||||
|
||||
return { url: url ?? null, containerRef };
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
RiSaveLine,
|
||||
RiSparklingLine,
|
||||
} from "@remixicon/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
@@ -13,10 +14,13 @@ import { toast } from "sonner";
|
||||
import {
|
||||
deleteSavedInsightsAction,
|
||||
generateInsightsAction,
|
||||
loadSavedInsightsAction,
|
||||
saveInsightsAction,
|
||||
} from "@/features/insights/actions";
|
||||
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 { Alert, AlertDescription } from "@/shared/components/ui/alert";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
@@ -32,47 +36,47 @@ interface InsightsPageProps {
|
||||
}
|
||||
|
||||
export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
||||
const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL);
|
||||
const [insights, setInsights] = useState<InsightsResponse | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const savedInsightsQuery = useSavedInsights(period);
|
||||
const [isPending, startTransition] = 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 [isSaved, setIsSaved] = useState(false);
|
||||
const [savedDate, setSavedDate] = useState<Date | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const savedInsights = savedInsightsQuery.data ?? null;
|
||||
const insights = draftInsights ?? savedInsights?.insights ?? null;
|
||||
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(() => {
|
||||
const loadSaved = async () => {
|
||||
try {
|
||||
const result = await loadSavedInsightsAction(period);
|
||||
if (result.success && result.data) {
|
||||
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();
|
||||
void period;
|
||||
setDraftInsights(null);
|
||||
setSelectedModelOverride(null);
|
||||
setError(null);
|
||||
}, [period]);
|
||||
|
||||
const handleAnalyze = () => {
|
||||
setError(null);
|
||||
setIsSaved(false);
|
||||
setSavedDate(null);
|
||||
onAnalyze?.();
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await generateInsightsAction(period, selectedModel);
|
||||
|
||||
if (result.success) {
|
||||
setInsights(result.data);
|
||||
setDraftInsights(result.data);
|
||||
setSelectedModelOverride(selectedModel);
|
||||
toast.success("Insights gerados com sucesso!");
|
||||
} else {
|
||||
setError(result.error);
|
||||
@@ -99,8 +103,13 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setIsSaved(true);
|
||||
setSavedDate(result.data.createdAt);
|
||||
queryClient.setQueryData(savedInsightsQueryKey(period), {
|
||||
insights,
|
||||
modelId: selectedModel,
|
||||
createdAt: result.data.createdAt.toISOString(),
|
||||
});
|
||||
setDraftInsights(null);
|
||||
setSelectedModelOverride(null);
|
||||
toast.success("Análise salva com sucesso!");
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
@@ -113,13 +122,16 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!insights) return;
|
||||
|
||||
startSaveTransition(async () => {
|
||||
try {
|
||||
const result = await deleteSavedInsightsAction(period);
|
||||
|
||||
if (result.success) {
|
||||
setIsSaved(false);
|
||||
setSavedDate(null);
|
||||
queryClient.setQueryData(savedInsightsQueryKey(period), null);
|
||||
setDraftInsights(insights);
|
||||
setSelectedModelOverride(selectedModel);
|
||||
toast.success("Análise removida com sucesso!");
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
@@ -148,7 +160,7 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
||||
{/* Model Selector */}
|
||||
<ModelSelector
|
||||
value={selectedModel}
|
||||
onValueChange={setSelectedModel}
|
||||
onValueChange={setSelectedModelOverride}
|
||||
disabled={isPending}
|
||||
/>
|
||||
|
||||
@@ -156,7 +168,7 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Button
|
||||
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"
|
||||
>
|
||||
<RiSparklingLine className="mr-2 size-5" aria-hidden="true" />
|
||||
@@ -166,7 +178,7 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
||||
{insights && !error && (
|
||||
<Button
|
||||
onClick={isSaved ? handleDelete : handleSave}
|
||||
disabled={isSaving || isPending || isLoading}
|
||||
disabled={isSaving || isPending || isLoadingSavedInsights}
|
||||
variant={isSaved ? "destructive" : "outline"}
|
||||
>
|
||||
{isSaved ? (
|
||||
@@ -195,23 +207,43 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="min-h-[400px]">
|
||||
{(isPending || isLoading) && <LoadingState />}
|
||||
{!isPending && !isLoading && !insights && !error && (
|
||||
<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
|
||||
{(isPending || isLoadingSavedInsights) && <LoadingState />}
|
||||
{!isPending &&
|
||||
!isLoadingSavedInsights &&
|
||||
!insights &&
|
||||
!error &&
|
||||
!savedInsightsError && (
|
||||
<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."
|
||||
/>
|
||||
</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 && !isLoading && error && (
|
||||
<ErrorState error={error} onRetry={handleAnalyze} />
|
||||
)}
|
||||
{!isPending && !isLoading && insights && !error && (
|
||||
<InsightsGrid insights={insights} />
|
||||
)}
|
||||
)}
|
||||
{!isPending &&
|
||||
!isLoadingSavedInsights &&
|
||||
insights &&
|
||||
!error &&
|
||||
!savedInsightsError && <InsightsGrid insights={insights} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -258,18 +290,18 @@ function LoadingState() {
|
||||
}
|
||||
|
||||
function ErrorState({
|
||||
title,
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
title: string;
|
||||
error: string;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-12 px-4 text-center">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-lg font-medium text-destructive">
|
||||
Erro ao gerar insights
|
||||
</h3>
|
||||
<h3 className="text-lg font-medium text-destructive">{title}</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md">{error}</p>
|
||||
</div>
|
||||
<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";
|
||||
|
||||
import { RiFileAddLine } from "@remixicon/react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { fetchTransactionAttachmentsAction } from "@/features/transactions/actions/attachments";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
transactionAttachmentsQueryKey,
|
||||
useTransactionAttachments,
|
||||
} from "@/features/transactions/hooks/use-transaction-attachments";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { AttachmentItem } from "./attachment-item";
|
||||
import { AttachmentUpload } from "./attachment-upload";
|
||||
|
||||
type AttachmentRow = {
|
||||
attachmentId: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
createdAt: Date;
|
||||
url: string;
|
||||
};
|
||||
|
||||
interface AttachmentSectionProps {
|
||||
transactionId: string;
|
||||
readonly?: boolean;
|
||||
@@ -41,28 +36,35 @@ export function AttachmentSection({
|
||||
onCancelPendingUpload,
|
||||
maxSizeMb,
|
||||
}: AttachmentSectionProps) {
|
||||
const [items, setItems] = useState<AttachmentRow[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchTransactionAttachmentsAction(transactionId);
|
||||
setItems(data);
|
||||
onLoaded?.(data.length);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [transactionId, onLoaded]);
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
data: items = [],
|
||||
isLoading,
|
||||
isError,
|
||||
} = useTransactionAttachments(transactionId);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
onLoaded?.(items.length);
|
||||
}, [items.length, onLoaded]);
|
||||
|
||||
const invalidateAttachments = () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: transactionAttachmentsQueryKey(transactionId),
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
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;
|
||||
|
||||
return (
|
||||
@@ -82,7 +84,7 @@ export function AttachmentSection({
|
||||
fileSize={item.fileSize}
|
||||
mimeType={item.mimeType}
|
||||
url={item.url}
|
||||
onDeleted={load}
|
||||
onDeleted={invalidateAttachments}
|
||||
readonly={readonly}
|
||||
isPendingDelete={pendingDetachIds?.includes(item.attachmentId)}
|
||||
onPendingDelete={onPendingDetach}
|
||||
@@ -119,7 +121,7 @@ export function AttachmentSection({
|
||||
{!readonly && (
|
||||
<AttachmentUpload
|
||||
transactionId={transactionId}
|
||||
onUploaded={load}
|
||||
onUploaded={invalidateAttachments}
|
||||
onPendingUpload={onPendingUpload}
|
||||
maxSizeMb={maxSizeMb}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { RiCalendarCheckLine, RiLoader4Line } from "@remixicon/react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { getInstallmentAnticipationsAction } from "@/features/transactions/anticipation-actions";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
installmentAnticipationsQueryKey,
|
||||
useInstallmentAnticipations,
|
||||
} from "@/features/transactions/hooks/use-installment-anticipations";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -20,7 +23,6 @@ import {
|
||||
EmptyTitle,
|
||||
} from "@/shared/components/ui/empty";
|
||||
import { useControlledState } from "@/shared/hooks/use-controlled-state";
|
||||
import type { InstallmentAnticipationWithRelations } from "@/shared/lib/installments/anticipation-types";
|
||||
import { AnticipationCard } from "../../shared/anticipation-card";
|
||||
|
||||
interface AnticipationHistoryDialogProps {
|
||||
@@ -40,53 +42,23 @@ export function AnticipationHistoryDialog({
|
||||
onOpenChange,
|
||||
onViewLancamento,
|
||||
}: AnticipationHistoryDialogProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [anticipations, setAnticipations] = useState<
|
||||
InstallmentAnticipationWithRelations[]
|
||||
>([]);
|
||||
|
||||
// Use controlled state hook for dialog open state
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||
open,
|
||||
false,
|
||||
onOpenChange,
|
||||
);
|
||||
|
||||
// Define loadAnticipations before it's used in useEffect
|
||||
const loadAnticipations = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
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 {
|
||||
data: anticipations = [],
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useInstallmentAnticipations(seriesId, dialogOpen);
|
||||
|
||||
const handleCanceled = () => {
|
||||
// Recarregar lista após cancelamento
|
||||
loadAnticipations();
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: installmentAnticipationsQueryKey(seriesId),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -106,6 +78,26 @@ export function AnticipationHistoryDialog({
|
||||
Carregando histórico...
|
||||
</span>
|
||||
</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 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ptBR } from "date-fns/locale";
|
||||
import { useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
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 MoneyValues from "@/shared/components/money-values";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
@@ -18,11 +19,10 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card";
|
||||
import type { InstallmentAnticipationWithRelations } from "@/shared/lib/installments/anticipation-types";
|
||||
import { displayPeriod } from "@/shared/utils/period";
|
||||
|
||||
interface AnticipationCardProps {
|
||||
anticipation: InstallmentAnticipationWithRelations;
|
||||
anticipation: InstallmentAnticipationListItem;
|
||||
onViewLancamento?: (transactionId: string) => void;
|
||||
onCanceled?: () => void;
|
||||
}
|
||||
@@ -37,8 +37,8 @@ export function AnticipationCard({
|
||||
const isSettled = anticipation.transaction?.isSettled === true;
|
||||
const canCancel = !isSettled;
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return format(date, "dd 'de' MMMM 'de' yyyy", { locale: ptBR });
|
||||
const formatDate = (date: string) => {
|
||||
return format(new Date(date), "dd 'de' MMMM 'de' yyyy", { locale: ptBR });
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user