refactor: migrate from ESLint to Biome and extract SQL queries to data.ts

- Replace ESLint with Biome for linting and formatting
- Configure Biome with tabs, double quotes, and organized imports
- Move all SQL/Drizzle queries from page.tsx files to data.ts files
- Create new data.ts files for: ajustes, dashboard, relatorios/categorias
- Update existing data.ts files: extrato, fatura (add lancamentos queries)
- Remove all drizzle-orm imports from page.tsx files
- Update README.md with new tooling info

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-01-27 13:15:37 +00:00
parent 8ffe61c59b
commit a7f63fb77a
442 changed files with 66141 additions and 69292 deletions

View File

@@ -1,335 +1,352 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import {
RiSmartphoneLine,
RiDeleteBinLine,
RiAddLine,
RiFileCopyLine,
RiCheckLine,
RiAlertLine,
RiAddLine,
RiAlertLine,
RiCheckLine,
RiDeleteBinLine,
RiFileCopyLine,
RiSmartphoneLine,
} from "@remixicon/react";
import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import { createApiTokenAction, revokeApiTokenAction } from "@/app/(dashboard)/ajustes/actions";
import { useState } from "react";
import {
createApiTokenAction,
revokeApiTokenAction,
} from "@/app/(dashboard)/ajustes/actions";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface ApiToken {
id: string;
name: string;
tokenPrefix: string;
lastUsedAt: Date | null;
lastUsedIp: string | null;
createdAt: Date;
expiresAt: Date | null;
revokedAt: Date | null;
id: string;
name: string;
tokenPrefix: string;
lastUsedAt: Date | null;
lastUsedIp: string | null;
createdAt: Date;
expiresAt: Date | null;
revokedAt: Date | null;
}
interface ApiTokensFormProps {
tokens: ApiToken[];
tokens: ApiToken[];
}
export function ApiTokensForm({ tokens }: ApiTokensFormProps) {
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [tokenName, setTokenName] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [newToken, setNewToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [revokeId, setRevokeId] = useState<string | null>(null);
const [isRevoking, setIsRevoking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [tokenName, setTokenName] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [newToken, setNewToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [revokeId, setRevokeId] = useState<string | null>(null);
const [isRevoking, setIsRevoking] = useState(false);
const [error, setError] = useState<string | null>(null);
const activeTokens = tokens.filter((t) => !t.revokedAt);
const activeTokens = tokens.filter((t) => !t.revokedAt);
const handleCreate = async () => {
if (!tokenName.trim()) return;
const handleCreate = async () => {
if (!tokenName.trim()) return;
setIsCreating(true);
setError(null);
setIsCreating(true);
setError(null);
try {
const result = await createApiTokenAction({ name: tokenName.trim() });
try {
const result = await createApiTokenAction({ name: tokenName.trim() });
if (result.success && result.data?.token) {
setNewToken(result.data.token);
setTokenName("");
} else {
setError(result.error || "Erro ao criar token");
}
} catch {
setError("Erro ao criar token");
} finally {
setIsCreating(false);
}
};
if (result.success && result.data?.token) {
setNewToken(result.data.token);
setTokenName("");
} else {
setError(result.error || "Erro ao criar token");
}
} catch {
setError("Erro ao criar token");
} finally {
setIsCreating(false);
}
};
const handleCopy = async () => {
if (!newToken) return;
const handleCopy = async () => {
if (!newToken) return;
try {
await navigator.clipboard.writeText(newToken);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback for browsers that don't support clipboard API
const textArea = document.createElement("textarea");
textArea.value = newToken;
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
try {
await navigator.clipboard.writeText(newToken);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback for browsers that don't support clipboard API
const textArea = document.createElement("textarea");
textArea.value = newToken;
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleRevoke = async () => {
if (!revokeId) return;
const handleRevoke = async () => {
if (!revokeId) return;
setIsRevoking(true);
setIsRevoking(true);
try {
const result = await revokeApiTokenAction({ tokenId: revokeId });
try {
const result = await revokeApiTokenAction({ tokenId: revokeId });
if (!result.success) {
setError(result.error || "Erro ao revogar token");
}
} catch {
setError("Erro ao revogar token");
} finally {
setIsRevoking(false);
setRevokeId(null);
}
};
if (!result.success) {
setError(result.error || "Erro ao revogar token");
}
} catch {
setError("Erro ao revogar token");
} finally {
setIsRevoking(false);
setRevokeId(null);
}
};
const handleCloseCreate = () => {
setIsCreateOpen(false);
setNewToken(null);
setTokenName("");
setError(null);
};
const handleCloseCreate = () => {
setIsCreateOpen(false);
setNewToken(null);
setTokenName("");
setError(null);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">Dispositivos conectados</h3>
<p className="text-sm text-muted-foreground">
Gerencie os dispositivos que podem enviar notificações para o OpenSheets.
</p>
</div>
<Dialog open={isCreateOpen} onOpenChange={(open) => {
if (!open) handleCloseCreate();
else setIsCreateOpen(true);
}}>
<DialogTrigger asChild>
<Button size="sm">
<RiAddLine className="h-4 w-4 mr-1" />
Novo Token
</Button>
</DialogTrigger>
<DialogContent>
{!newToken ? (
<>
<DialogHeader>
<DialogTitle>Criar Token de API</DialogTitle>
<DialogDescription>
Crie um token para conectar o OpenSheets Companion no seu dispositivo Android.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="tokenName">Nome do dispositivo</Label>
<Input
id="tokenName"
placeholder="Ex: Meu Celular, Galaxy S24..."
value={tokenName}
onChange={(e) => setTokenName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
}}
/>
</div>
{error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<RiAlertLine className="h-4 w-4" />
{error}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCloseCreate}>
Cancelar
</Button>
<Button onClick={handleCreate} disabled={isCreating || !tokenName.trim()}>
{isCreating ? "Criando..." : "Criar Token"}
</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle>Token Criado</DialogTitle>
<DialogDescription>
Copie o token abaixo e cole no app OpenSheets Companion. Este token
<strong> não será exibido novamente</strong>.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Seu token de API</Label>
<div className="relative">
<Input
value={newToken}
readOnly
className="pr-10 font-mono text-sm"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3"
onClick={handleCopy}
>
{copied ? (
<RiCheckLine className="h-4 w-4 text-green-500" />
) : (
<RiFileCopyLine className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-sm text-amber-800 dark:text-amber-200">
<p className="font-medium">Importante:</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>Guarde este token em local seguro</li>
<li>Ele não será exibido novamente</li>
<li>Use-o para configurar o app Android</li>
</ul>
</div>
</div>
<DialogFooter>
<Button onClick={handleCloseCreate}>Fechar</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
</div>
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">Dispositivos conectados</h3>
<p className="text-sm text-muted-foreground">
Gerencie os dispositivos que podem enviar notificações para o
OpenSheets.
</p>
</div>
<Dialog
open={isCreateOpen}
onOpenChange={(open) => {
if (!open) handleCloseCreate();
else setIsCreateOpen(true);
}}
>
<DialogTrigger asChild>
<Button size="sm">
<RiAddLine className="h-4 w-4 mr-1" />
Novo Token
</Button>
</DialogTrigger>
<DialogContent>
{!newToken ? (
<>
<DialogHeader>
<DialogTitle>Criar Token de API</DialogTitle>
<DialogDescription>
Crie um token para conectar o OpenSheets Companion no seu
dispositivo Android.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="tokenName">Nome do dispositivo</Label>
<Input
id="tokenName"
placeholder="Ex: Meu Celular, Galaxy S24..."
value={tokenName}
onChange={(e) => setTokenName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
}}
/>
</div>
{error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<RiAlertLine className="h-4 w-4" />
{error}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCloseCreate}>
Cancelar
</Button>
<Button
onClick={handleCreate}
disabled={isCreating || !tokenName.trim()}
>
{isCreating ? "Criando..." : "Criar Token"}
</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle>Token Criado</DialogTitle>
<DialogDescription>
Copie o token abaixo e cole no app OpenSheets Companion.
Este token
<strong> não será exibido novamente</strong>.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Seu token de API</Label>
<div className="relative">
<Input
value={newToken}
readOnly
className="pr-10 font-mono text-sm"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3"
onClick={handleCopy}
>
{copied ? (
<RiCheckLine className="h-4 w-4 text-green-500" />
) : (
<RiFileCopyLine className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-sm text-amber-800 dark:text-amber-200">
<p className="font-medium">Importante:</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>Guarde este token em local seguro</li>
<li>Ele não será exibido novamente</li>
<li>Use-o para configurar o app Android</li>
</ul>
</div>
</div>
<DialogFooter>
<Button onClick={handleCloseCreate}>Fechar</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
</div>
{activeTokens.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<RiSmartphoneLine className="h-10 w-10 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
Nenhum dispositivo conectado.
</p>
<p className="text-sm text-muted-foreground">
Crie um token para conectar o app OpenSheets Companion.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{activeTokens.map((token) => (
<Card key={token.id}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className="rounded-full bg-muted p-2">
<RiSmartphoneLine className="h-5 w-5" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{token.name}</span>
<Badge variant="outline" className="text-xs">
{token.tokenPrefix}...
</Badge>
</div>
<div className="text-sm text-muted-foreground mt-1">
{token.lastUsedAt ? (
<span>
Usado{" "}
{formatDistanceToNow(token.lastUsedAt, {
addSuffix: true,
locale: ptBR,
})}
{token.lastUsedIp && (
<span className="text-xs ml-1">
({token.lastUsedIp})
</span>
)}
</span>
) : (
<span>Nunca usado</span>
)}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
Criado em{" "}
{new Date(token.createdAt).toLocaleDateString("pt-BR")}
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => setRevokeId(token.id)}
>
<RiDeleteBinLine className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{activeTokens.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<RiSmartphoneLine className="h-10 w-10 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
Nenhum dispositivo conectado.
</p>
<p className="text-sm text-muted-foreground">
Crie um token para conectar o app OpenSheets Companion.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{activeTokens.map((token) => (
<Card key={token.id}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className="rounded-full bg-muted p-2">
<RiSmartphoneLine className="h-5 w-5" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{token.name}</span>
<Badge variant="outline" className="text-xs">
{token.tokenPrefix}...
</Badge>
</div>
<div className="text-sm text-muted-foreground mt-1">
{token.lastUsedAt ? (
<span>
Usado{" "}
{formatDistanceToNow(token.lastUsedAt, {
addSuffix: true,
locale: ptBR,
})}
{token.lastUsedIp && (
<span className="text-xs ml-1">
({token.lastUsedIp})
</span>
)}
</span>
) : (
<span>Nunca usado</span>
)}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
Criado em{" "}
{new Date(token.createdAt).toLocaleDateString("pt-BR")}
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => setRevokeId(token.id)}
>
<RiDeleteBinLine className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Revoke Confirmation Dialog */}
<AlertDialog open={!!revokeId} onOpenChange={(open) => !open && setRevokeId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revogar token?</AlertDialogTitle>
<AlertDialogDescription>
O dispositivo associado a este token será desconectado e não poderá mais
enviar notificações. Esta ação não pode ser desfeita.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isRevoking}>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleRevoke}
disabled={isRevoking}
className="bg-destructive text-white hover:bg-destructive/90"
>
{isRevoking ? "Revogando..." : "Revogar"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
{/* Revoke Confirmation Dialog */}
<AlertDialog
open={!!revokeId}
onOpenChange={(open) => !open && setRevokeId(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revogar token?</AlertDialogTitle>
<AlertDialogDescription>
O dispositivo associado a este token será desconectado e não
poderá mais enviar notificações. Esta ação não pode ser desfeita.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isRevoking}>
Cancelar
</AlertDialogCancel>
<AlertDialogAction
onClick={handleRevoke}
disabled={isRevoking}
className="bg-destructive text-white hover:bg-destructive/90"
>
{isRevoking ? "Revogando..." : "Revogar"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -1,136 +1,136 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { deleteAccountAction } from "@/app/(dashboard)/ajustes/actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth/client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { toast } from "sonner";
export function DeleteAccountForm() {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isModalOpen, setIsModalOpen] = useState(false);
const [confirmation, setConfirmation] = useState("");
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isModalOpen, setIsModalOpen] = useState(false);
const [confirmation, setConfirmation] = useState("");
const handleDelete = () => {
startTransition(async () => {
const result = await deleteAccountAction({
confirmation,
});
const handleDelete = () => {
startTransition(async () => {
const result = await deleteAccountAction({
confirmation,
});
if (result.success) {
toast.success(result.message);
// Fazer logout e redirecionar para página de login
await authClient.signOut();
router.push("/");
} else {
toast.error(result.error);
}
});
};
if (result.success) {
toast.success(result.message);
// Fazer logout e redirecionar para página de login
await authClient.signOut();
router.push("/");
} else {
toast.error(result.error);
}
});
};
const handleOpenModal = () => {
setConfirmation("");
setIsModalOpen(true);
};
const handleOpenModal = () => {
setConfirmation("");
setIsModalOpen(true);
};
const handleCloseModal = () => {
if (isPending) return;
setConfirmation("");
setIsModalOpen(false);
};
const handleCloseModal = () => {
if (isPending) return;
setConfirmation("");
setIsModalOpen(false);
};
return (
<>
<div className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
<ul className="list-disc list-inside text-sm text-destructive space-y-1">
<li>Lançamentos, orçamentos e anotações</li>
<li>Contas, cartões e categorias</li>
<li>Pagadores (incluindo o pagador padrão)</li>
<li>Preferências e configurações</li>
<li className="font-bold">
Resumindo tudo, sua conta será permanentemente removida
</li>
</ul>
</div>
return (
<>
<div className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
<ul className="list-disc list-inside text-sm text-destructive space-y-1">
<li>Lançamentos, orçamentos e anotações</li>
<li>Contas, cartões e categorias</li>
<li>Pagadores (incluindo o pagador padrão)</li>
<li>Preferências e configurações</li>
<li className="font-bold">
Resumindo tudo, sua conta será permanentemente removida
</li>
</ul>
</div>
<div className="flex justify-end">
<Button
variant="destructive"
onClick={handleOpenModal}
disabled={isPending}
className="w-fit"
>
Deletar conta
</Button>
</div>
</div>
<div className="flex justify-end">
<Button
variant="destructive"
onClick={handleOpenModal}
disabled={isPending}
className="w-fit"
>
Deletar conta
</Button>
</div>
</div>
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent
className="max-w-md"
onEscapeKeyDown={(e) => {
if (isPending) e.preventDefault();
}}
onPointerDownOutside={(e) => {
if (isPending) e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>Você tem certeza?</DialogTitle>
<DialogDescription>
Essa ação não pode ser desfeita. Isso irá deletar permanentemente
sua conta e remover seus dados de nossos servidores.
</DialogDescription>
</DialogHeader>
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent
className="max-w-md"
onEscapeKeyDown={(e) => {
if (isPending) e.preventDefault();
}}
onPointerDownOutside={(e) => {
if (isPending) e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>Você tem certeza?</DialogTitle>
<DialogDescription>
Essa ação não pode ser desfeita. Isso irá deletar permanentemente
sua conta e remover seus dados de nossos servidores.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="confirmation">
Para confirmar, digite <strong>DELETAR</strong> no campo abaixo.
</Label>
<Input
id="confirmation"
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
disabled={isPending}
placeholder="DELETAR"
autoComplete="off"
/>
</div>
</div>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="confirmation">
Para confirmar, digite <strong>DELETAR</strong> no campo abaixo.
</Label>
<Input
id="confirmation"
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
disabled={isPending}
placeholder="DELETAR"
autoComplete="off"
/>
</div>
</div>
<DialogFooter className="sm:justify-end">
<Button
type="button"
variant="outline"
onClick={handleCloseModal}
disabled={isPending}
>
Cancelar
</Button>
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={isPending || confirmation !== "DELETAR"}
>
{isPending ? "Deletando..." : "Deletar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
<DialogFooter className="sm:justify-end">
<Button
type="button"
variant="outline"
onClick={handleCloseModal}
disabled={isPending}
>
Cancelar
</Button>
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={isPending || confirmation !== "DELETAR"}
>
{isPending ? "Deletando..." : "Deletar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,78 +1,72 @@
"use client";
import { updatePreferencesAction } from "@/app/(dashboard)/ajustes/actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { updatePreferencesAction } from "@/app/(dashboard)/ajustes/actions";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
interface PreferencesFormProps {
disableMagnetlines: boolean;
disableMagnetlines: boolean;
}
export function PreferencesForm({
disableMagnetlines,
}: PreferencesFormProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [magnetlinesDisabled, setMagnetlinesDisabled] =
useState(disableMagnetlines);
export function PreferencesForm({ disableMagnetlines }: PreferencesFormProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [magnetlinesDisabled, setMagnetlinesDisabled] =
useState(disableMagnetlines);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
startTransition(async () => {
const result = await updatePreferencesAction({
disableMagnetlines: magnetlinesDisabled,
});
startTransition(async () => {
const result = await updatePreferencesAction({
disableMagnetlines: magnetlinesDisabled,
});
if (result.success) {
toast.success(result.message);
// Recarregar a página para aplicar as mudanças nos componentes
router.refresh();
// Forçar reload completo para garantir que os hooks re-executem
setTimeout(() => {
window.location.reload();
}, 500);
} else {
toast.error(result.error);
}
});
};
if (result.success) {
toast.success(result.message);
// Recarregar a página para aplicar as mudanças nos componentes
router.refresh();
// Forçar reload completo para garantir que os hooks re-executem
setTimeout(() => {
window.location.reload();
}, 500);
} else {
toast.error(result.error);
}
});
};
return (
<form
onSubmit={handleSubmit}
className="flex flex-col space-y-6"
>
<div className="space-y-4 max-w-md">
<div className="flex items-center justify-between rounded-lg border border-dashed p-4">
<div className="space-y-0.5">
<Label htmlFor="magnetlines" className="text-base">
Desabilitar Magnetlines
</Label>
<p className="text-sm text-muted-foreground">
Remove o recurso de linhas magnéticas do sistema. Essa mudança
afeta a interface e interações visuais.
</p>
</div>
<Switch
id="magnetlines"
checked={magnetlinesDisabled}
onCheckedChange={setMagnetlinesDisabled}
disabled={isPending}
/>
</div>
</div>
return (
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
<div className="flex items-center justify-between rounded-lg border border-dashed p-4">
<div className="space-y-0.5">
<Label htmlFor="magnetlines" className="text-base">
Desabilitar Magnetlines
</Label>
<p className="text-sm text-muted-foreground">
Remove o recurso de linhas magnéticas do sistema. Essa mudança
afeta a interface e interações visuais.
</p>
</div>
<Switch
id="magnetlines"
checked={magnetlinesDisabled}
onCheckedChange={setMagnetlinesDisabled}
disabled={isPending}
/>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending} className="w-fit">
{isPending ? "Salvando..." : "Salvar preferências"}
</Button>
</div>
</form>
);
<div className="flex justify-end">
<Button type="submit" disabled={isPending} className="w-fit">
{isPending ? "Salvando..." : "Salvar preferências"}
</Button>
</div>
</form>
);
}

View File

@@ -1,220 +1,248 @@
"use client";
import {
RiCheckLine,
RiCloseLine,
RiEyeLine,
RiEyeOffLine,
} from "@remixicon/react";
import { useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { updateEmailAction } from "@/app/(dashboard)/ajustes/actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RiCheckLine, RiCloseLine, RiEyeLine, RiEyeOffLine } from "@remixicon/react";
import { useState, useTransition, useMemo } from "react";
import { toast } from "sonner";
type UpdateEmailFormProps = {
currentEmail: string;
authProvider?: string; // 'google' | 'credential' | undefined
currentEmail: string;
authProvider?: string; // 'google' | 'credential' | undefined
};
export function UpdateEmailForm({ currentEmail, authProvider }: UpdateEmailFormProps) {
const [isPending, startTransition] = useTransition();
const [password, setPassword] = useState("");
const [newEmail, setNewEmail] = useState("");
const [confirmEmail, setConfirmEmail] = useState("");
const [showPassword, setShowPassword] = useState(false);
export function UpdateEmailForm({
currentEmail,
authProvider,
}: UpdateEmailFormProps) {
const [isPending, startTransition] = useTransition();
const [password, setPassword] = useState("");
const [newEmail, setNewEmail] = useState("");
const [confirmEmail, setConfirmEmail] = useState("");
const [showPassword, setShowPassword] = useState(false);
// Verificar se o usuário usa login via Google (não precisa de senha)
const isGoogleAuth = authProvider === "google";
// Verificar se o usuário usa login via Google (não precisa de senha)
const isGoogleAuth = authProvider === "google";
// Validação em tempo real: e-mails coincidem
const emailsMatch = useMemo(() => {
if (!confirmEmail) return null; // Não mostrar erro se campo vazio
return newEmail.toLowerCase() === confirmEmail.toLowerCase();
}, [newEmail, confirmEmail]);
// Validação em tempo real: e-mails coincidem
const emailsMatch = useMemo(() => {
if (!confirmEmail) return null; // Não mostrar erro se campo vazio
return newEmail.toLowerCase() === confirmEmail.toLowerCase();
}, [newEmail, confirmEmail]);
// Validação: novo e-mail é diferente do atual
const isEmailDifferent = useMemo(() => {
if (!newEmail) return true;
return newEmail.toLowerCase() !== currentEmail.toLowerCase();
}, [newEmail, currentEmail]);
// Validação: novo e-mail é diferente do atual
const isEmailDifferent = useMemo(() => {
if (!newEmail) return true;
return newEmail.toLowerCase() !== currentEmail.toLowerCase();
}, [newEmail, currentEmail]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Validação frontend antes de enviar
if (newEmail.toLowerCase() !== confirmEmail.toLowerCase()) {
toast.error("Os e-mails não coincidem");
return;
}
// Validação frontend antes de enviar
if (newEmail.toLowerCase() !== confirmEmail.toLowerCase()) {
toast.error("Os e-mails não coincidem");
return;
}
if (newEmail.toLowerCase() === currentEmail.toLowerCase()) {
toast.error("O novo e-mail deve ser diferente do atual");
return;
}
if (newEmail.toLowerCase() === currentEmail.toLowerCase()) {
toast.error("O novo e-mail deve ser diferente do atual");
return;
}
startTransition(async () => {
const result = await updateEmailAction({
password: isGoogleAuth ? undefined : password,
newEmail,
confirmEmail,
});
startTransition(async () => {
const result = await updateEmailAction({
password: isGoogleAuth ? undefined : password,
newEmail,
confirmEmail,
});
if (result.success) {
toast.success(result.message);
setPassword("");
setNewEmail("");
setConfirmEmail("");
} else {
toast.error(result.error);
}
});
};
if (result.success) {
toast.success(result.message);
setPassword("");
setNewEmail("");
setConfirmEmail("");
} else {
toast.error(result.error);
}
});
};
return (
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
{/* E-mail atual (apenas informativo) */}
<div className="space-y-2">
<Label htmlFor="currentEmail">E-mail atual</Label>
<Input
id="currentEmail"
type="email"
value={currentEmail}
disabled
className="bg-muted cursor-not-allowed"
aria-describedby="current-email-help"
/>
<p id="current-email-help" className="text-xs text-muted-foreground">
Este é seu e-mail atual cadastrado
</p>
</div>
return (
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
{/* E-mail atual (apenas informativo) */}
<div className="space-y-2">
<Label htmlFor="currentEmail">E-mail atual</Label>
<Input
id="currentEmail"
type="email"
value={currentEmail}
disabled
className="bg-muted cursor-not-allowed"
aria-describedby="current-email-help"
/>
<p id="current-email-help" className="text-xs text-muted-foreground">
Este é seu e-mail atual cadastrado
</p>
</div>
{/* Senha de confirmação (apenas para usuários com login por e-mail/senha) */}
{!isGoogleAuth && (
<div className="space-y-2">
<Label htmlFor="password">
Senha atual <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isPending}
placeholder="Digite sua senha para confirmar"
required
aria-required="true"
aria-describedby="password-help"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={showPassword ? "Ocultar senha" : "Mostrar senha"}
>
{showPassword ? (
<RiEyeOffLine size={20} />
) : (
<RiEyeLine size={20} />
)}
</button>
</div>
<p id="password-help" className="text-xs text-muted-foreground">
Por segurança, confirme sua senha antes de alterar seu e-mail
</p>
</div>
)}
{/* Senha de confirmação (apenas para usuários com login por e-mail/senha) */}
{!isGoogleAuth && (
<div className="space-y-2">
<Label htmlFor="password">
Senha atual <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isPending}
placeholder="Digite sua senha para confirmar"
required
aria-required="true"
aria-describedby="password-help"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={showPassword ? "Ocultar senha" : "Mostrar senha"}
>
{showPassword ? (
<RiEyeOffLine size={20} />
) : (
<RiEyeLine size={20} />
)}
</button>
</div>
<p id="password-help" className="text-xs text-muted-foreground">
Por segurança, confirme sua senha antes de alterar seu e-mail
</p>
</div>
)}
{/* Novo e-mail */}
<div className="space-y-2">
<Label htmlFor="newEmail">
Novo e-mail <span className="text-destructive">*</span>
</Label>
<Input
id="newEmail"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
disabled={isPending}
placeholder="Digite o novo e-mail"
required
aria-required="true"
aria-describedby="new-email-help"
aria-invalid={!isEmailDifferent}
className={!isEmailDifferent ? "border-red-500 focus-visible:ring-red-500" : ""}
/>
{!isEmailDifferent && newEmail && (
<p className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
<RiCloseLine className="h-3.5 w-3.5" />
O novo e-mail deve ser diferente do atual
</p>
)}
{!newEmail && (
<p id="new-email-help" className="text-xs text-muted-foreground">
Digite o novo endereço de e-mail para sua conta
</p>
)}
</div>
{/* Novo e-mail */}
<div className="space-y-2">
<Label htmlFor="newEmail">
Novo e-mail <span className="text-destructive">*</span>
</Label>
<Input
id="newEmail"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
disabled={isPending}
placeholder="Digite o novo e-mail"
required
aria-required="true"
aria-describedby="new-email-help"
aria-invalid={!isEmailDifferent}
className={
!isEmailDifferent
? "border-red-500 focus-visible:ring-red-500"
: ""
}
/>
{!isEmailDifferent && newEmail && (
<p
className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1"
role="alert"
>
<RiCloseLine className="h-3.5 w-3.5" />O novo e-mail deve ser
diferente do atual
</p>
)}
{!newEmail && (
<p id="new-email-help" className="text-xs text-muted-foreground">
Digite o novo endereço de e-mail para sua conta
</p>
)}
</div>
{/* Confirmar novo e-mail */}
<div className="space-y-2">
<Label htmlFor="confirmEmail">
Confirmar novo e-mail <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Input
id="confirmEmail"
type="email"
value={confirmEmail}
onChange={(e) => setConfirmEmail(e.target.value)}
disabled={isPending}
placeholder="Repita o novo e-mail"
required
aria-required="true"
aria-describedby="confirm-email-help"
aria-invalid={emailsMatch === false}
className={
emailsMatch === false
? "border-red-500 focus-visible:ring-red-500 pr-10"
: emailsMatch === true
? "border-green-500 focus-visible:ring-green-500 pr-10"
: ""
}
/>
{/* Indicador visual de match */}
{emailsMatch !== null && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{emailsMatch ? (
<RiCheckLine className="h-5 w-5 text-green-500" aria-label="Os e-mails coincidem" />
) : (
<RiCloseLine className="h-5 w-5 text-red-500" aria-label="Os e-mails não coincidem" />
)}
</div>
)}
</div>
{/* Mensagem de erro em tempo real */}
{emailsMatch === false && (
<p id="confirm-email-help" className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
<RiCloseLine className="h-3.5 w-3.5" />
Os e-mails não coincidem
</p>
)}
{emailsMatch === true && (
<p id="confirm-email-help" className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
<RiCheckLine className="h-3.5 w-3.5" />
Os e-mails coincidem
</p>
)}
</div>
</div>
{/* Confirmar novo e-mail */}
<div className="space-y-2">
<Label htmlFor="confirmEmail">
Confirmar novo e-mail <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Input
id="confirmEmail"
type="email"
value={confirmEmail}
onChange={(e) => setConfirmEmail(e.target.value)}
disabled={isPending}
placeholder="Repita o novo e-mail"
required
aria-required="true"
aria-describedby="confirm-email-help"
aria-invalid={emailsMatch === false}
className={
emailsMatch === false
? "border-red-500 focus-visible:ring-red-500 pr-10"
: emailsMatch === true
? "border-green-500 focus-visible:ring-green-500 pr-10"
: ""
}
/>
{/* Indicador visual de match */}
{emailsMatch !== null && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{emailsMatch ? (
<RiCheckLine
className="h-5 w-5 text-green-500"
aria-label="Os e-mails coincidem"
/>
) : (
<RiCloseLine
className="h-5 w-5 text-red-500"
aria-label="Os e-mails não coincidem"
/>
)}
</div>
)}
</div>
{/* Mensagem de erro em tempo real */}
{emailsMatch === false && (
<p
id="confirm-email-help"
className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1"
role="alert"
>
<RiCloseLine className="h-3.5 w-3.5" />
Os e-mails não coincidem
</p>
)}
{emailsMatch === true && (
<p
id="confirm-email-help"
className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1"
>
<RiCheckLine className="h-3.5 w-3.5" />
Os e-mails coincidem
</p>
)}
</div>
</div>
<div className="flex justify-end">
<Button
type="submit"
disabled={isPending || emailsMatch === false || !isEmailDifferent}
className="w-fit"
>
{isPending ? "Atualizando..." : "Atualizar e-mail"}
</Button>
</div>
</form>
);
<div className="flex justify-end">
<Button
type="submit"
disabled={isPending || emailsMatch === false || !isEmailDifferent}
className="w-fit"
>
{isPending ? "Atualizando..." : "Atualizar e-mail"}
</Button>
</div>
</form>
);
}

View File

@@ -1,75 +1,75 @@
"use client";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { updateNameAction } from "@/app/(dashboard)/ajustes/actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useState, useTransition } from "react";
import { toast } from "sonner";
type UpdateNameFormProps = {
currentName: string;
currentName: string;
};
export function UpdateNameForm({ currentName }: UpdateNameFormProps) {
const [isPending, startTransition] = useTransition();
const [isPending, startTransition] = useTransition();
// Dividir o nome atual em primeiro nome e sobrenome
const nameParts = currentName.split(" ");
const initialFirstName = nameParts[0] || "";
const initialLastName = nameParts.slice(1).join(" ") || "";
// Dividir o nome atual em primeiro nome e sobrenome
const nameParts = currentName.split(" ");
const initialFirstName = nameParts[0] || "";
const initialLastName = nameParts.slice(1).join(" ") || "";
const [firstName, setFirstName] = useState(initialFirstName);
const [lastName, setLastName] = useState(initialLastName);
const [firstName, setFirstName] = useState(initialFirstName);
const [lastName, setLastName] = useState(initialLastName);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
startTransition(async () => {
const result = await updateNameAction({
firstName,
lastName,
});
startTransition(async () => {
const result = await updateNameAction({
firstName,
lastName,
});
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.error);
}
});
};
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.error);
}
});
};
return (
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
<div className="space-y-2">
<Label htmlFor="firstName">Primeiro nome</Label>
<Input
id="firstName"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
disabled={isPending}
required
/>
</div>
return (
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
<div className="space-y-2">
<Label htmlFor="firstName">Primeiro nome</Label>
<Input
id="firstName"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
disabled={isPending}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Sobrenome</Label>
<Input
id="lastName"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
disabled={isPending}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Sobrenome</Label>
<Input
id="lastName"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
disabled={isPending}
required
/>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending} className="w-fit">
{isPending ? "Atualizando..." : "Atualizar nome"}
</Button>
</div>
</form>
);
<div className="flex justify-end">
<Button type="submit" disabled={isPending} className="w-fit">
{isPending ? "Atualizando..." : "Atualizar nome"}
</Button>
</div>
</form>
);
}

View File

@@ -1,363 +1,365 @@
"use client";
import {
RiAlertLine,
RiCheckLine,
RiCloseLine,
RiEyeLine,
RiEyeOffLine,
} from "@remixicon/react";
import { useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { updatePasswordAction } from "@/app/(dashboard)/ajustes/actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils/ui";
import {
RiEyeLine,
RiEyeOffLine,
RiCheckLine,
RiCloseLine,
RiAlertLine,
} from "@remixicon/react";
import { useState, useTransition, useMemo } from "react";
import { toast } from "sonner";
interface PasswordValidation {
hasLowercase: boolean;
hasUppercase: boolean;
hasNumber: boolean;
hasSpecial: boolean;
hasMinLength: boolean;
hasMaxLength: boolean;
isValid: boolean;
hasLowercase: boolean;
hasUppercase: boolean;
hasNumber: boolean;
hasSpecial: boolean;
hasMinLength: boolean;
hasMaxLength: boolean;
isValid: boolean;
}
function validatePassword(password: string): PasswordValidation {
const hasLowercase = /[a-z]/.test(password);
const hasUppercase = /[A-Z]/.test(password);
const hasNumber = /\d/.test(password);
const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?`~]/.test(password);
const hasMinLength = password.length >= 7;
const hasMaxLength = password.length <= 23;
const hasLowercase = /[a-z]/.test(password);
const hasUppercase = /[A-Z]/.test(password);
const hasNumber = /\d/.test(password);
const hasSpecial = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?`~]/.test(password);
const hasMinLength = password.length >= 7;
const hasMaxLength = password.length <= 23;
return {
hasLowercase,
hasUppercase,
hasNumber,
hasSpecial,
hasMinLength,
hasMaxLength,
isValid:
hasLowercase &&
hasUppercase &&
hasNumber &&
hasSpecial &&
hasMinLength &&
hasMaxLength,
};
return {
hasLowercase,
hasUppercase,
hasNumber,
hasSpecial,
hasMinLength,
hasMaxLength,
isValid:
hasLowercase &&
hasUppercase &&
hasNumber &&
hasSpecial &&
hasMinLength &&
hasMaxLength,
};
}
function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
return (
<div
className={cn(
"flex items-center gap-1.5 text-xs transition-colors",
met ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground"
)}
>
{met ? (
<RiCheckLine className="h-3.5 w-3.5" />
) : (
<RiCloseLine className="h-3.5 w-3.5" />
)}
<span>{label}</span>
</div>
);
return (
<div
className={cn(
"flex items-center gap-1.5 text-xs transition-colors",
met
? "text-emerald-600 dark:text-emerald-400"
: "text-muted-foreground",
)}
>
{met ? (
<RiCheckLine className="h-3.5 w-3.5" />
) : (
<RiCloseLine className="h-3.5 w-3.5" />
)}
<span>{label}</span>
</div>
);
}
type UpdatePasswordFormProps = {
authProvider?: string; // 'google' | 'credential' | undefined
authProvider?: string; // 'google' | 'credential' | undefined
};
export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) {
const [isPending, startTransition] = useTransition();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isPending, startTransition] = useTransition();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
// Verificar se o usuário usa login via Google
const isGoogleAuth = authProvider === "google";
// Verificar se o usuário usa login via Google
const isGoogleAuth = authProvider === "google";
// Validação em tempo real: senhas coincidem
const passwordsMatch = useMemo(() => {
if (!confirmPassword) return null; // Não mostrar erro se campo vazio
return newPassword === confirmPassword;
}, [newPassword, confirmPassword]);
// Validação em tempo real: senhas coincidem
const passwordsMatch = useMemo(() => {
if (!confirmPassword) return null; // Não mostrar erro se campo vazio
return newPassword === confirmPassword;
}, [newPassword, confirmPassword]);
// Validação de requisitos da senha
const passwordValidation = useMemo(
() => validatePassword(newPassword),
[newPassword]
);
// Validação de requisitos da senha
const passwordValidation = useMemo(
() => validatePassword(newPassword),
[newPassword],
);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Validação frontend antes de enviar
if (!passwordValidation.isValid) {
toast.error("A senha não atende aos requisitos de segurança");
return;
}
// Validação frontend antes de enviar
if (!passwordValidation.isValid) {
toast.error("A senha não atende aos requisitos de segurança");
return;
}
if (newPassword !== confirmPassword) {
toast.error("As senhas não coincidem");
return;
}
if (newPassword !== confirmPassword) {
toast.error("As senhas não coincidem");
return;
}
startTransition(async () => {
const result = await updatePasswordAction({
currentPassword,
newPassword,
confirmPassword,
});
startTransition(async () => {
const result = await updatePasswordAction({
currentPassword,
newPassword,
confirmPassword,
});
if (result.success) {
toast.success(result.message);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} else {
toast.error(result.error);
}
});
};
if (result.success) {
toast.success(result.message);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} else {
toast.error(result.error);
}
});
};
// Se o usuário usa Google OAuth, mostrar aviso
if (isGoogleAuth) {
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/20">
<div className="flex gap-3">
<RiAlertLine className="h-5 w-5 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
<div>
<h3 className="font-medium text-amber-900 dark:text-amber-400">
Alteração de senha não disponível
</h3>
<p className="mt-1 text-sm text-amber-800 dark:text-amber-500">
Você fez login usando sua conta do Google. A senha é gerenciada
diretamente pelo Google e não pode ser alterada aqui. Para
modificar sua senha, acesse as configurações de segurança da sua
conta Google.
</p>
</div>
</div>
</div>
);
}
// Se o usuário usa Google OAuth, mostrar aviso
if (isGoogleAuth) {
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/20">
<div className="flex gap-3">
<RiAlertLine className="h-5 w-5 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
<div>
<h3 className="font-medium text-amber-900 dark:text-amber-400">
Alteração de senha não disponível
</h3>
<p className="mt-1 text-sm text-amber-800 dark:text-amber-500">
Você fez login usando sua conta do Google. A senha é gerenciada
diretamente pelo Google e não pode ser alterada aqui. Para
modificar sua senha, acesse as configurações de segurança da sua
conta Google.
</p>
</div>
</div>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
{/* Senha atual */}
<div className="space-y-2">
<Label htmlFor="currentPassword">
Senha atual <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Input
id="currentPassword"
type={showCurrentPassword ? "text" : "password"}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
disabled={isPending}
placeholder="Digite sua senha atual"
required
aria-required="true"
aria-describedby="current-password-help"
/>
<button
type="button"
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={
showCurrentPassword
? "Ocultar senha atual"
: "Mostrar senha atual"
}
>
{showCurrentPassword ? (
<RiEyeOffLine size={20} />
) : (
<RiEyeLine size={20} />
)}
</button>
</div>
<p
id="current-password-help"
className="text-xs text-muted-foreground"
>
Por segurança, confirme sua senha atual antes de alterá-la
</p>
</div>
return (
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
{/* Senha atual */}
<div className="space-y-2">
<Label htmlFor="currentPassword">
Senha atual <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Input
id="currentPassword"
type={showCurrentPassword ? "text" : "password"}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
disabled={isPending}
placeholder="Digite sua senha atual"
required
aria-required="true"
aria-describedby="current-password-help"
/>
<button
type="button"
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={
showCurrentPassword
? "Ocultar senha atual"
: "Mostrar senha atual"
}
>
{showCurrentPassword ? (
<RiEyeOffLine size={20} />
) : (
<RiEyeLine size={20} />
)}
</button>
</div>
<p
id="current-password-help"
className="text-xs text-muted-foreground"
>
Por segurança, confirme sua senha atual antes de alterá-la
</p>
</div>
{/* Nova senha */}
<div className="space-y-2">
<Label htmlFor="newPassword">
Nova senha <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Input
id="newPassword"
type={showNewPassword ? "text" : "password"}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={isPending}
placeholder="Crie uma senha forte"
required
minLength={7}
maxLength={23}
aria-required="true"
aria-describedby="new-password-help"
aria-invalid={
newPassword.length > 0 && !passwordValidation.isValid
}
/>
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={
showNewPassword ? "Ocultar nova senha" : "Mostrar nova senha"
}
>
{showNewPassword ? (
<RiEyeOffLine size={20} />
) : (
<RiEyeLine size={20} />
)}
</button>
</div>
{/* Indicadores de requisitos da senha */}
{newPassword.length > 0 && (
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
<PasswordRequirement
met={passwordValidation.hasMinLength}
label="Mínimo 7 caracteres"
/>
<PasswordRequirement
met={passwordValidation.hasMaxLength}
label="Máximo 23 caracteres"
/>
<PasswordRequirement
met={passwordValidation.hasLowercase}
label="Letra minúscula"
/>
<PasswordRequirement
met={passwordValidation.hasUppercase}
label="Letra maiúscula"
/>
<PasswordRequirement
met={passwordValidation.hasNumber}
label="Número"
/>
<PasswordRequirement
met={passwordValidation.hasSpecial}
label="Caractere especial"
/>
</div>
)}
</div>
{/* Nova senha */}
<div className="space-y-2">
<Label htmlFor="newPassword">
Nova senha <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Input
id="newPassword"
type={showNewPassword ? "text" : "password"}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={isPending}
placeholder="Crie uma senha forte"
required
minLength={7}
maxLength={23}
aria-required="true"
aria-describedby="new-password-help"
aria-invalid={
newPassword.length > 0 && !passwordValidation.isValid
}
/>
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={
showNewPassword ? "Ocultar nova senha" : "Mostrar nova senha"
}
>
{showNewPassword ? (
<RiEyeOffLine size={20} />
) : (
<RiEyeLine size={20} />
)}
</button>
</div>
{/* Indicadores de requisitos da senha */}
{newPassword.length > 0 && (
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
<PasswordRequirement
met={passwordValidation.hasMinLength}
label="Mínimo 7 caracteres"
/>
<PasswordRequirement
met={passwordValidation.hasMaxLength}
label="Máximo 23 caracteres"
/>
<PasswordRequirement
met={passwordValidation.hasLowercase}
label="Letra minúscula"
/>
<PasswordRequirement
met={passwordValidation.hasUppercase}
label="Letra maiúscula"
/>
<PasswordRequirement
met={passwordValidation.hasNumber}
label="Número"
/>
<PasswordRequirement
met={passwordValidation.hasSpecial}
label="Caractere especial"
/>
</div>
)}
</div>
{/* Confirmar nova senha */}
<div className="space-y-2">
<Label htmlFor="confirmPassword">
Confirmar nova senha <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Input
id="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isPending}
placeholder="Repita a senha"
required
minLength={6}
aria-required="true"
aria-describedby="confirm-password-help"
aria-invalid={passwordsMatch === false}
className={
passwordsMatch === false
? "border-red-500 focus-visible:ring-red-500"
: passwordsMatch === true
? "border-green-500 focus-visible:ring-green-500"
: ""
}
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-10 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={
showConfirmPassword
? "Ocultar confirmação de senha"
: "Mostrar confirmação de senha"
}
>
{showConfirmPassword ? (
<RiEyeOffLine size={20} />
) : (
<RiEyeLine size={20} />
)}
</button>
{/* Indicador visual de match */}
{passwordsMatch !== null && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{passwordsMatch ? (
<RiCheckLine
className="h-5 w-5 text-green-500"
aria-label="As senhas coincidem"
/>
) : (
<RiCloseLine
className="h-5 w-5 text-red-500"
aria-label="As senhas não coincidem"
/>
)}
</div>
)}
</div>
{/* Mensagem de erro em tempo real */}
{passwordsMatch === false && (
<p
id="confirm-password-help"
className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1"
role="alert"
>
<RiCloseLine className="h-3.5 w-3.5" />
As senhas não coincidem
</p>
)}
{passwordsMatch === true && (
<p
id="confirm-password-help"
className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1"
>
<RiCheckLine className="h-3.5 w-3.5" />
As senhas coincidem
</p>
)}
</div>
</div>
{/* Confirmar nova senha */}
<div className="space-y-2">
<Label htmlFor="confirmPassword">
Confirmar nova senha <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Input
id="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isPending}
placeholder="Repita a senha"
required
minLength={6}
aria-required="true"
aria-describedby="confirm-password-help"
aria-invalid={passwordsMatch === false}
className={
passwordsMatch === false
? "border-red-500 focus-visible:ring-red-500"
: passwordsMatch === true
? "border-green-500 focus-visible:ring-green-500"
: ""
}
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-10 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={
showConfirmPassword
? "Ocultar confirmação de senha"
: "Mostrar confirmação de senha"
}
>
{showConfirmPassword ? (
<RiEyeOffLine size={20} />
) : (
<RiEyeLine size={20} />
)}
</button>
{/* Indicador visual de match */}
{passwordsMatch !== null && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{passwordsMatch ? (
<RiCheckLine
className="h-5 w-5 text-green-500"
aria-label="As senhas coincidem"
/>
) : (
<RiCloseLine
className="h-5 w-5 text-red-500"
aria-label="As senhas não coincidem"
/>
)}
</div>
)}
</div>
{/* Mensagem de erro em tempo real */}
{passwordsMatch === false && (
<p
id="confirm-password-help"
className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1"
role="alert"
>
<RiCloseLine className="h-3.5 w-3.5" />
As senhas não coincidem
</p>
)}
{passwordsMatch === true && (
<p
id="confirm-password-help"
className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1"
>
<RiCheckLine className="h-3.5 w-3.5" />
As senhas coincidem
</p>
)}
</div>
</div>
<div className="flex justify-end">
<Button
type="submit"
disabled={
isPending ||
passwordsMatch === false ||
(newPassword.length > 0 && !passwordValidation.isValid)
}
className="w-fit"
>
{isPending ? "Atualizando..." : "Atualizar senha"}
</Button>
</div>
</form>
);
<div className="flex justify-end">
<Button
type="submit"
disabled={
isPending ||
passwordsMatch === false ||
(newPassword.length > 0 && !passwordValidation.isValid)
}
className="w-fit"
>
{isPending ? "Atualizando..." : "Atualizar senha"}
</Button>
</div>
</form>
);
}

View File

@@ -1,122 +1,122 @@
"use client";
import { RiMoonClearLine, RiSunLine } from "@remixicon/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { buttonVariants } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils/ui";
import { RiMoonClearLine, RiSunLine } from "@remixicon/react";
interface AnimatedThemeTogglerProps
extends React.ComponentPropsWithoutRef<"button"> {
duration?: number;
extends React.ComponentPropsWithoutRef<"button"> {
duration?: number;
}
export const AnimatedThemeToggler = ({
className,
duration = 400,
...props
className,
duration = 400,
...props
}: AnimatedThemeTogglerProps) => {
const [isDark, setIsDark] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const [isDark, setIsDark] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const updateTheme = () => {
setIsDark(document.documentElement.classList.contains("dark"));
};
useEffect(() => {
const updateTheme = () => {
setIsDark(document.documentElement.classList.contains("dark"));
};
updateTheme();
updateTheme();
const observer = new MutationObserver(updateTheme);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
const observer = new MutationObserver(updateTheme);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
return () => observer.disconnect();
}, []);
const toggleTheme = useCallback(async () => {
if (!buttonRef.current) return;
const toggleTheme = useCallback(async () => {
if (!buttonRef.current) return;
await document.startViewTransition(() => {
flushSync(() => {
const newTheme = !isDark;
setIsDark(newTheme);
document.documentElement.classList.toggle("dark");
localStorage.setItem("theme", newTheme ? "dark" : "light");
});
}).ready;
await document.startViewTransition(() => {
flushSync(() => {
const newTheme = !isDark;
setIsDark(newTheme);
document.documentElement.classList.toggle("dark");
localStorage.setItem("theme", newTheme ? "dark" : "light");
});
}).ready;
const { top, left, width, height } =
buttonRef.current.getBoundingClientRect();
const x = left + width / 2;
const y = top + height / 2;
const maxRadius = Math.hypot(
Math.max(left, window.innerWidth - left),
Math.max(top, window.innerHeight - top)
);
const { top, left, width, height } =
buttonRef.current.getBoundingClientRect();
const x = left + width / 2;
const y = top + height / 2;
const maxRadius = Math.hypot(
Math.max(left, window.innerWidth - left),
Math.max(top, window.innerHeight - top),
);
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxRadius}px at ${x}px ${y}px)`,
],
},
{
duration,
easing: "ease-in-out",
pseudoElement: "::view-transition-new(root)",
}
);
}, [isDark, duration]);
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxRadius}px at ${x}px ${y}px)`,
],
},
{
duration,
easing: "ease-in-out",
pseudoElement: "::view-transition-new(root)",
},
);
}, [isDark, duration]);
return (
<Tooltip>
<TooltipTrigger asChild>
<button
ref={buttonRef}
type="button"
onClick={toggleTheme}
data-state={isDark ? "dark" : "light"}
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"group relative text-muted-foreground transition-all duration-200",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground border",
className
)}
{...props}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 opacity-0 transition-opacity duration-200 data-[state=dark]:opacity-100"
>
<span className="absolute inset-0 bg-linear-to-br from-amber-500/5 via-transparent to-amber-500/15 dark:from-amber-500/10 dark:to-amber-500/30" />
</span>
{isDark ? (
<RiSunLine
className="size-4 transition-transform duration-200"
aria-hidden
/>
) : (
<RiMoonClearLine
className="size-4 transition-transform duration-200"
aria-hidden
/>
)}
<span className="sr-only">
{isDark ? "Ativar tema claro" : "Ativar tema escuro"}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8}>
{isDark ? "Tema claro" : "Tema escuro"}
</TooltipContent>
</Tooltip>
);
return (
<Tooltip>
<TooltipTrigger asChild>
<button
ref={buttonRef}
type="button"
onClick={toggleTheme}
data-state={isDark ? "dark" : "light"}
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"group relative text-muted-foreground transition-all duration-200",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground border",
className,
)}
{...props}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 opacity-0 transition-opacity duration-200 data-[state=dark]:opacity-100"
>
<span className="absolute inset-0 bg-linear-to-br from-amber-500/5 via-transparent to-amber-500/15 dark:from-amber-500/10 dark:to-amber-500/30" />
</span>
{isDark ? (
<RiSunLine
className="size-4 transition-transform duration-200"
aria-hidden
/>
) : (
<RiMoonClearLine
className="size-4 transition-transform duration-200"
aria-hidden
/>
)}
<span className="sr-only">
{isDark ? "Ativar tema claro" : "Ativar tema escuro"}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8}>
{isDark ? "Tema claro" : "Tema escuro"}
</TooltipContent>
</Tooltip>
);
};

View File

@@ -1,158 +1,158 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import {
RiArchiveLine,
RiCheckLine,
RiDeleteBin5Line,
RiEyeLine,
RiInboxUnarchiveLine,
RiPencilLine,
RiArchiveLine,
RiCheckLine,
RiDeleteBin5Line,
RiEyeLine,
RiInboxUnarchiveLine,
RiPencilLine,
} from "@remixicon/react";
import { useMemo } from "react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import type { Note } from "./types";
const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
dateStyle: "medium",
dateStyle: "medium",
});
interface NoteCardProps {
note: Note;
onEdit?: (note: Note) => void;
onDetails?: (note: Note) => void;
onRemove?: (note: Note) => void;
onArquivar?: (note: Note) => void;
isArquivadas?: boolean;
note: Note;
onEdit?: (note: Note) => void;
onDetails?: (note: Note) => void;
onRemove?: (note: Note) => void;
onArquivar?: (note: Note) => void;
isArquivadas?: boolean;
}
export function NoteCard({
note,
onEdit,
onDetails,
onRemove,
onArquivar,
isArquivadas = false,
note,
onEdit,
onDetails,
onRemove,
onArquivar,
isArquivadas = false,
}: NoteCardProps) {
const { formattedDate, displayTitle } = useMemo(() => {
const resolvedTitle = note.title.trim().length
? note.title
: "Anotação sem título";
const { formattedDate, displayTitle } = useMemo(() => {
const resolvedTitle = note.title.trim().length
? note.title
: "Anotação sem título";
return {
displayTitle: resolvedTitle,
formattedDate: DATE_FORMATTER.format(new Date(note.createdAt)),
};
}, [note.createdAt, note.title]);
return {
displayTitle: resolvedTitle,
formattedDate: DATE_FORMATTER.format(new Date(note.createdAt)),
};
}, [note.createdAt, note.title]);
const isTask = note.type === "tarefa";
const tasks = note.tasks || [];
const completedCount = tasks.filter((t) => t.completed).length;
const totalCount = tasks.length;
const isTask = note.type === "tarefa";
const tasks = note.tasks || [];
const completedCount = tasks.filter((t) => t.completed).length;
const totalCount = tasks.length;
const actions = [
{
label: "editar",
icon: <RiPencilLine className="size-4" aria-hidden />,
onClick: onEdit,
variant: "default" as const,
},
{
label: "detalhes",
icon: <RiEyeLine className="size-4" aria-hidden />,
onClick: onDetails,
variant: "default" as const,
},
{
label: isArquivadas ? "desarquivar" : "arquivar",
icon: isArquivadas ? (
<RiInboxUnarchiveLine className="size-4" aria-hidden />
) : (
<RiArchiveLine className="size-4" aria-hidden />
),
onClick: onArquivar,
variant: "default" as const,
},
{
label: "remover",
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
onClick: onRemove,
variant: "destructive" as const,
},
].filter((action) => typeof action.onClick === "function");
const actions = [
{
label: "editar",
icon: <RiPencilLine className="size-4" aria-hidden />,
onClick: onEdit,
variant: "default" as const,
},
{
label: "detalhes",
icon: <RiEyeLine className="size-4" aria-hidden />,
onClick: onDetails,
variant: "default" as const,
},
{
label: isArquivadas ? "desarquivar" : "arquivar",
icon: isArquivadas ? (
<RiInboxUnarchiveLine className="size-4" aria-hidden />
) : (
<RiArchiveLine className="size-4" aria-hidden />
),
onClick: onArquivar,
variant: "default" as const,
},
{
label: "remover",
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
onClick: onRemove,
variant: "destructive" as const,
},
].filter((action) => typeof action.onClick === "function");
return (
<Card className="h-[300px] w-[440px] gap-0">
<CardContent className="flex flex-1 flex-col gap-4">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-2">
<h3 className="text-lg font-semibold leading-tight text-foreground wrap-break-word">
{displayTitle}
</h3>
</div>
{isTask && (
<Badge variant="outline" className="text-xs">
{completedCount}/{totalCount} concluídas
</Badge>
)}
</div>
return (
<Card className="h-[300px] w-[440px] gap-0">
<CardContent className="flex flex-1 flex-col gap-4">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-2">
<h3 className="text-lg font-semibold leading-tight text-foreground wrap-break-word">
{displayTitle}
</h3>
</div>
{isTask && (
<Badge variant="outline" className="text-xs">
{completedCount}/{totalCount} concluídas
</Badge>
)}
</div>
{isTask ? (
<div className="flex-1 overflow-auto space-y-2 mt-2">
{tasks.slice(0, 5).map((task) => (
<div key={task.id} className="flex items-start gap-2 text-sm">
<div
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border ${
task.completed
? "bg-green-600 border-green-600"
: "border-input"
}`}
>
{task.completed && (
<RiCheckLine className="h-3 w-3 text-background" />
)}
</div>
<span
className={`leading-relaxed ${
task.completed ? "text-muted-foreground" : "text-foreground"
}`}
>
{task.text}
</span>
</div>
))}
{tasks.length > 5 && (
<p className="text-xs text-muted-foreground pl-5 py-1">
+{tasks.length - 5}
{tasks.length - 5 === 1 ? "tarefa" : "tarefas"}...
</p>
)}
</div>
) : (
<p className="flex-1 overflow-auto whitespace-pre-line text-sm text-muted-foreground wrap-break-word leading-relaxed mt-2">
{note.description}
</p>
)}
</CardContent>
{isTask ? (
<div className="flex-1 overflow-auto space-y-2 mt-2">
{tasks.slice(0, 5).map((task) => (
<div key={task.id} className="flex items-start gap-2 text-sm">
<div
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border ${
task.completed
? "bg-green-600 border-green-600"
: "border-input"
}`}
>
{task.completed && (
<RiCheckLine className="h-3 w-3 text-background" />
)}
</div>
<span
className={`leading-relaxed ${
task.completed ? "text-muted-foreground" : "text-foreground"
}`}
>
{task.text}
</span>
</div>
))}
{tasks.length > 5 && (
<p className="text-xs text-muted-foreground pl-5 py-1">
+{tasks.length - 5}
{tasks.length - 5 === 1 ? "tarefa" : "tarefas"}...
</p>
)}
</div>
) : (
<p className="flex-1 overflow-auto whitespace-pre-line text-sm text-muted-foreground wrap-break-word leading-relaxed mt-2">
{note.description}
</p>
)}
</CardContent>
{actions.length > 0 ? (
<CardFooter className="flex flex-wrap gap-3 px-6 pt-3 text-sm">
{actions.map(({ label, icon, onClick, variant }) => (
<button
key={label}
type="button"
onClick={() => onClick?.(note)}
className={`flex items-center gap-1 font-medium transition-opacity hover:opacity-80 ${
variant === "destructive" ? "text-destructive" : "text-primary"
}`}
aria-label={`${label} anotação`}
>
{icon}
{label}
</button>
))}
</CardFooter>
) : null}
</Card>
);
{actions.length > 0 ? (
<CardFooter className="flex flex-wrap gap-3 px-6 pt-3 text-sm">
{actions.map(({ label, icon, onClick, variant }) => (
<button
key={label}
type="button"
onClick={() => onClick?.(note)}
className={`flex items-center gap-1 font-medium transition-opacity hover:opacity-80 ${
variant === "destructive" ? "text-destructive" : "text-primary"
}`}
aria-label={`${label} anotação`}
>
{icon}
{label}
</button>
))}
</CardFooter>
) : null}
</Card>
);
}

View File

@@ -1,116 +1,116 @@
"use client";
import { RiCheckLine } from "@remixicon/react";
import { useMemo } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { RiCheckLine } from "@remixicon/react";
import { useMemo } from "react";
import { Card } from "../ui/card";
import type { Note } from "./types";
const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
dateStyle: "long",
timeStyle: "short",
dateStyle: "long",
timeStyle: "short",
});
interface NoteDetailsDialogProps {
note: Note | null;
open: boolean;
onOpenChange: (open: boolean) => void;
note: Note | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function NoteDetailsDialog({
note,
open,
onOpenChange,
note,
open,
onOpenChange,
}: NoteDetailsDialogProps) {
const { formattedDate, displayTitle } = useMemo(() => {
if (!note) {
return { formattedDate: "", displayTitle: "" };
}
const { formattedDate, displayTitle } = useMemo(() => {
if (!note) {
return { formattedDate: "", displayTitle: "" };
}
const title = note.title.trim().length ? note.title : "Anotação sem título";
const title = note.title.trim().length ? note.title : "Anotação sem título";
return {
formattedDate: DATE_FORMATTER.format(new Date(note.createdAt)),
displayTitle: title,
};
}, [note]);
return {
formattedDate: DATE_FORMATTER.format(new Date(note.createdAt)),
displayTitle: title,
};
}, [note]);
if (!note) {
return null;
}
if (!note) {
return null;
}
const isTask = note.type === "tarefa";
const tasks = note.tasks || [];
const completedCount = tasks.filter((t) => t.completed).length;
const totalCount = tasks.length;
const isTask = note.type === "tarefa";
const tasks = note.tasks || [];
const completedCount = tasks.filter((t) => t.completed).length;
const totalCount = tasks.length;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{displayTitle}
{isTask && (
<Badge variant="secondary" className="text-xs">
{completedCount}/{totalCount}
</Badge>
)}
</DialogTitle>
<DialogDescription>{formattedDate}</DialogDescription>
</DialogHeader>
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{displayTitle}
{isTask && (
<Badge variant="secondary" className="text-xs">
{completedCount}/{totalCount}
</Badge>
)}
</DialogTitle>
<DialogDescription>{formattedDate}</DialogDescription>
</DialogHeader>
{isTask ? (
<div className="max-h-[320px] overflow-auto space-y-3">
{tasks.map((task) => (
<Card
key={task.id}
className="flex gap-3 p-3 flex-row items-center"
>
<div
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border ${
task.completed
? "bg-green-600 border-green-600"
: "border-input"
}`}
>
{task.completed && (
<RiCheckLine className="h-4 w-4 text-primary-foreground" />
)}
</div>
<span
className={`text-sm ${
task.completed ? "text-muted-foreground" : "text-foreground"
}`}
>
{task.text}
</span>
</Card>
))}
</div>
) : (
<div className="max-h-[320px] overflow-auto whitespace-pre-line wrap-break-word text-sm text-foreground">
{note.description}
</div>
)}
{isTask ? (
<div className="max-h-[320px] overflow-auto space-y-3">
{tasks.map((task) => (
<Card
key={task.id}
className="flex gap-3 p-3 flex-row items-center"
>
<div
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border ${
task.completed
? "bg-green-600 border-green-600"
: "border-input"
}`}
>
{task.completed && (
<RiCheckLine className="h-4 w-4 text-primary-foreground" />
)}
</div>
<span
className={`text-sm ${
task.completed ? "text-muted-foreground" : "text-foreground"
}`}
>
{task.text}
</span>
</Card>
))}
</div>
) : (
<div className="max-h-[320px] overflow-auto whitespace-pre-line wrap-break-word text-sm text-foreground">
{note.description}
</div>
)}
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Fechar
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Fechar
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,45 +1,45 @@
"use client";
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react";
import {
createNoteAction,
updateNoteAction,
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
useTransition,
} from "react";
import { toast } from "sonner";
import {
createNoteAction,
updateNoteAction,
} from "@/app/(dashboard)/anotacoes/actions";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { useControlledState } from "@/hooks/use-controlled-state";
import { useFormState } from "@/hooks/use-form-state";
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react";
import {
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
useTransition,
} from "react";
import { toast } from "sonner";
import { Card } from "../ui/card";
import type { Note, NoteFormValues, Task } from "./types";
type NoteDialogMode = "create" | "update";
interface NoteDialogProps {
mode: NoteDialogMode;
trigger?: ReactNode;
note?: Note;
open?: boolean;
onOpenChange?: (open: boolean) => void;
mode: NoteDialogMode;
trigger?: ReactNode;
note?: Note;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
const MAX_TITLE = 30;
@@ -47,426 +47,426 @@ const MAX_DESC = 350;
const normalize = (s: string) => s.replace(/\s+/g, " ").trim();
const buildInitialValues = (note?: Note): NoteFormValues => ({
title: note?.title ?? "",
description: note?.description ?? "",
type: note?.type ?? "nota",
tasks: note?.tasks ?? [],
title: note?.title ?? "",
description: note?.description ?? "",
type: note?.type ?? "nota",
tasks: note?.tasks ?? [],
});
const generateTaskId = () => {
return `task-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
return `task-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
};
export function NoteDialog({
mode,
trigger,
note,
open,
onOpenChange,
mode,
trigger,
note,
open,
onOpenChange,
}: NoteDialogProps) {
const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [newTaskText, setNewTaskText] = useState("");
const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [newTaskText, setNewTaskText] = useState("");
const titleRef = useRef<HTMLInputElement>(null);
const descRef = useRef<HTMLTextAreaElement>(null);
const newTaskRef = useRef<HTMLInputElement>(null);
const titleRef = useRef<HTMLInputElement>(null);
const descRef = useRef<HTMLTextAreaElement>(null);
const newTaskRef = useRef<HTMLInputElement>(null);
// Use controlled state hook for dialog open state
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange
);
// Use controlled state hook for dialog open state
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange,
);
const initialState = buildInitialValues(note);
const initialState = buildInitialValues(note);
// Use form state hook for form management
const { formState, updateField, setFormState } =
useFormState<NoteFormValues>(initialState);
// Use form state hook for form management
const { formState, updateField, setFormState } =
useFormState<NoteFormValues>(initialState);
useEffect(() => {
if (dialogOpen) {
setFormState(buildInitialValues(note));
setErrorMessage(null);
setNewTaskText("");
requestAnimationFrame(() => titleRef.current?.focus());
}
}, [dialogOpen, note, setFormState]);
useEffect(() => {
if (dialogOpen) {
setFormState(buildInitialValues(note));
setErrorMessage(null);
setNewTaskText("");
requestAnimationFrame(() => titleRef.current?.focus());
}
}, [dialogOpen, note, setFormState]);
const title = mode === "create" ? "Nova anotação" : "Editar anotação";
const description =
mode === "create"
? "Escolha entre uma nota simples ou uma lista de tarefas."
: "Altere o título e/ou conteúdo desta anotação.";
const submitLabel =
mode === "create" ? "Salvar anotação" : "Atualizar anotação";
const title = mode === "create" ? "Nova anotação" : "Editar anotação";
const description =
mode === "create"
? "Escolha entre uma nota simples ou uma lista de tarefas."
: "Altere o título e/ou conteúdo desta anotação.";
const submitLabel =
mode === "create" ? "Salvar anotação" : "Atualizar anotação";
const titleCount = formState.title.length;
const descCount = formState.description.length;
const isNote = formState.type === "nota";
const titleCount = formState.title.length;
const descCount = formState.description.length;
const isNote = formState.type === "nota";
const onlySpaces =
normalize(formState.title).length === 0 ||
(isNote && normalize(formState.description).length === 0) ||
(!isNote && (!formState.tasks || formState.tasks.length === 0));
const onlySpaces =
normalize(formState.title).length === 0 ||
(isNote && normalize(formState.description).length === 0) ||
(!isNote && (!formState.tasks || formState.tasks.length === 0));
const invalidLen = titleCount > MAX_TITLE || descCount > MAX_DESC;
const invalidLen = titleCount > MAX_TITLE || descCount > MAX_DESC;
const unchanged =
mode === "update" &&
normalize(formState.title) === normalize(note?.title ?? "") &&
normalize(formState.description) === normalize(note?.description ?? "") &&
JSON.stringify(formState.tasks) === JSON.stringify(note?.tasks);
const unchanged =
mode === "update" &&
normalize(formState.title) === normalize(note?.title ?? "") &&
normalize(formState.description) === normalize(note?.description ?? "") &&
JSON.stringify(formState.tasks) === JSON.stringify(note?.tasks);
const disableSubmit = isPending || onlySpaces || unchanged || invalidLen;
const disableSubmit = isPending || onlySpaces || unchanged || invalidLen;
const handleOpenChange = useCallback(
(v: boolean) => {
setDialogOpen(v);
if (!v) setErrorMessage(null);
},
[setDialogOpen]
);
const handleOpenChange = useCallback(
(v: boolean) => {
setDialogOpen(v);
if (!v) setErrorMessage(null);
},
[setDialogOpen],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter")
(e.currentTarget as HTMLFormElement).requestSubmit();
if (e.key === "Escape") handleOpenChange(false);
},
[handleOpenChange]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter")
(e.currentTarget as HTMLFormElement).requestSubmit();
if (e.key === "Escape") handleOpenChange(false);
},
[handleOpenChange],
);
const handleAddTask = useCallback(() => {
const text = normalize(newTaskText);
if (!text) return;
const handleAddTask = useCallback(() => {
const text = normalize(newTaskText);
if (!text) return;
const newTask: Task = {
id: generateTaskId(),
text,
completed: false,
};
const newTask: Task = {
id: generateTaskId(),
text,
completed: false,
};
updateField("tasks", [...(formState.tasks || []), newTask]);
setNewTaskText("");
requestAnimationFrame(() => newTaskRef.current?.focus());
}, [newTaskText, formState.tasks, updateField]);
updateField("tasks", [...(formState.tasks || []), newTask]);
setNewTaskText("");
requestAnimationFrame(() => newTaskRef.current?.focus());
}, [newTaskText, formState.tasks, updateField]);
const handleRemoveTask = useCallback(
(taskId: string) => {
updateField(
"tasks",
(formState.tasks || []).filter((t) => t.id !== taskId)
);
},
[formState.tasks, updateField]
);
const handleRemoveTask = useCallback(
(taskId: string) => {
updateField(
"tasks",
(formState.tasks || []).filter((t) => t.id !== taskId),
);
},
[formState.tasks, updateField],
);
const handleToggleTask = useCallback(
(taskId: string) => {
updateField(
"tasks",
(formState.tasks || []).map((t) =>
t.id === taskId ? { ...t, completed: !t.completed } : t
)
);
},
[formState.tasks, updateField]
);
const handleToggleTask = useCallback(
(taskId: string) => {
updateField(
"tasks",
(formState.tasks || []).map((t) =>
t.id === taskId ? { ...t, completed: !t.completed } : t,
),
);
},
[formState.tasks, updateField],
);
const handleSubmit = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setErrorMessage(null);
const handleSubmit = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setErrorMessage(null);
const payload = {
title: normalize(formState.title),
description: normalize(formState.description),
type: formState.type,
tasks: formState.tasks,
};
const payload = {
title: normalize(formState.title),
description: normalize(formState.description),
type: formState.type,
tasks: formState.tasks,
};
if (onlySpaces || invalidLen) {
setErrorMessage("Preencha os campos respeitando os limites.");
titleRef.current?.focus();
return;
}
if (onlySpaces || invalidLen) {
setErrorMessage("Preencha os campos respeitando os limites.");
titleRef.current?.focus();
return;
}
if (mode === "update" && !note?.id) {
const msg = "Não foi possível identificar a anotação a ser editada.";
setErrorMessage(msg);
toast.error(msg);
return;
}
if (mode === "update" && !note?.id) {
const msg = "Não foi possível identificar a anotação a ser editada.";
setErrorMessage(msg);
toast.error(msg);
return;
}
if (unchanged) {
toast.info("Nada para atualizar.");
return;
}
if (unchanged) {
toast.info("Nada para atualizar.");
return;
}
startTransition(async () => {
let result;
if (mode === "create") {
result = await createNoteAction(payload);
} else {
if (!note?.id) {
const msg = "ID da anotação não encontrado.";
setErrorMessage(msg);
toast.error(msg);
return;
}
result = await updateNoteAction({ id: note.id, ...payload });
}
startTransition(async () => {
let result;
if (mode === "create") {
result = await createNoteAction(payload);
} else {
if (!note?.id) {
const msg = "ID da anotação não encontrado.";
setErrorMessage(msg);
toast.error(msg);
return;
}
result = await updateNoteAction({ id: note.id, ...payload });
}
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
titleRef.current?.focus();
});
},
[
formState.title,
formState.description,
formState.type,
formState.tasks,
mode,
note,
setDialogOpen,
onlySpaces,
unchanged,
invalidLen,
]
);
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
titleRef.current?.focus();
});
},
[
formState.title,
formState.description,
formState.type,
formState.tasks,
mode,
note,
setDialogOpen,
onlySpaces,
unchanged,
invalidLen,
],
);
return (
<Dialog open={dialogOpen} onOpenChange={handleOpenChange}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
return (
<Dialog open={dialogOpen} onOpenChange={handleOpenChange}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<form
className="space-y-4"
onSubmit={handleSubmit}
onKeyDown={handleKeyDown}
noValidate
>
{/* Seletor de Tipo - apenas no modo de criação */}
{mode === "create" && (
<div className="space-y-3">
<label className="text-sm font-medium text-foreground">
Tipo de anotação
</label>
<RadioGroup
value={formState.type}
onValueChange={(value) =>
updateField("type", value as "nota" | "tarefa")
}
disabled={isPending}
className="flex gap-4"
>
<div className="flex items-center gap-2">
<RadioGroupItem value="nota" id="tipo-nota" />
<label
htmlFor="tipo-nota"
className="text-sm cursor-pointer select-none"
>
Nota
</label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="tarefa" id="tipo-tarefa" />
<label
htmlFor="tipo-tarefa"
className="text-sm cursor-pointer select-none"
>
Tarefas
</label>
</div>
</RadioGroup>
</div>
)}
<form
className="space-y-4"
onSubmit={handleSubmit}
onKeyDown={handleKeyDown}
noValidate
>
{/* Seletor de Tipo - apenas no modo de criação */}
{mode === "create" && (
<div className="space-y-3">
<label className="text-sm font-medium text-foreground">
Tipo de anotação
</label>
<RadioGroup
value={formState.type}
onValueChange={(value) =>
updateField("type", value as "nota" | "tarefa")
}
disabled={isPending}
className="flex gap-4"
>
<div className="flex items-center gap-2">
<RadioGroupItem value="nota" id="tipo-nota" />
<label
htmlFor="tipo-nota"
className="text-sm cursor-pointer select-none"
>
Nota
</label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="tarefa" id="tipo-tarefa" />
<label
htmlFor="tipo-tarefa"
className="text-sm cursor-pointer select-none"
>
Tarefas
</label>
</div>
</RadioGroup>
</div>
)}
{/* Título */}
<div className="space-y-2">
<label
htmlFor="note-title"
className="text-sm font-medium text-foreground"
>
Título
</label>
<Input
id="note-title"
ref={titleRef}
value={formState.title}
onChange={(e) => updateField("title", e.target.value)}
placeholder={
isNote ? "Ex.: Revisar metas do mês" : "Ex.: Tarefas da semana"
}
maxLength={MAX_TITLE}
disabled={isPending}
aria-describedby="note-title-help"
required
/>
<p
id="note-title-help"
className="text-xs text-muted-foreground"
aria-live="polite"
>
Até {MAX_TITLE} caracteres. Restantes:{" "}
{Math.max(0, MAX_TITLE - titleCount)}.
</p>
</div>
{/* Título */}
<div className="space-y-2">
<label
htmlFor="note-title"
className="text-sm font-medium text-foreground"
>
Título
</label>
<Input
id="note-title"
ref={titleRef}
value={formState.title}
onChange={(e) => updateField("title", e.target.value)}
placeholder={
isNote ? "Ex.: Revisar metas do mês" : "Ex.: Tarefas da semana"
}
maxLength={MAX_TITLE}
disabled={isPending}
aria-describedby="note-title-help"
required
/>
<p
id="note-title-help"
className="text-xs text-muted-foreground"
aria-live="polite"
>
Até {MAX_TITLE} caracteres. Restantes:{" "}
{Math.max(0, MAX_TITLE - titleCount)}.
</p>
</div>
{/* Conteúdo - apenas para Notas */}
{isNote && (
<div className="space-y-2">
<label
htmlFor="note-description"
className="text-sm font-medium text-foreground"
>
Conteúdo
</label>
<Textarea
id="note-description"
className="field-sizing-fixed"
ref={descRef}
value={formState.description}
onChange={(e) => updateField("description", e.target.value)}
placeholder="Detalhe sua anotação..."
rows={6}
maxLength={MAX_DESC}
disabled={isPending}
aria-describedby="note-desc-help"
required
/>
<p
id="note-desc-help"
className="text-xs text-muted-foreground"
aria-live="polite"
>
Até {MAX_DESC} caracteres. Restantes:{" "}
{Math.max(0, MAX_DESC - descCount)}.
</p>
</div>
)}
{/* Conteúdo - apenas para Notas */}
{isNote && (
<div className="space-y-2">
<label
htmlFor="note-description"
className="text-sm font-medium text-foreground"
>
Conteúdo
</label>
<Textarea
id="note-description"
className="field-sizing-fixed"
ref={descRef}
value={formState.description}
onChange={(e) => updateField("description", e.target.value)}
placeholder="Detalhe sua anotação..."
rows={6}
maxLength={MAX_DESC}
disabled={isPending}
aria-describedby="note-desc-help"
required
/>
<p
id="note-desc-help"
className="text-xs text-muted-foreground"
aria-live="polite"
>
Até {MAX_DESC} caracteres. Restantes:{" "}
{Math.max(0, MAX_DESC - descCount)}.
</p>
</div>
)}
{/* Lista de Tarefas - apenas para Tarefas */}
{!isNote && (
<div className="space-y-4">
<div className="space-y-2">
<label
htmlFor="new-task-input"
className="text-sm font-medium text-foreground"
>
Adicionar tarefa
</label>
<div className="flex gap-2">
<Input
id="new-task-input"
ref={newTaskRef}
value={newTaskText}
onChange={(e) => setNewTaskText(e.target.value)}
placeholder="Ex.: Comprar ingredientes para o jantar"
disabled={isPending}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddTask();
}
}}
/>
<Button
type="button"
onClick={handleAddTask}
disabled={isPending || !normalize(newTaskText)}
className="shrink-0"
>
<RiAddLine className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Pressione Enter ou clique no botão + para adicionar
</p>
</div>
{/* Lista de Tarefas - apenas para Tarefas */}
{!isNote && (
<div className="space-y-4">
<div className="space-y-2">
<label
htmlFor="new-task-input"
className="text-sm font-medium text-foreground"
>
Adicionar tarefa
</label>
<div className="flex gap-2">
<Input
id="new-task-input"
ref={newTaskRef}
value={newTaskText}
onChange={(e) => setNewTaskText(e.target.value)}
placeholder="Ex.: Comprar ingredientes para o jantar"
disabled={isPending}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddTask();
}
}}
/>
<Button
type="button"
onClick={handleAddTask}
disabled={isPending || !normalize(newTaskText)}
className="shrink-0"
>
<RiAddLine className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Pressione Enter ou clique no botão + para adicionar
</p>
</div>
{/* Lista de tarefas existentes */}
{formState.tasks && formState.tasks.length > 0 && (
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Tarefas ({formState.tasks.length})
</label>
<div className="space-y-2 max-h-[240px] overflow-y-auto pr-1">
{formState.tasks.map((task) => (
<Card
key={task.id}
className="flex items-center gap-3 px-3 py-2 flex-row mt-1"
>
<Checkbox
className="data-[state=checked]:bg-green-600 data-[state=checked]:border-green-600"
checked={task.completed}
onCheckedChange={() => handleToggleTask(task.id)}
disabled={isPending}
aria-label={`Marcar tarefa "${task.text}" como ${
task.completed ? "não concluída" : "concluída"
}`}
/>
<span
className={`flex-1 text-sm wrap-break-word ${
task.completed
? "text-muted-foreground"
: "text-foreground"
}`}
>
{task.text}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveTask(task.id)}
disabled={isPending}
className="h-8 w-8 p-0 shrink-0 text-muted-foreground hover:text-destructive"
aria-label={`Remover tarefa "${task.text}"`}
>
<RiDeleteBinLine className="h-4 w-4" />
</Button>
</Card>
))}
</div>
</div>
)}
</div>
)}
{/* Lista de tarefas existentes */}
{formState.tasks && formState.tasks.length > 0 && (
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Tarefas ({formState.tasks.length})
</label>
<div className="space-y-2 max-h-[240px] overflow-y-auto pr-1">
{formState.tasks.map((task) => (
<Card
key={task.id}
className="flex items-center gap-3 px-3 py-2 flex-row mt-1"
>
<Checkbox
className="data-[state=checked]:bg-green-600 data-[state=checked]:border-green-600"
checked={task.completed}
onCheckedChange={() => handleToggleTask(task.id)}
disabled={isPending}
aria-label={`Marcar tarefa "${task.text}" como ${
task.completed ? "não concluída" : "concluída"
}`}
/>
<span
className={`flex-1 text-sm wrap-break-word ${
task.completed
? "text-muted-foreground"
: "text-foreground"
}`}
>
{task.text}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveTask(task.id)}
disabled={isPending}
className="h-8 w-8 p-0 shrink-0 text-muted-foreground hover:text-destructive"
aria-label={`Remover tarefa "${task.text}"`}
>
<RiDeleteBinLine className="h-4 w-4" />
</Button>
</Card>
))}
</div>
</div>
)}
</div>
)}
{errorMessage ? (
<p className="text-sm text-destructive" role="alert">
{errorMessage}
</p>
) : null}
{errorMessage ? (
<p className="text-sm text-destructive" role="alert">
{errorMessage}
</p>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="submit" disabled={disableSubmit}>
{isPending ? "Salvando..." : submitLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="submit" disabled={disableSubmit}>
{isPending ? "Salvando..." : submitLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,12 +1,15 @@
"use client";
import { arquivarAnotacaoAction, deleteNoteAction } from "@/app/(dashboard)/anotacoes/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { EmptyState } from "@/components/empty-state";
import { Button } from "@/components/ui/button";
import { RiAddCircleLine, RiTodoLine } from "@remixicon/react";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import {
arquivarAnotacaoAction,
deleteNoteAction,
} from "@/app/(dashboard)/anotacoes/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { EmptyState } from "@/components/empty-state";
import { Button } from "@/components/ui/button";
import { Card } from "../ui/card";
import { NoteCard } from "./note-card";
import { NoteDetailsDialog } from "./note-details-dialog";
@@ -14,225 +17,225 @@ import { NoteDialog } from "./note-dialog";
import type { Note } from "./types";
interface NotesPageProps {
notes: Note[];
isArquivadas?: boolean;
notes: Note[];
isArquivadas?: boolean;
}
export function NotesPage({ notes, isArquivadas = false }: NotesPageProps) {
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [noteToEdit, setNoteToEdit] = useState<Note | null>(null);
const [detailsOpen, setDetailsOpen] = useState(false);
const [noteDetails, setNoteDetails] = useState<Note | null>(null);
const [removeOpen, setRemoveOpen] = useState(false);
const [noteToRemove, setNoteToRemove] = useState<Note | null>(null);
const [arquivarOpen, setArquivarOpen] = useState(false);
const [noteToArquivar, setNoteToArquivar] = useState<Note | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [noteToEdit, setNoteToEdit] = useState<Note | null>(null);
const [detailsOpen, setDetailsOpen] = useState(false);
const [noteDetails, setNoteDetails] = useState<Note | null>(null);
const [removeOpen, setRemoveOpen] = useState(false);
const [noteToRemove, setNoteToRemove] = useState<Note | null>(null);
const [arquivarOpen, setArquivarOpen] = useState(false);
const [noteToArquivar, setNoteToArquivar] = useState<Note | null>(null);
const sortedNotes = useMemo(
() =>
[...notes].sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
),
[notes]
);
const sortedNotes = useMemo(
() =>
[...notes].sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
),
[notes],
);
const handleCreateOpenChange = useCallback((open: boolean) => {
setCreateOpen(open);
}, []);
const handleCreateOpenChange = useCallback((open: boolean) => {
setCreateOpen(open);
}, []);
const handleEditOpenChange = useCallback((open: boolean) => {
setEditOpen(open);
if (!open) {
setNoteToEdit(null);
}
}, []);
const handleEditOpenChange = useCallback((open: boolean) => {
setEditOpen(open);
if (!open) {
setNoteToEdit(null);
}
}, []);
const handleDetailsOpenChange = useCallback((open: boolean) => {
setDetailsOpen(open);
if (!open) {
setNoteDetails(null);
}
}, []);
const handleDetailsOpenChange = useCallback((open: boolean) => {
setDetailsOpen(open);
if (!open) {
setNoteDetails(null);
}
}, []);
const handleRemoveOpenChange = useCallback((open: boolean) => {
setRemoveOpen(open);
if (!open) {
setNoteToRemove(null);
}
}, []);
const handleRemoveOpenChange = useCallback((open: boolean) => {
setRemoveOpen(open);
if (!open) {
setNoteToRemove(null);
}
}, []);
const handleArquivarOpenChange = useCallback((open: boolean) => {
setArquivarOpen(open);
if (!open) {
setNoteToArquivar(null);
}
}, []);
const handleArquivarOpenChange = useCallback((open: boolean) => {
setArquivarOpen(open);
if (!open) {
setNoteToArquivar(null);
}
}, []);
const handleEditRequest = useCallback((note: Note) => {
setNoteToEdit(note);
setEditOpen(true);
}, []);
const handleEditRequest = useCallback((note: Note) => {
setNoteToEdit(note);
setEditOpen(true);
}, []);
const handleDetailsRequest = useCallback((note: Note) => {
setNoteDetails(note);
setDetailsOpen(true);
}, []);
const handleDetailsRequest = useCallback((note: Note) => {
setNoteDetails(note);
setDetailsOpen(true);
}, []);
const handleRemoveRequest = useCallback((note: Note) => {
setNoteToRemove(note);
setRemoveOpen(true);
}, []);
const handleRemoveRequest = useCallback((note: Note) => {
setNoteToRemove(note);
setRemoveOpen(true);
}, []);
const handleArquivarRequest = useCallback((note: Note) => {
setNoteToArquivar(note);
setArquivarOpen(true);
}, []);
const handleArquivarRequest = useCallback((note: Note) => {
setNoteToArquivar(note);
setArquivarOpen(true);
}, []);
const handleArquivarConfirm = useCallback(async () => {
if (!noteToArquivar) {
return;
}
const handleArquivarConfirm = useCallback(async () => {
if (!noteToArquivar) {
return;
}
const result = await arquivarAnotacaoAction({
id: noteToArquivar.id,
arquivada: !isArquivadas,
});
const result = await arquivarAnotacaoAction({
id: noteToArquivar.id,
arquivada: !isArquivadas,
});
if (result.success) {
toast.success(result.message);
return;
}
if (result.success) {
toast.success(result.message);
return;
}
toast.error(result.error);
throw new Error(result.error);
}, [noteToArquivar, isArquivadas]);
toast.error(result.error);
throw new Error(result.error);
}, [noteToArquivar, isArquivadas]);
const handleRemoveConfirm = useCallback(async () => {
if (!noteToRemove) {
return;
}
const handleRemoveConfirm = useCallback(async () => {
if (!noteToRemove) {
return;
}
const result = await deleteNoteAction({ id: noteToRemove.id });
const result = await deleteNoteAction({ id: noteToRemove.id });
if (result.success) {
toast.success(result.message);
return;
}
if (result.success) {
toast.success(result.message);
return;
}
toast.error(result.error);
throw new Error(result.error);
}, [noteToRemove]);
toast.error(result.error);
throw new Error(result.error);
}, [noteToRemove]);
const removeTitle = noteToRemove
? noteToRemove.title.trim().length
? `Remover anotação "${noteToRemove.title}"?`
: "Remover anotação?"
: "Remover anotação?";
const removeTitle = noteToRemove
? noteToRemove.title.trim().length
? `Remover anotação "${noteToRemove.title}"?`
: "Remover anotação?"
: "Remover anotação?";
const arquivarTitle = noteToArquivar
? noteToArquivar.title.trim().length
? isArquivadas
? `Desarquivar anotação "${noteToArquivar.title}"?`
: `Arquivar anotação "${noteToArquivar.title}"?`
: isArquivadas
? "Desarquivar anotação?"
: "Arquivar anotação?"
: isArquivadas
? "Desarquivar anotação?"
: "Arquivar anotação?";
const arquivarTitle = noteToArquivar
? noteToArquivar.title.trim().length
? isArquivadas
? `Desarquivar anotação "${noteToArquivar.title}"?`
: `Arquivar anotação "${noteToArquivar.title}"?`
: isArquivadas
? "Desarquivar anotação?"
: "Arquivar anotação?"
: isArquivadas
? "Desarquivar anotação?"
: "Arquivar anotação?";
return (
<>
<div className="flex w-full flex-col gap-6">
{!isArquivadas && (
<div className="flex justify-start">
<NoteDialog
mode="create"
open={createOpen}
onOpenChange={handleCreateOpenChange}
trigger={
<Button>
<RiAddCircleLine className="size-4" />
Nova anotação
</Button>
}
/>
</div>
)}
return (
<>
<div className="flex w-full flex-col gap-6">
{!isArquivadas && (
<div className="flex justify-start">
<NoteDialog
mode="create"
open={createOpen}
onOpenChange={handleCreateOpenChange}
trigger={
<Button>
<RiAddCircleLine className="size-4" />
Nova anotação
</Button>
}
/>
</div>
)}
{sortedNotes.length === 0 ? (
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
<EmptyState
media={<RiTodoLine className="size-6 text-primary" />}
title={
isArquivadas
? "Nenhuma anotação arquivada"
: "Nenhuma anotação registrada"
}
description={
isArquivadas
? "As anotações arquivadas aparecerão aqui."
: "Crie anotações personalizadas para acompanhar lembretes, decisões ou observações financeiras importantes."
}
/>
</Card>
) : (
<div className="flex flex-wrap gap-4">
{sortedNotes.map((note) => (
<NoteCard
key={note.id}
note={note}
onEdit={handleEditRequest}
onDetails={handleDetailsRequest}
onRemove={handleRemoveRequest}
onArquivar={handleArquivarRequest}
isArquivadas={isArquivadas}
/>
))}
</div>
)}
</div>
{sortedNotes.length === 0 ? (
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
<EmptyState
media={<RiTodoLine className="size-6 text-primary" />}
title={
isArquivadas
? "Nenhuma anotação arquivada"
: "Nenhuma anotação registrada"
}
description={
isArquivadas
? "As anotações arquivadas aparecerão aqui."
: "Crie anotações personalizadas para acompanhar lembretes, decisões ou observações financeiras importantes."
}
/>
</Card>
) : (
<div className="flex flex-wrap gap-4">
{sortedNotes.map((note) => (
<NoteCard
key={note.id}
note={note}
onEdit={handleEditRequest}
onDetails={handleDetailsRequest}
onRemove={handleRemoveRequest}
onArquivar={handleArquivarRequest}
isArquivadas={isArquivadas}
/>
))}
</div>
)}
</div>
<NoteDialog
mode="update"
note={noteToEdit ?? undefined}
open={editOpen}
onOpenChange={handleEditOpenChange}
/>
<NoteDialog
mode="update"
note={noteToEdit ?? undefined}
open={editOpen}
onOpenChange={handleEditOpenChange}
/>
<NoteDetailsDialog
note={noteDetails}
open={detailsOpen}
onOpenChange={handleDetailsOpenChange}
/>
<NoteDetailsDialog
note={noteDetails}
open={detailsOpen}
onOpenChange={handleDetailsOpenChange}
/>
<ConfirmActionDialog
open={arquivarOpen}
onOpenChange={handleArquivarOpenChange}
title={arquivarTitle}
description={
isArquivadas
? "A anotação será movida de volta para a lista principal."
: "A anotação será movida para arquivadas."
}
confirmLabel={isArquivadas ? "Desarquivar" : "Arquivar"}
confirmVariant="default"
pendingLabel={isArquivadas ? "Desarquivando..." : "Arquivando..."}
onConfirm={handleArquivarConfirm}
/>
<ConfirmActionDialog
open={arquivarOpen}
onOpenChange={handleArquivarOpenChange}
title={arquivarTitle}
description={
isArquivadas
? "A anotação será movida de volta para a lista principal."
: "A anotação será movida para arquivadas."
}
confirmLabel={isArquivadas ? "Desarquivar" : "Arquivar"}
confirmVariant="default"
pendingLabel={isArquivadas ? "Desarquivando..." : "Arquivando..."}
onConfirm={handleArquivarConfirm}
/>
<ConfirmActionDialog
open={removeOpen}
onOpenChange={handleRemoveOpenChange}
title={removeTitle}
description="Essa ação não pode ser desfeita."
confirmLabel="Remover"
confirmVariant="destructive"
pendingLabel="Removendo..."
onConfirm={handleRemoveConfirm}
/>
</>
);
<ConfirmActionDialog
open={removeOpen}
onOpenChange={handleRemoveOpenChange}
title={removeTitle}
description="Essa ação não pode ser desfeita."
confirmLabel="Remover"
confirmVariant="destructive"
pendingLabel="Removendo..."
onConfirm={handleRemoveConfirm}
/>
</>
);
}

View File

@@ -1,24 +1,24 @@
export type NoteType = "nota" | "tarefa";
export interface Task {
id: string;
text: string;
completed: boolean;
id: string;
text: string;
completed: boolean;
}
export interface Note {
id: string;
title: string;
description: string;
type: NoteType;
tasks?: Task[];
arquivada: boolean;
createdAt: string;
id: string;
title: string;
description: string;
type: NoteType;
tasks?: Task[];
arquivada: boolean;
createdAt: string;
}
export interface NoteFormValues {
title: string;
description: string;
type: NoteType;
tasks?: Task[];
title: string;
description: string;
type: NoteType;
tasks?: Task[];
}

View File

@@ -1,17 +1,17 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { RiTerminalLine } from "@remixicon/react";
import { Alert, AlertDescription } from "@/components/ui/alert";
interface AuthErrorAlertProps {
error: string;
error: string;
}
export function AuthErrorAlert({ error }: AuthErrorAlertProps) {
if (!error) return null;
if (!error) return null;
return (
<Alert className="mt-2 border border-red-500" variant="destructive">
<RiTerminalLine className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
);
return (
<Alert className="mt-2 border border-red-500" variant="destructive">
<RiTerminalLine className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
);
}

View File

@@ -1,17 +1,17 @@
import { FieldDescription } from "@/components/ui/field";
export function AuthFooter() {
return (
<FieldDescription className="px-6 text-center">
Ao continuar, você concorda com nossos{" "}
<a href="/terms" className="underline underline-offset-4">
Termos de Serviço
</a>{" "}
e{" "}
<a href="/privacy" className="underline underline-offset-4">
Política de Privacidade
</a>
.
</FieldDescription>
);
return (
<FieldDescription className="px-6 text-center">
Ao continuar, você concorda com nossos{" "}
<a href="/terms" className="underline underline-offset-4">
Termos de Serviço
</a>{" "}
e{" "}
<a href="/privacy" className="underline underline-offset-4">
Política de Privacidade
</a>
.
</FieldDescription>
);
}

View File

@@ -1,15 +1,15 @@
import { cn } from "@/lib/utils/ui";
interface AuthHeaderProps {
title: string;
title: string;
}
export function AuthHeader({ title }: AuthHeaderProps) {
return (
<div className={cn("flex flex-col gap-1.5")}>
<h1 className="text-xl font-semibold tracking-tight text-card-foreground">
{title}
</h1>
</div>
);
return (
<div className={cn("flex flex-col gap-1.5")}>
<h1 className="text-xl font-semibold tracking-tight text-card-foreground">
{title}
</h1>
</div>
);
}

View File

@@ -1,34 +1,34 @@
import MagnetLines from "../magnet-lines";
function AuthSidebar() {
return (
<div className="relative hidden flex-col overflow-hidden bg-welcome-banner text-welcome-banner-foreground md:flex">
<div className="absolute inset-0 flex items-center justify-center opacity-20 pointer-events-none">
<MagnetLines
rows={10}
columns={16}
containerSize="120%"
lineColor="currentColor"
lineWidth="0.35vmin"
lineHeight="5vmin"
baseAngle={-4}
className="text-welcome-banner-foreground"
/>
</div>
return (
<div className="relative hidden flex-col overflow-hidden bg-welcome-banner text-welcome-banner-foreground md:flex">
<div className="absolute inset-0 flex items-center justify-center opacity-20 pointer-events-none">
<MagnetLines
rows={10}
columns={16}
containerSize="120%"
lineColor="currentColor"
lineWidth="0.35vmin"
lineHeight="5vmin"
baseAngle={-4}
className="text-welcome-banner-foreground"
/>
</div>
<div className="relative flex flex-1 flex-col justify-between p-8">
<div className="space-y-4">
<h2 className="text-3xl font-semibold leading-tight">
Controle suas finanças com clareza e foco diário.
</h2>
<p className="text-sm opacity-90">
Centralize despesas, organize cartões e acompanhe metas mensais em
um painel inteligente feito para o seu dia a dia.
</p>
</div>
</div>
</div>
);
<div className="relative flex flex-1 flex-col justify-between p-8">
<div className="space-y-4">
<h2 className="text-3xl font-semibold leading-tight">
Controle suas finanças com clareza e foco diário.
</h2>
<p className="text-sm opacity-90">
Centralize despesas, organize cartões e acompanhe metas mensais em
um painel inteligente feito para o seu dia a dia.
</p>
</div>
</div>
</div>
);
}
export default AuthSidebar;

View File

@@ -1,54 +1,54 @@
import { Button } from "@/components/ui/button";
import { RiLoader4Line } from "@remixicon/react";
import { Button } from "@/components/ui/button";
interface GoogleAuthButtonProps {
onClick: () => void;
loading?: boolean;
disabled?: boolean;
text?: string;
onClick: () => void;
loading?: boolean;
disabled?: boolean;
text?: string;
}
export function GoogleAuthButton({
onClick,
loading = false,
disabled = false,
text = "Continuar com Google",
onClick,
loading = false,
disabled = false,
text = "Continuar com Google",
}: GoogleAuthButtonProps) {
return (
<Button
variant="outline"
type="button"
onClick={onClick}
disabled={disabled || loading}
className="w-full gap-2"
>
{loading ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
className="h-5 w-5"
>
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
)}
<span>{text}</span>
</Button>
);
return (
<Button
variant="outline"
type="button"
onClick={onClick}
disabled={disabled || loading}
className="w-full gap-2"
>
{loading ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
className="h-5 w-5"
>
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
)}
<span>{text}</span>
</Button>
);
}

View File

@@ -1,20 +1,20 @@
"use client";
import { RiLoader4Line } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { type FormEvent, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { authClient, googleSignInAvailable } from "@/lib/auth/client";
import { cn } from "@/lib/utils/ui";
import { RiLoader4Line } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { useState, type FormEvent } from "react";
import { toast } from "sonner";
import { Logo } from "../logo";
import { AuthErrorAlert } from "./auth-error-alert";
import { AuthHeader } from "./auth-header";
@@ -24,165 +24,170 @@ import { GoogleAuthButton } from "./google-auth-button";
type DivProps = React.ComponentProps<"div">;
export function LoginForm({ className, ...props }: DivProps) {
const router = useRouter();
const isGoogleAvailable = googleSignInAvailable;
const router = useRouter();
const isGoogleAvailable = googleSignInAvailable;
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loadingEmail, setLoadingEmail] = useState(false);
const [loadingGoogle, setLoadingGoogle] = useState(false);
const [error, setError] = useState("");
const [loadingEmail, setLoadingEmail] = useState(false);
const [loadingGoogle, setLoadingGoogle] = useState(false);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
await authClient.signIn.email(
{
email,
password,
callbackURL: "/dashboard",
rememberMe: false,
},
{
onRequest: () => {
setError("");
setLoadingEmail(true);
},
onSuccess: () => {
setLoadingEmail(false);
toast.success("Login realizado com sucesso!");
router.replace("/dashboard");
},
onError: (ctx) => {
if (ctx.error.status === 500 && ctx.error.statusText === "Internal Server Error") {
toast.error("Ocorreu uma falha na requisição. Tente novamente mais tarde.");
}
await authClient.signIn.email(
{
email,
password,
callbackURL: "/dashboard",
rememberMe: false,
},
{
onRequest: () => {
setError("");
setLoadingEmail(true);
},
onSuccess: () => {
setLoadingEmail(false);
toast.success("Login realizado com sucesso!");
router.replace("/dashboard");
},
onError: (ctx) => {
if (
ctx.error.status === 500 &&
ctx.error.statusText === "Internal Server Error"
) {
toast.error(
"Ocorreu uma falha na requisição. Tente novamente mais tarde.",
);
}
setError(ctx.error.message);
setLoadingEmail(false);
},
}
);
}
setError(ctx.error.message);
setLoadingEmail(false);
},
},
);
}
async function handleGoogle() {
if (!isGoogleAvailable) {
setError("Login com Google não está disponível no momento.");
return;
}
async function handleGoogle() {
if (!isGoogleAvailable) {
setError("Login com Google não está disponível no momento.");
return;
}
// Ativa loading antes de iniciar o fluxo OAuth
setError("");
setLoadingGoogle(true);
// Ativa loading antes de iniciar o fluxo OAuth
setError("");
setLoadingGoogle(true);
// OAuth redirect - o loading permanece até a página ser redirecionada
await authClient.signIn.social(
{
provider: "google",
callbackURL: "/dashboard",
},
{
onError: (ctx) => {
// Só desativa loading se houver erro
setError(ctx.error.message);
setLoadingGoogle(false);
},
}
);
}
// OAuth redirect - o loading permanece até a página ser redirecionada
await authClient.signIn.social(
{
provider: "google",
callbackURL: "/dashboard",
},
{
onError: (ctx) => {
// Só desativa loading se houver erro
setError(ctx.error.message);
setLoadingGoogle(false);
},
},
);
}
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Logo className="mb-2" />
<Card className="overflow-hidden p-0">
<CardContent className="grid gap-0 p-0 md:grid-cols-[1.05fr_0.95fr]">
<form
className="flex flex-col gap-6 p-6 md:p-8"
onSubmit={handleSubmit}
noValidate
>
<FieldGroup className="gap-4">
<AuthHeader title="Entrar no Opensheets" />
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Logo className="mb-2" />
<Card className="overflow-hidden p-0">
<CardContent className="grid gap-0 p-0 md:grid-cols-[1.05fr_0.95fr]">
<form
className="flex flex-col gap-6 p-6 md:p-8"
onSubmit={handleSubmit}
noValidate
>
<FieldGroup className="gap-4">
<AuthHeader title="Entrar no Opensheets" />
<AuthErrorAlert error={error} />
<AuthErrorAlert error={error} />
<Field>
<FieldLabel htmlFor="email">E-mail</FieldLabel>
<Input
id="email"
type="email"
placeholder="Digite seu e-mail"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!!error}
/>
</Field>
<Field>
<FieldLabel htmlFor="email">E-mail</FieldLabel>
<Input
id="email"
type="email"
placeholder="Digite seu e-mail"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!!error}
/>
</Field>
<Field>
<div className="flex items-center">
<FieldLabel htmlFor="password">Senha</FieldLabel>
</div>
<Input
id="password"
type="password"
required
placeholder="Digite sua senha"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
aria-invalid={!!error}
/>
</Field>
<Field>
<div className="flex items-center">
<FieldLabel htmlFor="password">Senha</FieldLabel>
</div>
<Input
id="password"
type="password"
required
placeholder="Digite sua senha"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
aria-invalid={!!error}
/>
</Field>
<Field>
<Button
type="submit"
disabled={loadingEmail || loadingGoogle}
className="w-full"
>
{loadingEmail ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
"Entrar"
)}
</Button>
</Field>
<Field>
<Button
type="submit"
disabled={loadingEmail || loadingGoogle}
className="w-full"
>
{loadingEmail ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
"Entrar"
)}
</Button>
</Field>
<FieldSeparator className="my-2 *:data-[slot=field-separator-content]:bg-card">
Ou continue com
</FieldSeparator>
<FieldSeparator className="my-2 *:data-[slot=field-separator-content]:bg-card">
Ou continue com
</FieldSeparator>
<Field>
<GoogleAuthButton
onClick={handleGoogle}
loading={loadingGoogle}
disabled={loadingEmail || loadingGoogle || !isGoogleAvailable}
text="Entrar com Google"
/>
</Field>
<Field>
<GoogleAuthButton
onClick={handleGoogle}
loading={loadingGoogle}
disabled={loadingEmail || loadingGoogle || !isGoogleAvailable}
text="Entrar com Google"
/>
</Field>
<FieldDescription className="text-center">
Não tem uma conta?{" "}
<a href="/signup" className="underline underline-offset-4">
Inscreva-se
</a>
</FieldDescription>
</FieldGroup>
</form>
<FieldDescription className="text-center">
Não tem uma conta?{" "}
<a href="/signup" className="underline underline-offset-4">
Inscreva-se
</a>
</FieldDescription>
</FieldGroup>
</form>
<AuthSidebar />
</CardContent>
</Card>
<AuthSidebar />
</CardContent>
</Card>
{/* <AuthFooter /> */}
<FieldDescription className="text-center">
<a href="/" className="underline underline-offset-4">
Voltar para o site
</a>
</FieldDescription>
</div>
);
{/* <AuthFooter /> */}
<FieldDescription className="text-center">
<a href="/" className="underline underline-offset-4">
Voltar para o site
</a>
</FieldDescription>
</div>
);
}

View File

@@ -1,56 +1,55 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { authClient } from "@/lib/auth/client";
import { useRouter } from "next/navigation";
import { Spinner } from "../ui/spinner";
export default function LogoutButton() {
const router = useRouter();
const router = useRouter();
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(false);
async function handleLogOut() {
await authClient.signOut({
fetchOptions: {
onSuccess: () => {
router.push("/login");
},
onRequest: (_ctx) => {
setLoading(true);
},
onResponse: (_ctx) => {
setLoading(false);
},
},
});
}
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="link"
size="sm"
aria-busy={loading}
data-loading={loading}
onClick={handleLogOut}
disabled={loading}
className="text-destructive transition-all duration-200 border hover:text-destructive focus-visible:ring-destructive/30 data-[loading=true]:opacity-90"
>
{loading && <Spinner className="size-3.5 text-destructive" />}
<span aria-live="polite">{loading ? "Saindo" : "Sair"}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8}>
Encerrar sessão
</TooltipContent>
</Tooltip>
);
async function handleLogOut() {
await authClient.signOut({
fetchOptions: {
onSuccess: () => {
router.push("/login");
},
onRequest: (_ctx) => {
setLoading(true);
},
onResponse: (_ctx) => {
setLoading(false);
},
},
});
}
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="link"
size="sm"
aria-busy={loading}
data-loading={loading}
onClick={handleLogOut}
disabled={loading}
className="text-destructive transition-all duration-200 border hover:text-destructive focus-visible:ring-destructive/30 data-[loading=true]:opacity-90"
>
{loading && <Spinner className="size-3.5 text-destructive" />}
<span aria-live="polite">{loading ? "Saindo" : "Sair"}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8}>
Encerrar sessão
</TooltipContent>
</Tooltip>
);
}

View File

@@ -1,295 +1,297 @@
"use client";
import { RiCheckLine, RiCloseLine, RiLoader4Line } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { type FormEvent, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { authClient, googleSignInAvailable } from "@/lib/auth/client";
import { cn } from "@/lib/utils/ui";
import { RiLoader4Line } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { useState, useMemo, type FormEvent } from "react";
import { toast } from "sonner";
import { Logo } from "../logo";
import { AuthErrorAlert } from "./auth-error-alert";
import { AuthHeader } from "./auth-header";
import AuthSidebar from "./auth-sidebar";
import { GoogleAuthButton } from "./google-auth-button";
import { RiCheckLine, RiCloseLine } from "@remixicon/react";
interface PasswordValidation {
hasLowercase: boolean;
hasUppercase: boolean;
hasNumber: boolean;
hasSpecial: boolean;
hasMinLength: boolean;
hasMaxLength: boolean;
isValid: boolean;
hasLowercase: boolean;
hasUppercase: boolean;
hasNumber: boolean;
hasSpecial: boolean;
hasMinLength: boolean;
hasMaxLength: boolean;
isValid: boolean;
}
function validatePassword(password: string): PasswordValidation {
const hasLowercase = /[a-z]/.test(password);
const hasUppercase = /[A-Z]/.test(password);
const hasNumber = /\d/.test(password);
const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?`~]/.test(password);
const hasMinLength = password.length >= 7;
const hasMaxLength = password.length <= 23;
const hasLowercase = /[a-z]/.test(password);
const hasUppercase = /[A-Z]/.test(password);
const hasNumber = /\d/.test(password);
const hasSpecial = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?`~]/.test(password);
const hasMinLength = password.length >= 7;
const hasMaxLength = password.length <= 23;
return {
hasLowercase,
hasUppercase,
hasNumber,
hasSpecial,
hasMinLength,
hasMaxLength,
isValid:
hasLowercase &&
hasUppercase &&
hasNumber &&
hasSpecial &&
hasMinLength &&
hasMaxLength,
};
return {
hasLowercase,
hasUppercase,
hasNumber,
hasSpecial,
hasMinLength,
hasMaxLength,
isValid:
hasLowercase &&
hasUppercase &&
hasNumber &&
hasSpecial &&
hasMinLength &&
hasMaxLength,
};
}
function PasswordRequirement({
met,
label,
}: {
met: boolean;
label: string;
}) {
return (
<div
className={cn(
"flex items-center gap-1.5 text-xs transition-colors",
met ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground"
)}
>
{met ? (
<RiCheckLine className="h-3.5 w-3.5" />
) : (
<RiCloseLine className="h-3.5 w-3.5" />
)}
<span>{label}</span>
</div>
);
function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
return (
<div
className={cn(
"flex items-center gap-1.5 text-xs transition-colors",
met
? "text-emerald-600 dark:text-emerald-400"
: "text-muted-foreground",
)}
>
{met ? (
<RiCheckLine className="h-3.5 w-3.5" />
) : (
<RiCloseLine className="h-3.5 w-3.5" />
)}
<span>{label}</span>
</div>
);
}
type DivProps = React.ComponentProps<"div">;
export function SignupForm({ className, ...props }: DivProps) {
const router = useRouter();
const isGoogleAvailable = googleSignInAvailable;
const router = useRouter();
const isGoogleAvailable = googleSignInAvailable;
const [fullname, setFullname] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [fullname, setFullname] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loadingEmail, setLoadingEmail] = useState(false);
const [loadingGoogle, setLoadingGoogle] = useState(false);
const [error, setError] = useState("");
const [loadingEmail, setLoadingEmail] = useState(false);
const [loadingGoogle, setLoadingGoogle] = useState(false);
const passwordValidation = useMemo(
() => validatePassword(password),
[password]
);
const passwordValidation = useMemo(
() => validatePassword(password),
[password],
);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!passwordValidation.isValid) {
setError("A senha não atende aos requisitos de segurança.");
return;
}
if (!passwordValidation.isValid) {
setError("A senha não atende aos requisitos de segurança.");
return;
}
await authClient.signUp.email(
{
email,
password,
name: fullname,
},
{
onRequest: () => {
setError("");
setLoadingEmail(true);
},
onSuccess: () => {
setLoadingEmail(false);
toast.success("Conta criada com sucesso!");
router.replace("/dashboard");
},
onError: (ctx) => {
setError(ctx.error.message);
setLoadingEmail(false);
},
}
);
}
await authClient.signUp.email(
{
email,
password,
name: fullname,
},
{
onRequest: () => {
setError("");
setLoadingEmail(true);
},
onSuccess: () => {
setLoadingEmail(false);
toast.success("Conta criada com sucesso!");
router.replace("/dashboard");
},
onError: (ctx) => {
setError(ctx.error.message);
setLoadingEmail(false);
},
},
);
}
async function handleGoogle() {
if (!isGoogleAvailable) {
setError("Login com Google não está disponível no momento.");
return;
}
async function handleGoogle() {
if (!isGoogleAvailable) {
setError("Login com Google não está disponível no momento.");
return;
}
// Ativa loading antes de iniciar o fluxo OAuth
setError("");
setLoadingGoogle(true);
// Ativa loading antes de iniciar o fluxo OAuth
setError("");
setLoadingGoogle(true);
// OAuth redirect - o loading permanece até a página ser redirecionada
await authClient.signIn.social(
{
provider: "google",
callbackURL: "/dashboard",
},
{
onError: (ctx) => {
// Só desativa loading se houver erro
setError(ctx.error.message);
setLoadingGoogle(false);
},
}
);
}
// OAuth redirect - o loading permanece até a página ser redirecionada
await authClient.signIn.social(
{
provider: "google",
callbackURL: "/dashboard",
},
{
onError: (ctx) => {
// Só desativa loading se houver erro
setError(ctx.error.message);
setLoadingGoogle(false);
},
},
);
}
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Logo className="mb-2" />
<Card className="overflow-hidden p-0">
<CardContent className="grid gap-0 p-0 md:grid-cols-[1.05fr_0.95fr]">
<form
className="flex flex-col gap-6 p-6 md:p-8"
onSubmit={handleSubmit}
noValidate
>
<FieldGroup className="gap-4">
<AuthHeader title="Criar sua conta" />
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Logo className="mb-2" />
<Card className="overflow-hidden p-0">
<CardContent className="grid gap-0 p-0 md:grid-cols-[1.05fr_0.95fr]">
<form
className="flex flex-col gap-6 p-6 md:p-8"
onSubmit={handleSubmit}
noValidate
>
<FieldGroup className="gap-4">
<AuthHeader title="Criar sua conta" />
<AuthErrorAlert error={error} />
<AuthErrorAlert error={error} />
<Field>
<FieldLabel htmlFor="name">Nome completo</FieldLabel>
<Input
id="name"
type="text"
placeholder="Digite seu nome"
autoComplete="name"
required
value={fullname}
onChange={(e) => setFullname(e.target.value)}
aria-invalid={!!error}
/>
</Field>
<Field>
<FieldLabel htmlFor="name">Nome completo</FieldLabel>
<Input
id="name"
type="text"
placeholder="Digite seu nome"
autoComplete="name"
required
value={fullname}
onChange={(e) => setFullname(e.target.value)}
aria-invalid={!!error}
/>
</Field>
<Field>
<FieldLabel htmlFor="email">E-mail</FieldLabel>
<Input
id="email"
type="email"
placeholder="Digite seu e-mail"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!!error}
/>
</Field>
<Field>
<FieldLabel htmlFor="email">E-mail</FieldLabel>
<Input
id="email"
type="email"
placeholder="Digite seu e-mail"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!!error}
/>
</Field>
<Field>
<FieldLabel htmlFor="password">Senha</FieldLabel>
<Input
id="password"
type="password"
required
autoComplete="new-password"
placeholder="Crie uma senha forte"
value={password}
onChange={(e) => setPassword(e.target.value)}
aria-invalid={!!error || (password.length > 0 && !passwordValidation.isValid)}
maxLength={23}
/>
{password.length > 0 && (
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
<PasswordRequirement
met={passwordValidation.hasMinLength}
label="Mínimo 7 caracteres"
/>
<PasswordRequirement
met={passwordValidation.hasMaxLength}
label="Máximo 23 caracteres"
/>
<PasswordRequirement
met={passwordValidation.hasLowercase}
label="Letra minúscula"
/>
<PasswordRequirement
met={passwordValidation.hasUppercase}
label="Letra maiúscula"
/>
<PasswordRequirement
met={passwordValidation.hasNumber}
label="Número"
/>
<PasswordRequirement
met={passwordValidation.hasSpecial}
label="Caractere especial"
/>
</div>
)}
</Field>
<Field>
<FieldLabel htmlFor="password">Senha</FieldLabel>
<Input
id="password"
type="password"
required
autoComplete="new-password"
placeholder="Crie uma senha forte"
value={password}
onChange={(e) => setPassword(e.target.value)}
aria-invalid={
!!error ||
(password.length > 0 && !passwordValidation.isValid)
}
maxLength={23}
/>
{password.length > 0 && (
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
<PasswordRequirement
met={passwordValidation.hasMinLength}
label="Mínimo 7 caracteres"
/>
<PasswordRequirement
met={passwordValidation.hasMaxLength}
label="Máximo 23 caracteres"
/>
<PasswordRequirement
met={passwordValidation.hasLowercase}
label="Letra minúscula"
/>
<PasswordRequirement
met={passwordValidation.hasUppercase}
label="Letra maiúscula"
/>
<PasswordRequirement
met={passwordValidation.hasNumber}
label="Número"
/>
<PasswordRequirement
met={passwordValidation.hasSpecial}
label="Caractere especial"
/>
</div>
)}
</Field>
<Field>
<Button
type="submit"
disabled={loadingEmail || loadingGoogle || (password.length > 0 && !passwordValidation.isValid)}
className="w-full"
>
{loadingEmail ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
"Criar conta"
)}
</Button>
</Field>
<Field>
<Button
type="submit"
disabled={
loadingEmail ||
loadingGoogle ||
(password.length > 0 && !passwordValidation.isValid)
}
className="w-full"
>
{loadingEmail ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
"Criar conta"
)}
</Button>
</Field>
<FieldSeparator className="my-2 *:data-[slot=field-separator-content]:bg-card">
Ou continue com
</FieldSeparator>
<FieldSeparator className="my-2 *:data-[slot=field-separator-content]:bg-card">
Ou continue com
</FieldSeparator>
<Field>
<GoogleAuthButton
onClick={handleGoogle}
loading={loadingGoogle}
disabled={loadingEmail || loadingGoogle || !isGoogleAvailable}
text="Continuar com Google"
/>
</Field>
<Field>
<GoogleAuthButton
onClick={handleGoogle}
loading={loadingGoogle}
disabled={loadingEmail || loadingGoogle || !isGoogleAvailable}
text="Continuar com Google"
/>
</Field>
<FieldDescription className="text-center">
tem uma conta?{" "}
<a href="/login" className="underline underline-offset-4">
Entrar
</a>
</FieldDescription>
</FieldGroup>
</form>
<FieldDescription className="text-center">
tem uma conta?{" "}
<a href="/login" className="underline underline-offset-4">
Entrar
</a>
</FieldDescription>
</FieldGroup>
</form>
<AuthSidebar />
</CardContent>
</Card>
<AuthSidebar />
</CardContent>
</Card>
{/* <AuthFooter /> */}
<FieldDescription className="text-center">
<a href="/" className="underline underline-offset-4">
Voltar para o site
</a>
</FieldDescription>
</div>
);
{/* <AuthFooter /> */}
<FieldDescription className="text-center">
<a href="/" className="underline underline-offset-4">
Voltar para o site
</a>
</FieldDescription>
</div>
);
}

View File

@@ -1,109 +1,109 @@
"use client";
import { RiCalculatorFill, RiCalculatorLine } from "@remixicon/react";
import * as React from "react";
import Calculator from "@/components/calculadora/calculator";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils/ui";
import { RiCalculatorFill, RiCalculatorLine } from "@remixicon/react";
import * as React from "react";
type Variant = React.ComponentProps<typeof Button>["variant"];
type Size = React.ComponentProps<typeof Button>["size"];
type CalculatorDialogButtonProps = {
variant?: Variant;
size?: Size;
className?: string;
children?: React.ReactNode;
withTooltip?: boolean;
variant?: Variant;
size?: Size;
className?: string;
children?: React.ReactNode;
withTooltip?: boolean;
};
export function CalculatorDialogButton({
variant = "ghost",
size = "sm",
className,
children,
withTooltip = false,
variant = "ghost",
size = "sm",
className,
children,
withTooltip = false,
}: CalculatorDialogButtonProps) {
const [open, setOpen] = React.useState(false);
const [open, setOpen] = React.useState(false);
// Se withTooltip for true, usa o estilo do header
if (withTooltip) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<button
type="button"
aria-label="Calculadora"
aria-expanded={open}
data-state={open ? "open" : "closed"}
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"group relative text-muted-foreground transition-all duration-200",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground border",
className
)}
>
<RiCalculatorLine
className={cn(
"size-4 transition-transform duration-200",
open ? "scale-90" : "scale-100"
)}
/>
<span className="sr-only">Calculadora</span>
</button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8}>
Calculadora
</TooltipContent>
</Tooltip>
<DialogContent className="p-4 sm:max-w-sm">
<DialogHeader className="space-y-2">
<DialogTitle className="flex items-center gap-2 text-lg">
<RiCalculatorLine className="h-5 w-5" />
Calculadora
</DialogTitle>
</DialogHeader>
<Calculator />
</DialogContent>
</Dialog>
);
}
// Se withTooltip for true, usa o estilo do header
if (withTooltip) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<button
type="button"
aria-label="Calculadora"
aria-expanded={open}
data-state={open ? "open" : "closed"}
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"group relative text-muted-foreground transition-all duration-200",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground border",
className,
)}
>
<RiCalculatorLine
className={cn(
"size-4 transition-transform duration-200",
open ? "scale-90" : "scale-100",
)}
/>
<span className="sr-only">Calculadora</span>
</button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8}>
Calculadora
</TooltipContent>
</Tooltip>
<DialogContent className="p-4 sm:max-w-sm">
<DialogHeader className="space-y-2">
<DialogTitle className="flex items-center gap-2 text-lg">
<RiCalculatorLine className="h-5 w-5" />
Calculadora
</DialogTitle>
</DialogHeader>
<Calculator />
</DialogContent>
</Dialog>
);
}
// Estilo padrão para outros usos
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant={variant} size={size} className={cn(className)}>
{children ?? (
<RiCalculatorFill className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</DialogTrigger>
<DialogContent className="p-4 sm:max-w-sm">
<DialogHeader className="space-y-2">
<DialogTitle className="flex items-center gap-2 text-lg">
<RiCalculatorLine className="h-5 w-5" />
Calculadora
</DialogTitle>
</DialogHeader>
<Calculator />
</DialogContent>
</Dialog>
);
// Estilo padrão para outros usos
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant={variant} size={size} className={cn(className)}>
{children ?? (
<RiCalculatorFill className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</DialogTrigger>
<DialogContent className="p-4 sm:max-w-sm">
<DialogHeader className="space-y-2">
<DialogTitle className="flex items-center gap-2 text-lg">
<RiCalculatorLine className="h-5 w-5" />
Calculadora
</DialogTitle>
</DialogHeader>
<Calculator />
</DialogContent>
</Dialog>
);
}

View File

@@ -1,49 +1,49 @@
import { Button } from "@/components/ui/button";
import { RiCheckLine, RiFileCopyLine } from "@remixicon/react";
import { Button } from "@/components/ui/button";
export type CalculatorDisplayProps = {
history: string | null;
expression: string;
resultText: string | null;
copied: boolean;
onCopy: () => void;
history: string | null;
expression: string;
resultText: string | null;
copied: boolean;
onCopy: () => void;
};
export function CalculatorDisplay({
history,
expression,
resultText,
copied,
onCopy,
history,
expression,
resultText,
copied,
onCopy,
}: CalculatorDisplayProps) {
return (
<div className="rounded-xl border bg-muted px-4 py-5 text-right">
{history && (
<div className="text-sm text-muted-foreground">{history}</div>
)}
<div className="flex items-center justify-end gap-2">
<div className="text-right text-3xl font-semibold tracking-tight tabular-nums">
{expression}
</div>
{resultText && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={onCopy}
className="h-6 w-6 shrink-0 rounded-full p-0 text-muted-foreground hover:text-foreground"
>
{copied ? (
<RiCheckLine className="h-4 w-4" />
) : (
<RiFileCopyLine className="h-4 w-4" />
)}
<span className="sr-only">
{copied ? "Resultado copiado" : "Copiar resultado"}
</span>
</Button>
)}
</div>
</div>
);
return (
<div className="rounded-xl border bg-muted px-4 py-5 text-right">
{history && (
<div className="text-sm text-muted-foreground">{history}</div>
)}
<div className="flex items-center justify-end gap-2">
<div className="text-right text-3xl font-semibold tracking-tight tabular-nums">
{expression}
</div>
{resultText && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={onCopy}
className="h-6 w-6 shrink-0 rounded-full p-0 text-muted-foreground hover:text-foreground"
>
{copied ? (
<RiCheckLine className="h-4 w-4" />
) : (
<RiFileCopyLine className="h-4 w-4" />
)}
<span className="sr-only">
{copied ? "Resultado copiado" : "Copiar resultado"}
</span>
</Button>
)}
</div>
</div>
);
}

View File

@@ -1,29 +1,29 @@
import { Button } from "@/components/ui/button";
import type { CalculatorButtonConfig } from "@/hooks/use-calculator-state";
import { cn } from "@/lib/utils/ui";
import { type CalculatorButtonConfig } from "@/hooks/use-calculator-state";
type CalculatorKeypadProps = {
buttons: CalculatorButtonConfig[][];
buttons: CalculatorButtonConfig[][];
};
export function CalculatorKeypad({ buttons }: CalculatorKeypadProps) {
return (
<div className="grid grid-cols-4 gap-2">
{buttons.flat().map((btn, index) => (
<Button
key={`${btn.label}-${index}`}
type="button"
variant={btn.variant ?? "outline"}
onClick={btn.onClick}
className={cn(
"h-12 text-base font-semibold",
btn.colSpan === 2 && "col-span-2",
btn.colSpan === 3 && "col-span-3",
)}
>
{btn.label}
</Button>
))}
</div>
);
return (
<div className="grid grid-cols-4 gap-2">
{buttons.flat().map((btn, index) => (
<Button
key={`${btn.label}-${index}`}
type="button"
variant={btn.variant ?? "outline"}
onClick={btn.onClick}
className={cn(
"h-12 text-base font-semibold",
btn.colSpan === 2 && "col-span-2",
btn.colSpan === 3 && "col-span-3",
)}
>
{btn.label}
</Button>
))}
</div>
);
}

View File

@@ -6,32 +6,32 @@ import { useCalculatorState } from "@/hooks/use-calculator-state";
import { CalculatorDisplay } from "./calculator-display";
export default function Calculator() {
const {
expression,
history,
resultText,
copied,
buttons,
copyToClipboard,
pasteFromClipboard,
} = useCalculatorState();
const {
expression,
history,
resultText,
copied,
buttons,
copyToClipboard,
pasteFromClipboard,
} = useCalculatorState();
useCalculatorKeyboard({
canCopy: Boolean(resultText),
onCopy: copyToClipboard,
onPaste: pasteFromClipboard,
});
useCalculatorKeyboard({
canCopy: Boolean(resultText),
onCopy: copyToClipboard,
onPaste: pasteFromClipboard,
});
return (
<div className="space-y-4">
<CalculatorDisplay
history={history}
expression={expression}
resultText={resultText}
copied={copied}
onCopy={copyToClipboard}
/>
<CalculatorKeypad buttons={buttons} />
</div>
);
return (
<div className="space-y-4">
<CalculatorDisplay
history={history}
expression={expression}
resultText={resultText}
copied={copied}
onCopy={copyToClipboard}
/>
<CalculatorKeypad buttons={buttons} />
</div>
);
}

View File

@@ -1,42 +1,42 @@
"use client";
import { cn } from "@/lib/utils/ui";
import { DayCell } from "@/components/calendario/day-cell";
import type { CalendarDay } from "@/components/calendario/types";
import { WEEK_DAYS_SHORT } from "@/components/calendario/utils";
import { DayCell } from "@/components/calendario/day-cell";
import { cn } from "@/lib/utils/ui";
type CalendarGridProps = {
days: CalendarDay[];
onSelectDay: (day: CalendarDay) => void;
onCreateDay: (day: CalendarDay) => void;
days: CalendarDay[];
onSelectDay: (day: CalendarDay) => void;
onCreateDay: (day: CalendarDay) => void;
};
export function CalendarGrid({
days,
onSelectDay,
onCreateDay,
days,
onSelectDay,
onCreateDay,
}: CalendarGridProps) {
return (
<div className="overflow-hidden rounded-lg bg-card drop-shadow-xs px-2">
<div className="grid grid-cols-7 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{WEEK_DAYS_SHORT.map((dayName) => (
<span key={dayName} className="px-3 py-2 text-center">
{dayName}
</span>
))}
</div>
return (
<div className="overflow-hidden rounded-lg bg-card drop-shadow-xs px-2">
<div className="grid grid-cols-7 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{WEEK_DAYS_SHORT.map((dayName) => (
<span key={dayName} className="px-3 py-2 text-center">
{dayName}
</span>
))}
</div>
<div className="grid grid-cols-7 gap-px bg-border/60 px-px pb-px pt-px">
{days.map((day) => (
<div
key={day.date}
className={cn("h-[150px] bg-card p-0.5", !day.isCurrentMonth && "")}
>
<DayCell day={day} onSelect={onSelectDay} onCreate={onCreateDay} />
</div>
))}
</div>
</div>
);
<div className="grid grid-cols-7 gap-px bg-border/60 px-px pb-px pt-px">
{days.map((day) => (
<div
key={day.date}
className={cn("h-[150px] bg-card p-0.5", !day.isCurrentMonth && "")}
>
<DayCell day={day} onSelect={onSelectDay} onCreate={onCreateDay} />
</div>
))}
</div>
</div>
);
}

View File

@@ -5,29 +5,29 @@ import type { CalendarEvent } from "@/components/calendario/types";
import { cn } from "@/lib/utils/ui";
const LEGEND_ITEMS: Array<{
type?: CalendarEvent["type"];
label: string;
dotColor?: string;
type?: CalendarEvent["type"];
label: string;
dotColor?: string;
}> = [
{ type: "lancamento", label: "Lançamentos" },
{ type: "boleto", label: "Boleto com vencimento" },
{ type: "cartao", label: "Vencimento de cartão" },
{ label: "Pagamento fatura", dotColor: "bg-green-600" },
{ type: "lancamento", label: "Lançamentos" },
{ type: "boleto", label: "Boleto com vencimento" },
{ type: "cartao", label: "Vencimento de cartão" },
{ label: "Pagamento fatura", dotColor: "bg-green-600" },
];
export function CalendarLegend() {
return (
<div className="flex flex-wrap gap-3 rounded-sm border border-border/60 bg-muted/20 p-2 text-xs font-medium text-muted-foreground">
{LEGEND_ITEMS.map((item, index) => {
const dotColor =
item.dotColor || (item.type ? EVENT_TYPE_STYLES[item.type].dot : "");
return (
<span key={item.type || index} className="flex items-center gap-2">
<span className={cn("size-3 rounded-full", dotColor)} />
{item.label}
</span>
);
})}
</div>
);
return (
<div className="flex flex-wrap gap-3 rounded-sm border border-border/60 bg-muted/20 p-2 text-xs font-medium text-muted-foreground">
{LEGEND_ITEMS.map((item, index) => {
const dotColor =
item.dotColor || (item.type ? EVENT_TYPE_STYLES[item.type].dot : "");
return (
<span key={item.type || index} className="flex items-center gap-2">
<span className={cn("size-3 rounded-full", dotColor)} />
{item.label}
</span>
);
})}
</div>
);
}

View File

@@ -1,185 +1,185 @@
"use client";
import { RiAddLine } from "@remixicon/react";
import type { KeyboardEvent, MouseEvent } from "react";
import type { CalendarDay, CalendarEvent } from "@/components/calendario/types";
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
import { cn } from "@/lib/utils/ui";
import { RiAddLine } from "@remixicon/react";
import type { KeyboardEvent, MouseEvent } from "react";
type DayCellProps = {
day: CalendarDay;
onSelect: (day: CalendarDay) => void;
onCreate: (day: CalendarDay) => void;
day: CalendarDay;
onSelect: (day: CalendarDay) => void;
onCreate: (day: CalendarDay) => void;
};
export const EVENT_TYPE_STYLES: Record<
CalendarEvent["type"],
{ wrapper: string; dot: string; accent?: string }
CalendarEvent["type"],
{ wrapper: string; dot: string; accent?: string }
> = {
lancamento: {
wrapper:
"bg-orange-100 text-orange-600 dark:bg-orange-900/10 dark:text-orange-50 border-l-4 border-orange-500",
dot: "bg-orange-600",
},
boleto: {
wrapper:
"bg-blue-100 text-blue-600 dark:bg-blue-900/10 dark:text-blue-50 border-l-4 border-blue-500",
dot: "bg-blue-600",
},
cartao: {
wrapper:
"bg-violet-100 text-violet-600 dark:bg-violet-900/10 dark:text-violet-50 border-l-4 border-violet-500",
dot: "bg-violet-600",
},
lancamento: {
wrapper:
"bg-orange-100 text-orange-600 dark:bg-orange-900/10 dark:text-orange-50 border-l-4 border-orange-500",
dot: "bg-orange-600",
},
boleto: {
wrapper:
"bg-blue-100 text-blue-600 dark:bg-blue-900/10 dark:text-blue-50 border-l-4 border-blue-500",
dot: "bg-blue-600",
},
cartao: {
wrapper:
"bg-violet-100 text-violet-600 dark:bg-violet-900/10 dark:text-violet-50 border-l-4 border-violet-500",
dot: "bg-violet-600",
},
};
const eventStyles = EVENT_TYPE_STYLES;
const formatCurrencyValue = (value: number | null | undefined) =>
currencyFormatter.format(Math.abs(value ?? 0));
currencyFormatter.format(Math.abs(value ?? 0));
const formatAmount = (event: Extract<CalendarEvent, { type: "lancamento" }>) =>
formatCurrencyValue(event.lancamento.amount);
formatCurrencyValue(event.lancamento.amount);
const buildEventLabel = (event: CalendarEvent) => {
switch (event.type) {
case "lancamento": {
return event.lancamento.name;
}
case "boleto": {
return event.lancamento.name;
}
case "cartao": {
return event.card.name;
}
default:
return "";
}
switch (event.type) {
case "lancamento": {
return event.lancamento.name;
}
case "boleto": {
return event.lancamento.name;
}
case "cartao": {
return event.card.name;
}
default:
return "";
}
};
const buildEventComplement = (event: CalendarEvent) => {
switch (event.type) {
case "lancamento": {
return formatAmount(event);
}
case "boleto": {
return formatCurrencyValue(event.lancamento.amount);
}
case "cartao": {
if (event.card.totalDue !== null) {
return formatCurrencyValue(event.card.totalDue);
}
return null;
}
default:
return null;
}
switch (event.type) {
case "lancamento": {
return formatAmount(event);
}
case "boleto": {
return formatCurrencyValue(event.lancamento.amount);
}
case "cartao": {
if (event.card.totalDue !== null) {
return formatCurrencyValue(event.card.totalDue);
}
return null;
}
default:
return null;
}
};
const isPagamentoFatura = (event: CalendarEvent) => {
return (
event.type === "lancamento" &&
event.lancamento.name.startsWith("Pagamento fatura -")
);
return (
event.type === "lancamento" &&
event.lancamento.name.startsWith("Pagamento fatura -")
);
};
const getEventStyle = (event: CalendarEvent) => {
if (isPagamentoFatura(event)) {
return {
wrapper:
"bg-green-100 text-green-600 dark:bg-green-900/10 dark:text-green-50 border-l-4 border-green-500",
dot: "bg-green-600",
};
}
return eventStyles[event.type];
if (isPagamentoFatura(event)) {
return {
wrapper:
"bg-green-100 text-green-600 dark:bg-green-900/10 dark:text-green-50 border-l-4 border-green-500",
dot: "bg-green-600",
};
}
return eventStyles[event.type];
};
const DayEventPreview = ({ event }: { event: CalendarEvent }) => {
const complement = buildEventComplement(event);
const label = buildEventLabel(event);
const style = getEventStyle(event);
const complement = buildEventComplement(event);
const label = buildEventLabel(event);
const style = getEventStyle(event);
return (
<div
className={cn(
"flex w-full items-center justify-between gap-2 rounded p-1 text-xs",
style.wrapper
)}
>
<div className="flex min-w-0 items-center gap-1">
<span className="truncate">{label}</span>
</div>
{complement ? (
<span
className={cn("shrink-0 font-semibold", style.accent ?? "text-xs")}
>
{complement}
</span>
) : null}
</div>
);
return (
<div
className={cn(
"flex w-full items-center justify-between gap-2 rounded p-1 text-xs",
style.wrapper,
)}
>
<div className="flex min-w-0 items-center gap-1">
<span className="truncate">{label}</span>
</div>
{complement ? (
<span
className={cn("shrink-0 font-semibold", style.accent ?? "text-xs")}
>
{complement}
</span>
) : null}
</div>
);
};
export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
const previewEvents = day.events.slice(0, 3);
const hasOverflow = day.events.length > 3;
const previewEvents = day.events.slice(0, 3);
const hasOverflow = day.events.length > 3;
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" || event.key === " " || event.key === "Space") {
event.preventDefault();
onSelect(day);
}
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" || event.key === " " || event.key === "Space") {
event.preventDefault();
onSelect(day);
}
};
const handleCreateClick = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onCreate(day);
};
const handleCreateClick = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onCreate(day);
};
return (
<div
role="button"
tabIndex={0}
onClick={() => onSelect(day)}
onKeyDown={handleKeyDown}
className={cn(
"flex h-full cursor-pointer flex-col gap-1.5 rounded-lg border border-transparent bg-card/80 p-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:border-primary/40 hover:bg-primary/5 dark:hover:bg-primary/10",
!day.isCurrentMonth && "opacity-60",
day.isToday && "border-primary/70 bg-primary/5 hover:border-primary"
)}
>
<div className="flex items-start justify-between gap-2">
<span
className={cn(
"text-sm font-semibold leading-none",
day.isToday
? "text-orange-100 bg-primary size-5 rounded-full flex items-center justify-center"
: "text-foreground/90"
)}
>
{day.label}
</span>
<button
type="button"
onClick={handleCreateClick}
className="flex size-6 items-center justify-center rounded-full border bg-muted text-muted-foreground transition-colors hover:bg-primary/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
aria-label={`Criar lançamento em ${day.date}`}
>
<RiAddLine className="size-3.5" />
</button>
</div>
return (
<div
role="button"
tabIndex={0}
onClick={() => onSelect(day)}
onKeyDown={handleKeyDown}
className={cn(
"flex h-full cursor-pointer flex-col gap-1.5 rounded-lg border border-transparent bg-card/80 p-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:border-primary/40 hover:bg-primary/5 dark:hover:bg-primary/10",
!day.isCurrentMonth && "opacity-60",
day.isToday && "border-primary/70 bg-primary/5 hover:border-primary",
)}
>
<div className="flex items-start justify-between gap-2">
<span
className={cn(
"text-sm font-semibold leading-none",
day.isToday
? "text-orange-100 bg-primary size-5 rounded-full flex items-center justify-center"
: "text-foreground/90",
)}
>
{day.label}
</span>
<button
type="button"
onClick={handleCreateClick}
className="flex size-6 items-center justify-center rounded-full border bg-muted text-muted-foreground transition-colors hover:bg-primary/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
aria-label={`Criar lançamento em ${day.date}`}
>
<RiAddLine className="size-3.5" />
</button>
</div>
<div className="flex flex-1 flex-col gap-1.5">
{previewEvents.map((event) => (
<DayEventPreview key={event.id} event={event} />
))}
<div className="flex flex-1 flex-col gap-1.5">
{previewEvents.map((event) => (
<DayEventPreview key={event.id} event={event} />
))}
{hasOverflow ? (
<span className="text-xs font-medium text-primary/80">
+ ver mais
</span>
) : null}
</div>
</div>
);
{hasOverflow ? (
<span className="text-xs font-medium text-primary/80">
+ ver mais
</span>
) : null}
</div>
</div>
);
}

View File

@@ -1,208 +1,210 @@
"use client";
import type { ReactNode } from "react";
import { EVENT_TYPE_STYLES } from "@/components/calendario/day-cell";
import type { CalendarDay, CalendarEvent } from "@/components/calendario/types";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { friendlyDate, parseLocalDateString } from "@/lib/utils/date";
import { cn } from "@/lib/utils/ui";
import type { ReactNode } from "react";
import MoneyValues from "../money-values";
import { Badge } from "../ui/badge";
import { Card } from "../ui/card";
type EventModalProps = {
open: boolean;
day: CalendarDay | null;
onClose: () => void;
onCreate: (date: string) => void;
open: boolean;
day: CalendarDay | null;
onClose: () => void;
onCreate: (date: string) => void;
};
const EventCard = ({
children,
type,
isPagamentoFatura = false,
children,
type,
isPagamentoFatura = false,
}: {
children: ReactNode;
type: CalendarEvent["type"];
isPagamentoFatura?: boolean;
children: ReactNode;
type: CalendarEvent["type"];
isPagamentoFatura?: boolean;
}) => {
const style = isPagamentoFatura
? { dot: "bg-green-600" }
: EVENT_TYPE_STYLES[type];
return (
<Card className="flex flex-row gap-2 p-3 mb-1">
<span
className={cn("mt-1 size-3 shrink-0 rounded-full", style.dot)}
aria-hidden
/>
<div className="flex flex-1 flex-col">{children}</div>
</Card>
);
const style = isPagamentoFatura
? { dot: "bg-green-600" }
: EVENT_TYPE_STYLES[type];
return (
<Card className="flex flex-row gap-2 p-3 mb-1">
<span
className={cn("mt-1 size-3 shrink-0 rounded-full", style.dot)}
aria-hidden
/>
<div className="flex flex-1 flex-col">{children}</div>
</Card>
);
};
const renderLancamento = (
event: Extract<CalendarEvent, { type: "lancamento" }>
event: Extract<CalendarEvent, { type: "lancamento" }>,
) => {
const isReceita = event.lancamento.transactionType === "Receita";
const isPagamentoFatura =
event.lancamento.name.startsWith("Pagamento fatura -");
const isReceita = event.lancamento.transactionType === "Receita";
const isPagamentoFatura =
event.lancamento.name.startsWith("Pagamento fatura -");
return (
<EventCard type="lancamento" isPagamentoFatura={isPagamentoFatura}>
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<span
className={`text-sm font-semibold leading-tight ${
isPagamentoFatura && "text-green-600 dark:text-green-400"
}`}
>
{event.lancamento.name}
</span>
return (
<EventCard type="lancamento" isPagamentoFatura={isPagamentoFatura}>
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<span
className={`text-sm font-semibold leading-tight ${
isPagamentoFatura && "text-green-600 dark:text-green-400"
}`}
>
{event.lancamento.name}
</span>
<div className="flex gap-1">
<Badge variant={"outline"}>{event.lancamento.condition}</Badge>
<Badge variant={"outline"}>{event.lancamento.paymentMethod}</Badge>
<Badge variant={"outline"}>{event.lancamento.categoriaName}</Badge>
</div>
</div>
<span
className={cn(
"text-sm font-semibold whitespace-nowrap",
isReceita ? "text-green-600 dark:text-green-400" : "text-foreground"
)}
>
<MoneyValues
showPositiveSign
className="text-base"
amount={event.lancamento.amount}
/>
</span>
</div>
</EventCard>
);
<div className="flex gap-1">
<Badge variant={"outline"}>{event.lancamento.condition}</Badge>
<Badge variant={"outline"}>{event.lancamento.paymentMethod}</Badge>
<Badge variant={"outline"}>{event.lancamento.categoriaName}</Badge>
</div>
</div>
<span
className={cn(
"text-sm font-semibold whitespace-nowrap",
isReceita
? "text-green-600 dark:text-green-400"
: "text-foreground",
)}
>
<MoneyValues
showPositiveSign
className="text-base"
amount={event.lancamento.amount}
/>
</span>
</div>
</EventCard>
);
};
const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
const isPaid = Boolean(event.lancamento.isSettled);
const dueDate = event.lancamento.dueDate;
const formattedDueDate = dueDate
? new Intl.DateTimeFormat("pt-BR").format(new Date(dueDate))
: null;
const isPaid = Boolean(event.lancamento.isSettled);
const dueDate = event.lancamento.dueDate;
const formattedDueDate = dueDate
? new Intl.DateTimeFormat("pt-BR").format(new Date(dueDate))
: null;
return (
<EventCard type="boleto">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<div className="flex gap-1 items-center">
<span className="text-sm font-semibold leading-tight">
{event.lancamento.name}
</span>
return (
<EventCard type="boleto">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<div className="flex gap-1 items-center">
<span className="text-sm font-semibold leading-tight">
{event.lancamento.name}
</span>
{formattedDueDate && (
<span className="text-xs text-muted-foreground leading-tight">
Vence em {formattedDueDate}
</span>
)}
</div>
{formattedDueDate && (
<span className="text-xs text-muted-foreground leading-tight">
Vence em {formattedDueDate}
</span>
)}
</div>
<Badge variant={"outline"}>{isPaid ? "Pago" : "Pendente"}</Badge>
</div>
<span className="font-semibold">
<MoneyValues amount={event.lancamento.amount} />
</span>
</div>
</EventCard>
);
<Badge variant={"outline"}>{isPaid ? "Pago" : "Pendente"}</Badge>
</div>
<span className="font-semibold">
<MoneyValues amount={event.lancamento.amount} />
</span>
</div>
</EventCard>
);
};
const renderCard = (event: Extract<CalendarEvent, { type: "cartao" }>) => (
<EventCard type="cartao">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<div className="flex gap-1 items-center">
<span className="text-sm font-semibold leading-tight">
Vencimento Fatura - {event.card.name}
</span>
</div>
<EventCard type="cartao">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<div className="flex gap-1 items-center">
<span className="text-sm font-semibold leading-tight">
Vencimento Fatura - {event.card.name}
</span>
</div>
<Badge variant={"outline"}>{event.card.status ?? "Fatura"}</Badge>
</div>
{event.card.totalDue !== null ? (
<span className="font-semibold">
<MoneyValues amount={event.card.totalDue} />
</span>
) : null}
</div>
</EventCard>
<Badge variant={"outline"}>{event.card.status ?? "Fatura"}</Badge>
</div>
{event.card.totalDue !== null ? (
<span className="font-semibold">
<MoneyValues amount={event.card.totalDue} />
</span>
) : null}
</div>
</EventCard>
);
const renderEvent = (event: CalendarEvent) => {
switch (event.type) {
case "lancamento":
return renderLancamento(event);
case "boleto":
return renderBoleto(event);
case "cartao":
return renderCard(event);
default:
return null;
}
switch (event.type) {
case "lancamento":
return renderLancamento(event);
case "boleto":
return renderBoleto(event);
case "cartao":
return renderCard(event);
default:
return null;
}
};
export function EventModal({ open, day, onClose, onCreate }: EventModalProps) {
const formattedDate = !day
? ""
: friendlyDate(parseLocalDateString(day.date));
const formattedDate = !day
? ""
: friendlyDate(parseLocalDateString(day.date));
const handleCreate = () => {
if (!day) return;
onClose();
onCreate(day.date);
};
const handleCreate = () => {
if (!day) return;
onClose();
onCreate(day.date);
};
const description = day?.events.length
? "Confira os lançamentos e vencimentos cadastrados para este dia."
: "Nenhum lançamento encontrado para este dia. Você pode criar um novo lançamento agora.";
const description = day?.events.length
? "Confira os lançamentos e vencimentos cadastrados para este dia."
: "Nenhum lançamento encontrado para este dia. Você pode criar um novo lançamento agora.";
return (
<Dialog open={open} onOpenChange={(value) => (!value ? onClose() : null)}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{formattedDate}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
return (
<Dialog open={open} onOpenChange={(value) => (!value ? onClose() : null)}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{formattedDate}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="max-h-[380px] space-y-2 overflow-y-auto pr-2">
{day?.events.length ? (
day.events.map((event) => (
<div key={event.id}>{renderEvent(event)}</div>
))
) : (
<div className="rounded-xl border border-dashed border-border/60 bg-muted/30 p-6 text-center text-sm text-muted-foreground">
Nenhum lançamento ou vencimento registrado. Clique em{" "}
<span className="font-medium text-primary">Novo lançamento</span>{" "}
para começar.
</div>
)}
</div>
<div className="max-h-[380px] space-y-2 overflow-y-auto pr-2">
{day?.events.length ? (
day.events.map((event) => (
<div key={event.id}>{renderEvent(event)}</div>
))
) : (
<div className="rounded-xl border border-dashed border-border/60 bg-muted/30 p-6 text-center text-sm text-muted-foreground">
Nenhum lançamento ou vencimento registrado. Clique em{" "}
<span className="font-medium text-primary">Novo lançamento</span>{" "}
para começar.
</div>
)}
</div>
<DialogFooter className="flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button onClick={handleCreate} disabled={!day}>
Novo lançamento
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
<DialogFooter className="flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button onClick={handleCreate} disabled={!day}>
Novo lançamento
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,126 +1,124 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog";
import type {
CalendarDay,
CalendarEvent,
CalendarFormOptions,
CalendarPeriod,
} from "@/components/calendario/types";
import { buildCalendarDays } from "@/components/calendario/utils";
import { CalendarGrid } from "@/components/calendario/calendar-grid";
import { CalendarLegend } from "@/components/calendario/calendar-legend";
import { EventModal } from "@/components/calendario/event-modal";
import type {
CalendarDay,
CalendarEvent,
CalendarFormOptions,
CalendarPeriod,
} from "@/components/calendario/types";
import { buildCalendarDays } from "@/components/calendario/utils";
import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog";
type MonthlyCalendarProps = {
period: CalendarPeriod;
events: CalendarEvent[];
formOptions: CalendarFormOptions;
period: CalendarPeriod;
events: CalendarEvent[];
formOptions: CalendarFormOptions;
};
const parsePeriod = (period: string) => {
const [yearStr, monthStr] = period.split("-");
const year = Number.parseInt(yearStr ?? "", 10);
const month = Number.parseInt(monthStr ?? "", 10);
const [yearStr, monthStr] = period.split("-");
const year = Number.parseInt(yearStr ?? "", 10);
const month = Number.parseInt(monthStr ?? "", 10);
return { year, monthIndex: month - 1 };
return { year, monthIndex: month - 1 };
};
export function MonthlyCalendar({
period,
events,
formOptions,
period,
events,
formOptions,
}: MonthlyCalendarProps) {
const { year, monthIndex } = useMemo(
() => parsePeriod(period.period),
[period.period]
);
const { year, monthIndex } = useMemo(
() => parsePeriod(period.period),
[period.period],
);
const eventsByDay = useMemo(() => {
const map = new Map<string, CalendarEvent[]>();
events.forEach((event) => {
const list = map.get(event.date) ?? [];
list.push(event);
map.set(event.date, list);
});
return map;
}, [events]);
const eventsByDay = useMemo(() => {
const map = new Map<string, CalendarEvent[]>();
events.forEach((event) => {
const list = map.get(event.date) ?? [];
list.push(event);
map.set(event.date, list);
});
return map;
}, [events]);
const days = useMemo(
() => buildCalendarDays({ year, monthIndex, events: eventsByDay }),
[eventsByDay, monthIndex, year]
);
const days = useMemo(
() => buildCalendarDays({ year, monthIndex, events: eventsByDay }),
[eventsByDay, monthIndex, year],
);
const [selectedDay, setSelectedDay] = useState<CalendarDay | null>(null);
const [isModalOpen, setModalOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [createDate, setCreateDate] = useState<string | null>(null);
const [selectedDay, setSelectedDay] = useState<CalendarDay | null>(null);
const [isModalOpen, setModalOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [createDate, setCreateDate] = useState<string | null>(null);
const handleOpenCreate = useCallback((date: string) => {
setCreateDate(date);
setModalOpen(false);
setCreateOpen(true);
}, []);
const handleOpenCreate = useCallback((date: string) => {
setCreateDate(date);
setModalOpen(false);
setCreateOpen(true);
}, []);
const handleDaySelect = useCallback((day: CalendarDay) => {
setSelectedDay(day);
setModalOpen(true);
}, []);
const handleDaySelect = useCallback((day: CalendarDay) => {
setSelectedDay(day);
setModalOpen(true);
}, []);
const handleCreateFromCell = useCallback(
(day: CalendarDay) => {
handleOpenCreate(day.date);
},
[handleOpenCreate]
);
const handleCreateFromCell = useCallback(
(day: CalendarDay) => {
handleOpenCreate(day.date);
},
[handleOpenCreate],
);
const handleModalClose = useCallback(() => {
setModalOpen(false);
setSelectedDay(null);
}, []);
const handleModalClose = useCallback(() => {
setModalOpen(false);
setSelectedDay(null);
}, []);
const handleCreateDialogChange = useCallback((open: boolean) => {
setCreateOpen(open);
if (!open) {
setCreateDate(null);
}
}, []);
const handleCreateDialogChange = useCallback((open: boolean) => {
setCreateOpen(open);
if (!open) {
setCreateDate(null);
}
}, []);
return (
<>
<div className="space-y-3">
<CalendarLegend />
<CalendarGrid
days={days}
onSelectDay={handleDaySelect}
onCreateDay={handleCreateFromCell}
/>
</div>
return (
<>
<div className="space-y-3">
<CalendarLegend />
<CalendarGrid
days={days}
onSelectDay={handleDaySelect}
onCreateDay={handleCreateFromCell}
/>
</div>
<EventModal
open={isModalOpen}
day={selectedDay}
onClose={handleModalClose}
onCreate={handleOpenCreate}
/>
<EventModal
open={isModalOpen}
day={selectedDay}
onClose={handleModalClose}
onCreate={handleOpenCreate}
/>
<LancamentoDialog
mode="create"
open={createOpen}
onOpenChange={handleCreateDialogChange}
pagadorOptions={formOptions.pagadorOptions}
splitPagadorOptions={formOptions.splitPagadorOptions}
defaultPagadorId={formOptions.defaultPagadorId}
contaOptions={formOptions.contaOptions}
cartaoOptions={formOptions.cartaoOptions}
categoriaOptions={formOptions.categoriaOptions}
estabelecimentos={formOptions.estabelecimentos}
defaultPeriod={period.period}
defaultPurchaseDate={createDate ?? undefined}
/>
</>
);
<LancamentoDialog
mode="create"
open={createOpen}
onOpenChange={handleCreateDialogChange}
pagadorOptions={formOptions.pagadorOptions}
splitPagadorOptions={formOptions.splitPagadorOptions}
defaultPagadorId={formOptions.defaultPagadorId}
contaOptions={formOptions.contaOptions}
cartaoOptions={formOptions.cartaoOptions}
categoriaOptions={formOptions.categoriaOptions}
estabelecimentos={formOptions.estabelecimentos}
defaultPeriod={period.period}
defaultPurchaseDate={createDate ?? undefined}
/>
</>
);
}

View File

@@ -1,61 +1,64 @@
import type { LancamentoItem, SelectOption } from "@/components/lancamentos/types";
import type {
LancamentoItem,
SelectOption,
} from "@/components/lancamentos/types";
export type CalendarEventType = "lancamento" | "boleto" | "cartao";
export type CalendarEvent =
| {
id: string;
type: "lancamento";
date: string;
lancamento: LancamentoItem;
}
| {
id: string;
type: "boleto";
date: string;
lancamento: LancamentoItem;
}
| {
id: string;
type: "cartao";
date: string;
card: {
id: string;
name: string;
dueDay: string;
closingDay: string;
brand: string | null;
status: string;
logo: string | null;
totalDue: number | null;
};
};
| {
id: string;
type: "lancamento";
date: string;
lancamento: LancamentoItem;
}
| {
id: string;
type: "boleto";
date: string;
lancamento: LancamentoItem;
}
| {
id: string;
type: "cartao";
date: string;
card: {
id: string;
name: string;
dueDay: string;
closingDay: string;
brand: string | null;
status: string;
logo: string | null;
totalDue: number | null;
};
};
export type CalendarPeriod = {
period: string;
monthName: string;
year: number;
period: string;
monthName: string;
year: number;
};
export type CalendarDay = {
date: string;
label: string;
isCurrentMonth: boolean;
isToday: boolean;
events: CalendarEvent[];
date: string;
label: string;
isCurrentMonth: boolean;
isToday: boolean;
events: CalendarEvent[];
};
export type CalendarFormOptions = {
pagadorOptions: SelectOption[];
splitPagadorOptions: SelectOption[];
defaultPagadorId: string | null;
contaOptions: SelectOption[];
cartaoOptions: SelectOption[];
categoriaOptions: SelectOption[];
estabelecimentos: string[];
pagadorOptions: SelectOption[];
splitPagadorOptions: SelectOption[];
defaultPagadorId: string | null;
contaOptions: SelectOption[];
cartaoOptions: SelectOption[];
categoriaOptions: SelectOption[];
estabelecimentos: string[];
};
export type CalendarData = {
events: CalendarEvent[];
formOptions: CalendarFormOptions;
events: CalendarEvent[];
formOptions: CalendarFormOptions;
};

View File

@@ -3,59 +3,67 @@ import type { CalendarDay, CalendarEvent } from "@/components/calendario/types";
export const formatDateKey = (date: Date) => date.toISOString().slice(0, 10);
export const parseDateKey = (value: string) => {
const [yearStr, monthStr, dayStr] = value.split("-");
const year = Number.parseInt(yearStr ?? "", 10);
const month = Number.parseInt(monthStr ?? "", 10);
const day = Number.parseInt(dayStr ?? "", 10);
const [yearStr, monthStr, dayStr] = value.split("-");
const year = Number.parseInt(yearStr ?? "", 10);
const month = Number.parseInt(monthStr ?? "", 10);
const day = Number.parseInt(dayStr ?? "", 10);
return new Date(Date.UTC(year, (month ?? 1) - 1, day ?? 1));
return new Date(Date.UTC(year, (month ?? 1) - 1, day ?? 1));
};
const getWeekdayIndex = (date: Date) => {
const day = date.getUTCDay(); // 0 (domingo) - 6 (sábado)
// Ajusta para segunda-feira como primeiro dia
return day === 0 ? 6 : day - 1;
const day = date.getUTCDay(); // 0 (domingo) - 6 (sábado)
// Ajusta para segunda-feira como primeiro dia
return day === 0 ? 6 : day - 1;
};
export const buildCalendarDays = ({
year,
monthIndex,
events,
year,
monthIndex,
events,
}: {
year: number;
monthIndex: number;
events: Map<string, CalendarEvent[]>;
year: number;
monthIndex: number;
events: Map<string, CalendarEvent[]>;
}): CalendarDay[] => {
const startOfMonth = new Date(Date.UTC(year, monthIndex, 1));
const offset = getWeekdayIndex(startOfMonth);
const startDate = new Date(Date.UTC(year, monthIndex, 1 - offset));
const totalCells = 42; // 6 semanas
const now = new Date();
const todayKey = formatDateKey(
new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()))
);
const startOfMonth = new Date(Date.UTC(year, monthIndex, 1));
const offset = getWeekdayIndex(startOfMonth);
const startDate = new Date(Date.UTC(year, monthIndex, 1 - offset));
const totalCells = 42; // 6 semanas
const now = new Date();
const todayKey = formatDateKey(
new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())),
);
const days: CalendarDay[] = [];
const days: CalendarDay[] = [];
for (let index = 0; index < totalCells; index += 1) {
const currentDate = new Date(startDate);
currentDate.setUTCDate(startDate.getUTCDate() + index);
for (let index = 0; index < totalCells; index += 1) {
const currentDate = new Date(startDate);
currentDate.setUTCDate(startDate.getUTCDate() + index);
const dateKey = formatDateKey(currentDate);
const isCurrentMonth = currentDate.getUTCMonth() === monthIndex;
const dateLabel = currentDate.getUTCDate().toString();
const eventsForDay = events.get(dateKey) ?? [];
const dateKey = formatDateKey(currentDate);
const isCurrentMonth = currentDate.getUTCMonth() === monthIndex;
const dateLabel = currentDate.getUTCDate().toString();
const eventsForDay = events.get(dateKey) ?? [];
days.push({
date: dateKey,
label: dateLabel,
isCurrentMonth,
isToday: dateKey === todayKey,
events: eventsForDay,
});
}
days.push({
date: dateKey,
label: dateLabel,
isCurrentMonth,
isToday: dateKey === todayKey,
events: eventsForDay,
});
}
return days;
return days;
};
export const WEEK_DAYS_SHORT = ["Seg", "Ter", "Qua", "Qui", "Sex", "Sáb", "Dom"];
export const WEEK_DAYS_SHORT = [
"Seg",
"Ter",
"Qua",
"Qui",
"Sex",
"Sáb",
"Dom",
];

View File

@@ -1,250 +1,249 @@
"use client";
import {
createCardAction,
updateCardAction,
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { toast } from "sonner";
import {
createCardAction,
updateCardAction,
} from "@/app/(dashboard)/cartoes/actions";
import { LogoPickerDialog, LogoPickerTrigger } from "@/components/logo-picker";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useControlledState } from "@/hooks/use-controlled-state";
import { useFormState } from "@/hooks/use-form-state";
import { useLogoSelection } from "@/hooks/use-logo-selection";
import { deriveNameFromLogo, normalizeLogo } from "@/lib/logo";
import { formatLimitInput } from "@/lib/utils/currency";
import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { toast } from "sonner";
import { DEFAULT_CARD_BRANDS, DEFAULT_CARD_STATUS } from "./constants";
import { CardFormFields } from "./card-form-fields";
import { DEFAULT_CARD_BRANDS, DEFAULT_CARD_STATUS } from "./constants";
import type { Card, CardFormValues } from "./types";
type AccountOption = {
id: string;
name: string;
logo: string | null;
id: string;
name: string;
logo: string | null;
};
interface CardDialogProps {
mode: "create" | "update";
trigger?: React.ReactNode;
logoOptions: string[];
accounts: AccountOption[];
card?: Card;
open?: boolean;
onOpenChange?: (open: boolean) => void;
mode: "create" | "update";
trigger?: React.ReactNode;
logoOptions: string[];
accounts: AccountOption[];
card?: Card;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
const buildInitialValues = ({
card,
logoOptions,
accounts,
card,
logoOptions,
accounts,
}: {
card?: Card;
logoOptions: string[];
accounts: AccountOption[];
card?: Card;
logoOptions: string[];
accounts: AccountOption[];
}): CardFormValues => {
const fallbackLogo = logoOptions[0] ?? "";
const selectedLogo = normalizeLogo(card?.logo) || fallbackLogo;
const derivedName = deriveNameFromLogo(selectedLogo);
const fallbackLogo = logoOptions[0] ?? "";
const selectedLogo = normalizeLogo(card?.logo) || fallbackLogo;
const derivedName = deriveNameFromLogo(selectedLogo);
return {
name: card?.name ?? derivedName,
brand: card?.brand ?? DEFAULT_CARD_BRANDS[0],
status: card?.status ?? DEFAULT_CARD_STATUS[0],
closingDay: card?.closingDay ?? "01",
dueDay: card?.dueDay ?? "10",
limit: formatLimitInput(card?.limit ?? null),
note: card?.note ?? "",
logo: selectedLogo,
contaId: card?.contaId ?? accounts[0]?.id ?? "",
};
return {
name: card?.name ?? derivedName,
brand: card?.brand ?? DEFAULT_CARD_BRANDS[0],
status: card?.status ?? DEFAULT_CARD_STATUS[0],
closingDay: card?.closingDay ?? "01",
dueDay: card?.dueDay ?? "10",
limit: formatLimitInput(card?.limit ?? null),
note: card?.note ?? "",
logo: selectedLogo,
contaId: card?.contaId ?? accounts[0]?.id ?? "",
};
};
export function CardDialog({
mode,
trigger,
logoOptions,
accounts,
card,
open,
onOpenChange,
mode,
trigger,
logoOptions,
accounts,
card,
open,
onOpenChange,
}: CardDialogProps) {
const [logoDialogOpen, setLogoDialogOpen] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [logoDialogOpen, setLogoDialogOpen] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
// Use controlled state hook for dialog open state
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange
);
// Use controlled state hook for dialog open state
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange,
);
const initialState = useMemo(
() => buildInitialValues({ card, logoOptions, accounts }),
[card, logoOptions, accounts]
);
const initialState = useMemo(
() => buildInitialValues({ card, logoOptions, accounts }),
[card, logoOptions, accounts],
);
// Use form state hook for form management
const { formState, updateField, updateFields, setFormState } =
useFormState<CardFormValues>(initialState);
// Use form state hook for form management
const { formState, updateField, updateFields, setFormState } =
useFormState<CardFormValues>(initialState);
// Reset form when dialog opens
useEffect(() => {
if (dialogOpen) {
setFormState(initialState);
setErrorMessage(null);
}
}, [dialogOpen, initialState, setFormState]);
// Reset form when dialog opens
useEffect(() => {
if (dialogOpen) {
setFormState(initialState);
setErrorMessage(null);
}
}, [dialogOpen, initialState, setFormState]);
// Close logo dialog when main dialog closes
useEffect(() => {
if (!dialogOpen) {
setErrorMessage(null);
setLogoDialogOpen(false);
}
}, [dialogOpen]);
// Close logo dialog when main dialog closes
useEffect(() => {
if (!dialogOpen) {
setErrorMessage(null);
setLogoDialogOpen(false);
}
}, [dialogOpen]);
// Use logo selection hook
const handleLogoSelection = useLogoSelection({
mode,
currentLogo: formState.logo,
currentName: formState.name,
onUpdate: (updates) => {
updateFields(updates);
setLogoDialogOpen(false);
},
});
// Use logo selection hook
const handleLogoSelection = useLogoSelection({
mode,
currentLogo: formState.logo,
currentName: formState.name,
onUpdate: (updates) => {
updateFields(updates);
setLogoDialogOpen(false);
},
});
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
if (mode === "update" && !card?.id) {
const message = "Cartão inválido.";
setErrorMessage(message);
toast.error(message);
return;
}
if (mode === "update" && !card?.id) {
const message = "Cartão inválido.";
setErrorMessage(message);
toast.error(message);
return;
}
if (!formState.contaId) {
const message = "Selecione a conta vinculada.";
setErrorMessage(message);
toast.error(message);
return;
}
if (!formState.contaId) {
const message = "Selecione a conta vinculada.";
setErrorMessage(message);
toast.error(message);
return;
}
const payload = { ...formState };
const payload = { ...formState };
if (!payload.logo) {
const message = "Selecione um logo.";
setErrorMessage(message);
toast.error(message);
return;
}
if (!payload.logo) {
const message = "Selecione um logo.";
setErrorMessage(message);
toast.error(message);
return;
}
startTransition(async () => {
const result =
mode === "create"
? await createCardAction(payload)
: await updateCardAction({
id: card?.id ?? "",
...payload,
});
startTransition(async () => {
const result =
mode === "create"
? await createCardAction(payload)
: await updateCardAction({
id: card?.id ?? "",
...payload,
});
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
return;
}
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
});
},
[card?.id, formState, initialState, mode, setDialogOpen, setFormState]
);
setErrorMessage(result.error);
toast.error(result.error);
});
},
[card?.id, formState, initialState, mode, setDialogOpen, setFormState],
);
const title = mode === "create" ? "Novo cartão" : "Editar cartão";
const description =
mode === "create"
? "Inclua um novo cartão de crédito para acompanhar seus gastos."
: "Atualize as informações do cartão selecionado.";
const submitLabel = mode === "create" ? "Salvar cartão" : "Atualizar cartão";
const title = mode === "create" ? "Novo cartão" : "Editar cartão";
const description =
mode === "create"
? "Inclua um novo cartão de crédito para acompanhar seus gastos."
: "Atualize as informações do cartão selecionado.";
const submitLabel = mode === "create" ? "Salvar cartão" : "Atualizar cartão";
return (
<>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent className="">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
return (
<>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent className="">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<LogoPickerTrigger
selectedLogo={formState.logo}
disabled={logoOptions.length === 0}
helperText="Clique para escolher o logo do cartão"
onOpen={() => {
if (logoOptions.length > 0) {
setLogoDialogOpen(true);
}
}}
/>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<LogoPickerTrigger
selectedLogo={formState.logo}
disabled={logoOptions.length === 0}
helperText="Clique para escolher o logo do cartão"
onOpen={() => {
if (logoOptions.length > 0) {
setLogoDialogOpen(true);
}
}}
/>
<CardFormFields
values={formState}
accountOptions={accounts}
onChange={updateField}
/>
<CardFormFields
values={formState}
accountOptions={accounts}
onChange={updateField}
/>
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
<DialogFooter className="gap-3">
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Salvando..." : submitLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<DialogFooter className="gap-3">
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Salvando..." : submitLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<LogoPickerDialog
open={logoDialogOpen}
logos={logoOptions}
value={formState.logo}
onOpenChange={setLogoDialogOpen}
onSelect={handleLogoSelection}
/>
</>
);
<LogoPickerDialog
open={logoDialogOpen}
logos={logoOptions}
value={formState.logo}
onOpenChange={setLogoDialogOpen}
onSelect={handleLogoSelection}
/>
</>
);
}

View File

@@ -4,211 +4,212 @@ import { CurrencyInput } from "@/components/ui/currency-input";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
DAYS_IN_MONTH,
DEFAULT_CARD_BRANDS,
DEFAULT_CARD_STATUS,
AccountSelectContent,
BrandSelectContent,
StatusSelectContent,
} from "./card-select-items";
import {
DAYS_IN_MONTH,
DEFAULT_CARD_BRANDS,
DEFAULT_CARD_STATUS,
} from "./constants";
import type { CardFormValues } from "./types";
import {
BrandSelectContent,
StatusSelectContent,
AccountSelectContent,
} from "./card-select-items";
interface AccountOption {
id: string;
name: string;
logo: string | null;
id: string;
name: string;
logo: string | null;
}
interface CardFormFieldsProps {
values: CardFormValues;
accountOptions: AccountOption[];
onChange: (field: keyof CardFormValues, value: string) => void;
values: CardFormValues;
accountOptions: AccountOption[];
onChange: (field: keyof CardFormValues, value: string) => void;
}
const ensureOption = (options: string[], value: string) => {
if (!value) {
return options;
}
return options.includes(value) ? options : [value, ...options];
if (!value) {
return options;
}
return options.includes(value) ? options : [value, ...options];
};
export function CardFormFields({
values,
accountOptions,
onChange,
values,
accountOptions,
onChange,
}: CardFormFieldsProps) {
const brands = ensureOption(
DEFAULT_CARD_BRANDS as unknown as string[],
values.brand
);
const statuses = ensureOption(
DEFAULT_CARD_STATUS as unknown as string[],
values.status
);
const brands = ensureOption(
DEFAULT_CARD_BRANDS as unknown as string[],
values.brand,
);
const statuses = ensureOption(
DEFAULT_CARD_STATUS as unknown as string[],
values.status,
);
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="card-name">Nome do cartão</Label>
<Input
id="card-name"
value={values.name}
onChange={(event) => onChange("name", event.target.value)}
placeholder="Ex.: Nubank Platinum"
required
/>
</div>
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="card-name">Nome do cartão</Label>
<Input
id="card-name"
value={values.name}
onChange={(event) => onChange("name", event.target.value)}
placeholder="Ex.: Nubank Platinum"
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="card-brand">Bandeira</Label>
<Select
value={values.brand}
onValueChange={(value) => onChange("brand", value)}
>
<SelectTrigger id="card-brand" className="w-full">
<SelectValue placeholder="Selecione a bandeira">
{values.brand && <BrandSelectContent label={values.brand} />}
</SelectValue>
</SelectTrigger>
<SelectContent>
{brands.map((brand) => (
<SelectItem key={brand} value={brand}>
<BrandSelectContent label={brand} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="card-brand">Bandeira</Label>
<Select
value={values.brand}
onValueChange={(value) => onChange("brand", value)}
>
<SelectTrigger id="card-brand" className="w-full">
<SelectValue placeholder="Selecione a bandeira">
{values.brand && <BrandSelectContent label={values.brand} />}
</SelectValue>
</SelectTrigger>
<SelectContent>
{brands.map((brand) => (
<SelectItem key={brand} value={brand}>
<BrandSelectContent label={brand} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="card-status">Status</Label>
<Select
value={values.status}
onValueChange={(value) => onChange("status", value)}
>
<SelectTrigger id="card-status" className="w-full">
<SelectValue placeholder="Selecione o status">
{values.status && <StatusSelectContent label={values.status} />}
</SelectValue>
</SelectTrigger>
<SelectContent>
{statuses.map((status) => (
<SelectItem key={status} value={status}>
<StatusSelectContent label={status} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="card-status">Status</Label>
<Select
value={values.status}
onValueChange={(value) => onChange("status", value)}
>
<SelectTrigger id="card-status" className="w-full">
<SelectValue placeholder="Selecione o status">
{values.status && <StatusSelectContent label={values.status} />}
</SelectValue>
</SelectTrigger>
<SelectContent>
{statuses.map((status) => (
<SelectItem key={status} value={status}>
<StatusSelectContent label={status} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="card-limit">Limite (R$)</Label>
<CurrencyInput
id="card-limit"
value={values.limit}
onValueChange={(value) => onChange("limit", value)}
placeholder="R$ 0,00"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="card-limit">Limite (R$)</Label>
<CurrencyInput
id="card-limit"
value={values.limit}
onValueChange={(value) => onChange("limit", value)}
placeholder="R$ 0,00"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="card-closing-day">Dia de fechamento</Label>
<Select
value={values.closingDay}
onValueChange={(value) => onChange("closingDay", value)}
>
<SelectTrigger id="card-closing-day" className="w-full">
<SelectValue placeholder="Dia de fechamento" />
</SelectTrigger>
<SelectContent>
{DAYS_IN_MONTH.map((day) => (
<SelectItem key={day} value={day}>
Dia {day}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="card-closing-day">Dia de fechamento</Label>
<Select
value={values.closingDay}
onValueChange={(value) => onChange("closingDay", value)}
>
<SelectTrigger id="card-closing-day" className="w-full">
<SelectValue placeholder="Dia de fechamento" />
</SelectTrigger>
<SelectContent>
{DAYS_IN_MONTH.map((day) => (
<SelectItem key={day} value={day}>
Dia {day}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="card-due-day">Dia de vencimento</Label>
<Select
value={values.dueDay}
onValueChange={(value) => onChange("dueDay", value)}
>
<SelectTrigger id="card-due-day" className="w-full">
<SelectValue placeholder="Dia de vencimento" />
</SelectTrigger>
<SelectContent>
{DAYS_IN_MONTH.map((day) => (
<SelectItem key={day} value={day}>
Dia {day}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="card-due-day">Dia de vencimento</Label>
<Select
value={values.dueDay}
onValueChange={(value) => onChange("dueDay", value)}
>
<SelectTrigger id="card-due-day" className="w-full">
<SelectValue placeholder="Dia de vencimento" />
</SelectTrigger>
<SelectContent>
{DAYS_IN_MONTH.map((day) => (
<SelectItem key={day} value={day}>
Dia {day}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="card-account">Conta vinculada</Label>
<Select
value={values.contaId}
onValueChange={(value) => onChange("contaId", value)}
disabled={accountOptions.length === 0}
>
<SelectTrigger id="card-account" className="w-full">
<SelectValue
placeholder={
accountOptions.length === 0
? "Cadastre uma conta primeiro"
: "Selecione a conta"
}
>
{values.contaId && (() => {
const selectedAccount = accountOptions.find(
(acc) => acc.id === values.contaId
);
return selectedAccount ? (
<AccountSelectContent
label={selectedAccount.name}
logo={selectedAccount.logo}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{accountOptions.map((account) => (
<SelectItem key={account.id} value={account.id}>
<AccountSelectContent
label={account.name}
logo={account.logo}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="card-account">Conta vinculada</Label>
<Select
value={values.contaId}
onValueChange={(value) => onChange("contaId", value)}
disabled={accountOptions.length === 0}
>
<SelectTrigger id="card-account" className="w-full">
<SelectValue
placeholder={
accountOptions.length === 0
? "Cadastre uma conta primeiro"
: "Selecione a conta"
}
>
{values.contaId &&
(() => {
const selectedAccount = accountOptions.find(
(acc) => acc.id === values.contaId,
);
return selectedAccount ? (
<AccountSelectContent
label={selectedAccount.name}
logo={selectedAccount.logo}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{accountOptions.map((account) => (
<SelectItem key={account.id} value={account.id}>
<AccountSelectContent
label={account.name}
logo={account.logo}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="card-note">Anotação</Label>
<Textarea
id="card-note"
value={values.note}
onChange={(event) => onChange("note", event.target.value)}
placeholder="Observações sobre este cartão"
/>
</div>
</div>
);
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="card-note">Anotação</Label>
<Textarea
id="card-note"
value={values.note}
onChange={(event) => onChange("note", event.target.value)}
placeholder="Observações sobre este cartão"
/>
</div>
</div>
);
}

View File

@@ -1,307 +1,307 @@
"use client";
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils/ui";
import {
RiChat3Line,
RiDeleteBin5Line,
RiEyeLine,
RiPencilLine,
RiChat3Line,
RiDeleteBin5Line,
RiEyeLine,
RiPencilLine,
} from "@remixicon/react";
import Image from "next/image";
import { useMemo } from "react";
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils/ui";
import MoneyValues from "../money-values";
interface CardItemProps {
name: string;
brand: string;
status: string;
closingDay: string;
dueDay: string;
limit: number | null;
limitInUse?: number | null;
limitAvailable?: number | null;
contaName: string;
logo?: string | null;
note?: string | null;
onEdit?: () => void;
onInvoice?: () => void;
onRemove?: () => void;
name: string;
brand: string;
status: string;
closingDay: string;
dueDay: string;
limit: number | null;
limitInUse?: number | null;
limitAvailable?: number | null;
contaName: string;
logo?: string | null;
note?: string | null;
onEdit?: () => void;
onInvoice?: () => void;
onRemove?: () => void;
}
const BRAND_ASSETS: Record<string, string> = {
visa: "/bandeiras/visa.svg",
mastercard: "/bandeiras/mastercard.svg",
amex: "/bandeiras/amex.svg",
american: "/bandeiras/amex.svg",
elo: "/bandeiras/elo.svg",
hipercard: "/bandeiras/hipercard.svg",
hiper: "/bandeiras/hipercard.svg",
visa: "/bandeiras/visa.svg",
mastercard: "/bandeiras/mastercard.svg",
amex: "/bandeiras/amex.svg",
american: "/bandeiras/amex.svg",
elo: "/bandeiras/elo.svg",
hipercard: "/bandeiras/hipercard.svg",
hiper: "/bandeiras/hipercard.svg",
};
const resolveBrandAsset = (brand: string) => {
const normalized = brand.trim().toLowerCase();
const normalized = brand.trim().toLowerCase();
const match = (
Object.keys(BRAND_ASSETS) as Array<keyof typeof BRAND_ASSETS>
).find((entry) => normalized.includes(entry));
const match = (
Object.keys(BRAND_ASSETS) as Array<keyof typeof BRAND_ASSETS>
).find((entry) => normalized.includes(entry));
return match ? BRAND_ASSETS[match] : null;
return match ? BRAND_ASSETS[match] : null;
};
const formatDay = (value: string) => value.padStart(2, "0");
export function CardItem({
name,
brand,
status,
closingDay,
dueDay,
limit,
limitInUse,
limitAvailable,
contaName: _contaName,
logo,
note,
onEdit,
onInvoice,
onRemove,
name,
brand,
status,
closingDay,
dueDay,
limit,
limitInUse,
limitAvailable,
contaName: _contaName,
logo,
note,
onEdit,
onInvoice,
onRemove,
}: CardItemProps) {
void _contaName;
void _contaName;
const limitTotal = limit ?? null;
const used =
limitInUse ??
(limitTotal !== null && limitAvailable !== null
? Math.max(limitTotal - limitAvailable, 0)
: limitTotal !== null
? 0
: null);
const limitTotal = limit ?? null;
const used =
limitInUse ??
(limitTotal !== null && limitAvailable !== null
? Math.max(limitTotal - limitAvailable, 0)
: limitTotal !== null
? 0
: null);
const available =
limitAvailable ??
(limitTotal !== null && used !== null
? Math.max(limitTotal - used, 0)
: null);
const available =
limitAvailable ??
(limitTotal !== null && used !== null
? Math.max(limitTotal - used, 0)
: null);
const usagePercent =
limitTotal && limitTotal > 0 && used !== null
? Math.min(Math.max((used / limitTotal) * 100, 0), 100)
: 0;
const usagePercent =
limitTotal && limitTotal > 0 && used !== null
? Math.min(Math.max((used / limitTotal) * 100, 0), 100)
: 0;
const logoPath = useMemo(() => {
if (!logo) {
return null;
}
const logoPath = useMemo(() => {
if (!logo) {
return null;
}
if (
logo.startsWith("http://") ||
logo.startsWith("https://") ||
logo.startsWith("data:")
) {
return logo;
}
if (
logo.startsWith("http://") ||
logo.startsWith("https://") ||
logo.startsWith("data:")
) {
return logo;
}
return logo.startsWith("/") ? logo : `/logos/${logo}`;
}, [logo]);
return logo.startsWith("/") ? logo : `/logos/${logo}`;
}, [logo]);
const brandAsset = useMemo(() => resolveBrandAsset(brand), [brand]);
const brandAsset = useMemo(() => resolveBrandAsset(brand), [brand]);
const isInactive = useMemo(
() => status?.toLowerCase() === "inativo",
[status]
);
const isInactive = useMemo(
() => status?.toLowerCase() === "inativo",
[status],
);
const metrics = useMemo(() => {
if (limitTotal === null) return null;
const metrics = useMemo(() => {
if (limitTotal === null) return null;
return [
{ label: "Limite Total", value: limitTotal },
{ label: "Em uso", value: used },
{ label: "Disponível", value: available },
];
}, [available, limitTotal, used]);
return [
{ label: "Limite Total", value: limitTotal },
{ label: "Em uso", value: used },
{ label: "Disponível", value: available },
];
}, [available, limitTotal, used]);
const actions = useMemo(
() => [
{
label: "editar",
icon: <RiPencilLine className="size-4" aria-hidden />,
onClick: onEdit,
className: "text-primary",
},
{
label: "ver fatura",
icon: <RiEyeLine className="size-4" aria-hidden />,
onClick: onInvoice,
className: "text-primary",
},
{
label: "remover",
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
onClick: onRemove,
className: "text-destructive",
},
],
[onEdit, onInvoice, onRemove]
);
const actions = useMemo(
() => [
{
label: "editar",
icon: <RiPencilLine className="size-4" aria-hidden />,
onClick: onEdit,
className: "text-primary",
},
{
label: "ver fatura",
icon: <RiEyeLine className="size-4" aria-hidden />,
onClick: onInvoice,
className: "text-primary",
},
{
label: "remover",
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
onClick: onRemove,
className: "text-destructive",
},
],
[onEdit, onInvoice, onRemove],
);
return (
<Card className="flex p-6 h-[300px] w-[440px]">
<CardHeader className="space-y-2 px-0 pb-0">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-1 items-center gap-2">
{logoPath ? (
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden">
<Image
src={logoPath}
alt={`Logo do cartão ${name}`}
width={42}
height={42}
className={cn(
"rounded-lg",
isInactive && "grayscale opacity-40"
)}
/>
</div>
) : null}
return (
<Card className="flex p-6 h-[300px] w-[440px]">
<CardHeader className="space-y-2 px-0 pb-0">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-1 items-center gap-2">
{logoPath ? (
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden">
<Image
src={logoPath}
alt={`Logo do cartão ${name}`}
width={42}
height={42}
className={cn(
"rounded-lg",
isInactive && "grayscale opacity-40",
)}
/>
</div>
) : null}
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<h3 className="truncate text-sm font-semibold text-foreground sm:text-base">
{name}
</h3>
{note ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground/70 transition-colors hover:text-foreground"
aria-label="Observações do cartão"
>
<RiChat3Line className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" align="start">
{note}
</TooltipContent>
</Tooltip>
) : null}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<h3 className="truncate text-sm font-semibold text-foreground sm:text-base">
{name}
</h3>
{note ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground/70 transition-colors hover:text-foreground"
aria-label="Observações do cartão"
>
<RiChat3Line className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" align="start">
{note}
</TooltipContent>
</Tooltip>
) : null}
</div>
{status ? (
<span className="text-xs tracking-wide text-muted-foreground">
{status}
</span>
) : null}
</div>
</div>
{status ? (
<span className="text-xs tracking-wide text-muted-foreground">
{status}
</span>
) : null}
</div>
</div>
{brandAsset ? (
<div className="flex items-center justify-center rounded-lg py-1">
<Image
src={brandAsset}
alt={`Bandeira ${brand}`}
width={42}
height={42}
className={cn(
"h-6 w-auto rounded",
isInactive && "grayscale opacity-40"
)}
/>
</div>
) : (
<span className="text-sm font-medium text-muted-foreground">
{brand}
</span>
)}
</div>
{brandAsset ? (
<div className="flex items-center justify-center rounded-lg py-1">
<Image
src={brandAsset}
alt={`Bandeira ${brand}`}
width={42}
height={42}
className={cn(
"h-6 w-auto rounded",
isInactive && "grayscale opacity-40",
)}
/>
</div>
) : (
<span className="text-sm font-medium text-muted-foreground">
{brand}
</span>
)}
</div>
<div className="flex items-center justify-between border-y border-dashed py-3 text-xs font-medium text-muted-foreground sm:text-sm">
<span>
Fecha dia{" "}
<span className="font-semibold text-foreground">
{formatDay(closingDay)}
</span>
</span>
<span>
Vence dia{" "}
<span className="font-semibold text-foreground">
{formatDay(dueDay)}
</span>
</span>
</div>
</CardHeader>
<div className="flex items-center justify-between border-y border-dashed py-3 text-xs font-medium text-muted-foreground sm:text-sm">
<span>
Fecha dia{" "}
<span className="font-semibold text-foreground">
{formatDay(closingDay)}
</span>
</span>
<span>
Vence dia{" "}
<span className="font-semibold text-foreground">
{formatDay(dueDay)}
</span>
</span>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-5 px-0">
{metrics ? (
<>
<div className="grid grid-cols-3 gap-4">
<div className="flex flex-col items-start gap-1">
<p className="text-sm font-semibold text-foreground">
<MoneyValues amount={metrics[0].value} />
</p>
<span className="text-xs font-medium text-muted-foreground">
{metrics[0].label}
</span>
</div>
<CardContent className="flex flex-1 flex-col gap-5 px-0">
{metrics ? (
<>
<div className="grid grid-cols-3 gap-4">
<div className="flex flex-col items-start gap-1">
<p className="text-sm font-semibold text-foreground">
<MoneyValues amount={metrics[0].value} />
</p>
<span className="text-xs font-medium text-muted-foreground">
{metrics[0].label}
</span>
</div>
<div className="flex flex-col items-center gap-1">
<p className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
<span className="size-2 rounded-full bg-primary" />
<MoneyValues amount={metrics[1].value} />
</p>
<span className="text-xs font-medium text-muted-foreground">
{metrics[1].label}
</span>
</div>
<div className="flex flex-col items-center gap-1">
<p className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
<span className="size-2 rounded-full bg-primary" />
<MoneyValues amount={metrics[1].value} />
</p>
<span className="text-xs font-medium text-muted-foreground">
{metrics[1].label}
</span>
</div>
<div className="flex flex-col items-end gap-1">
<p className="text-sm font-semibold text-foreground">
<MoneyValues amount={metrics[2].value} />
</p>
<span className="text-xs font-medium text-muted-foreground">
{metrics[2].label}
</span>
</div>
</div>
<div className="flex flex-col items-end gap-1">
<p className="text-sm font-semibold text-foreground">
<MoneyValues amount={metrics[2].value} />
</p>
<span className="text-xs font-medium text-muted-foreground">
{metrics[2].label}
</span>
</div>
</div>
<Progress value={usagePercent} className="h-3" />
</>
) : (
<p className="text-sm text-muted-foreground">
Ainda não limite registrado para este cartão.
</p>
)}
</CardContent>
<Progress value={usagePercent} className="h-3" />
</>
) : (
<p className="text-sm text-muted-foreground">
Ainda não limite registrado para este cartão.
</p>
)}
</CardContent>
<CardFooter className="mt-auto flex flex-wrap gap-4 px-0 text-sm">
{actions.map(({ label, icon, onClick, className }) => (
<button
key={label}
type="button"
onClick={onClick}
className={cn(
"flex items-center gap-1 font-medium transition-opacity hover:opacity-80",
className
)}
>
{icon}
{label}
</button>
))}
</CardFooter>
</Card>
);
<CardFooter className="mt-auto flex flex-wrap gap-4 px-0 text-sm">
{actions.map(({ label, icon, onClick, className }) => (
<button
key={label}
type="button"
onClick={onClick}
className={cn(
"flex items-center gap-1 font-medium transition-opacity hover:opacity-80",
className,
)}
>
{icon}
{label}
</button>
))}
</CardFooter>
</Card>
);
}

View File

@@ -1,89 +1,89 @@
"use client";
import DotIcon from "@/components/dot-icon";
import Image from "next/image";
import { RiBankLine } from "@remixicon/react";
import Image from "next/image";
import DotIcon from "@/components/dot-icon";
type SelectItemContentProps = {
label: string;
logo?: string | null;
label: string;
logo?: string | null;
};
const resolveLogoSrc = (logo: string | null) => {
if (!logo) {
return null;
}
if (!logo) {
return null;
}
const fileName = logo.split("/").filter(Boolean).pop() ?? logo;
return `/logos/${fileName}`;
const fileName = logo.split("/").filter(Boolean).pop() ?? logo;
return `/logos/${fileName}`;
};
const getBrandLogo = (brand: string): string | null => {
const brandMap: Record<string, string> = {
Visa: "visa.png",
Mastercard: "mastercard.png",
Elo: "elo.png",
};
const brandMap: Record<string, string> = {
Visa: "visa.png",
Mastercard: "mastercard.png",
Elo: "elo.png",
};
return brandMap[brand] ?? null;
return brandMap[brand] ?? null;
};
export function BrandSelectContent({ label }: { label: string }) {
const brandLogo = getBrandLogo(label);
const logoSrc = brandLogo ? `/logos/${brandLogo}` : null;
const brandLogo = getBrandLogo(label);
const logoSrc = brandLogo ? `/logos/${brandLogo}` : null;
return (
<span className="flex items-center gap-2">
{logoSrc ? (
<Image
src={logoSrc}
alt={`Logo ${label}`}
width={24}
height={24}
className="rounded object-contain"
/>
) : (
<RiBankLine className="size-5 text-muted-foreground" aria-hidden />
)}
<span>{label}</span>
</span>
);
return (
<span className="flex items-center gap-2">
{logoSrc ? (
<Image
src={logoSrc}
alt={`Logo ${label}`}
width={24}
height={24}
className="rounded object-contain"
/>
) : (
<RiBankLine className="size-5 text-muted-foreground" aria-hidden />
)}
<span>{label}</span>
</span>
);
}
export function StatusSelectContent({ label }: { label: string }) {
const isActive = label === "Ativo";
const isActive = label === "Ativo";
return (
<span className="flex items-center gap-2">
<DotIcon
bg_dot={
isActive
? "bg-emerald-600 dark:bg-emerald-300"
: "bg-slate-400 dark:bg-slate-500"
}
/>
<span>{label}</span>
</span>
);
return (
<span className="flex items-center gap-2">
<DotIcon
bg_dot={
isActive
? "bg-emerald-600 dark:bg-emerald-300"
: "bg-slate-400 dark:bg-slate-500"
}
/>
<span>{label}</span>
</span>
);
}
export function AccountSelectContent({ label, logo }: SelectItemContentProps) {
const logoSrc = resolveLogoSrc(logo);
const logoSrc = resolveLogoSrc(logo);
return (
<span className="flex items-center gap-2">
{logoSrc ? (
<Image
src={logoSrc}
alt={`Logo de ${label}`}
width={20}
height={20}
className="rounded"
/>
) : (
<RiBankLine className="size-4 text-muted-foreground" aria-hidden />
)}
<span>{label}</span>
</span>
);
return (
<span className="flex items-center gap-2">
{logoSrc ? (
<Image
src={logoSrc}
alt={`Logo de ${label}`}
width={20}
height={20}
className="rounded"
/>
) : (
<RiBankLine className="size-4 text-muted-foreground" aria-hidden />
)}
<span>{label}</span>
</span>
);
}

View File

@@ -1,189 +1,189 @@
"use client";
import { RiAddCircleLine, RiBankCard2Line } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import { deleteCardAction } from "@/app/(dashboard)/cartoes/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { EmptyState } from "@/components/empty-state";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { RiAddCircleLine, RiBankCard2Line } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import { CardDialog } from "./card-dialog";
import { CardItem } from "./card-item";
type AccountOption = {
id: string;
name: string;
id: string;
name: string;
};
interface CardsPageProps {
cards: Card[];
accounts: AccountOption[];
logoOptions: string[];
isInativos?: boolean;
cards: Card[];
accounts: AccountOption[];
logoOptions: string[];
isInativos?: boolean;
}
export function CardsPage({
cards,
accounts,
logoOptions,
isInativos = false,
cards,
accounts,
logoOptions,
isInativos = false,
}: CardsPageProps) {
const router = useRouter();
const [editOpen, setEditOpen] = useState(false);
const [selectedCard, setSelectedCard] = useState<Card | null>(null);
const [removeOpen, setRemoveOpen] = useState(false);
const [cardToRemove, setCardToRemove] = useState<Card | null>(null);
const router = useRouter();
const [editOpen, setEditOpen] = useState(false);
const [selectedCard, setSelectedCard] = useState<Card | null>(null);
const [removeOpen, setRemoveOpen] = useState(false);
const [cardToRemove, setCardToRemove] = useState<Card | null>(null);
const hasCards = cards.length > 0;
const hasCards = cards.length > 0;
const orderedCards = useMemo(
() =>
[...cards].sort((a, b) => {
// Coloca inativos no final
const aIsInactive = a.status?.toLowerCase() === "inativo";
const bIsInactive = b.status?.toLowerCase() === "inativo";
const orderedCards = useMemo(
() =>
[...cards].sort((a, b) => {
// Coloca inativos no final
const aIsInactive = a.status?.toLowerCase() === "inativo";
const bIsInactive = b.status?.toLowerCase() === "inativo";
if (aIsInactive && !bIsInactive) return 1;
if (!aIsInactive && bIsInactive) return -1;
if (aIsInactive && !bIsInactive) return 1;
if (!aIsInactive && bIsInactive) return -1;
// Mesma ordem alfabética dentro de cada grupo
return a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" });
}),
[cards]
);
// Mesma ordem alfabética dentro de cada grupo
return a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" });
}),
[cards],
);
const handleEdit = useCallback((card: Card) => {
setSelectedCard(card);
setEditOpen(true);
}, []);
const handleEdit = useCallback((card: Card) => {
setSelectedCard(card);
setEditOpen(true);
}, []);
const handleEditOpenChange = useCallback((open: boolean) => {
setEditOpen(open);
if (!open) {
setSelectedCard(null);
}
}, []);
const handleEditOpenChange = useCallback((open: boolean) => {
setEditOpen(open);
if (!open) {
setSelectedCard(null);
}
}, []);
const handleRemoveRequest = useCallback((card: Card) => {
setCardToRemove(card);
setRemoveOpen(true);
}, []);
const handleRemoveRequest = useCallback((card: Card) => {
setCardToRemove(card);
setRemoveOpen(true);
}, []);
const handleInvoice = useCallback(
(card: Card) => {
router.push(`/cartoes/${card.id}/fatura`);
},
[router]
);
const handleInvoice = useCallback(
(card: Card) => {
router.push(`/cartoes/${card.id}/fatura`);
},
[router],
);
const handleRemoveOpenChange = useCallback((open: boolean) => {
setRemoveOpen(open);
if (!open) {
setCardToRemove(null);
}
}, []);
const handleRemoveOpenChange = useCallback((open: boolean) => {
setRemoveOpen(open);
if (!open) {
setCardToRemove(null);
}
}, []);
const handleRemoveConfirm = useCallback(async () => {
if (!cardToRemove) {
return;
}
const handleRemoveConfirm = useCallback(async () => {
if (!cardToRemove) {
return;
}
const result = await deleteCardAction({ id: cardToRemove.id });
const result = await deleteCardAction({ id: cardToRemove.id });
if (result.success) {
toast.success(result.message);
return;
}
if (result.success) {
toast.success(result.message);
return;
}
toast.error(result.error);
throw new Error(result.error);
}, [cardToRemove]);
toast.error(result.error);
throw new Error(result.error);
}, [cardToRemove]);
const removeTitle = cardToRemove
? `Remover cartão "${cardToRemove.name}"?`
: "Remover cartão?";
const removeTitle = cardToRemove
? `Remover cartão "${cardToRemove.name}"?`
: "Remover cartão?";
return (
<>
<div className="flex w-full flex-col gap-6">
{!isInativos && (
<div className="flex justify-start">
<CardDialog
mode="create"
accounts={accounts}
logoOptions={logoOptions}
trigger={
<Button>
<RiAddCircleLine className="size-4" />
Novo cartão
</Button>
}
/>
</div>
)}
return (
<>
<div className="flex w-full flex-col gap-6">
{!isInativos && (
<div className="flex justify-start">
<CardDialog
mode="create"
accounts={accounts}
logoOptions={logoOptions}
trigger={
<Button>
<RiAddCircleLine className="size-4" />
Novo cartão
</Button>
}
/>
</div>
)}
{hasCards ? (
<div className="flex flex-wrap gap-4">
{orderedCards.map((card) => (
<CardItem
key={card.id}
name={card.name}
brand={card.brand}
status={card.status}
closingDay={card.closingDay}
dueDay={card.dueDay}
limit={card.limit}
limitInUse={card.limitInUse ?? null}
limitAvailable={card.limitAvailable ?? card.limit ?? null}
contaName={card.contaName}
logo={card.logo}
note={card.note}
onEdit={() => handleEdit(card)}
onInvoice={() => handleInvoice(card)}
onRemove={() => handleRemoveRequest(card)}
/>
))}
</div>
) : (
<Card className="flex w-full items-center justify-center py-12">
<EmptyState
media={<RiBankCard2Line className="size-6 text-primary" />}
title={
isInativos
? "Nenhum cartão inativo"
: "Nenhum cartão cadastrado"
}
description={
isInativos
? "Os cartões inativos aparecerão aqui."
: "Adicione seu primeiro cartão para acompanhar limites e faturas com mais controle."
}
/>
</Card>
)}
</div>
{hasCards ? (
<div className="flex flex-wrap gap-4">
{orderedCards.map((card) => (
<CardItem
key={card.id}
name={card.name}
brand={card.brand}
status={card.status}
closingDay={card.closingDay}
dueDay={card.dueDay}
limit={card.limit}
limitInUse={card.limitInUse ?? null}
limitAvailable={card.limitAvailable ?? card.limit ?? null}
contaName={card.contaName}
logo={card.logo}
note={card.note}
onEdit={() => handleEdit(card)}
onInvoice={() => handleInvoice(card)}
onRemove={() => handleRemoveRequest(card)}
/>
))}
</div>
) : (
<Card className="flex w-full items-center justify-center py-12">
<EmptyState
media={<RiBankCard2Line className="size-6 text-primary" />}
title={
isInativos
? "Nenhum cartão inativo"
: "Nenhum cartão cadastrado"
}
description={
isInativos
? "Os cartões inativos aparecerão aqui."
: "Adicione seu primeiro cartão para acompanhar limites e faturas com mais controle."
}
/>
</Card>
)}
</div>
<CardDialog
mode="update"
accounts={accounts}
logoOptions={logoOptions}
card={selectedCard ?? undefined}
open={editOpen && !!selectedCard}
onOpenChange={handleEditOpenChange}
/>
<CardDialog
mode="update"
accounts={accounts}
logoOptions={logoOptions}
card={selectedCard ?? undefined}
open={editOpen && !!selectedCard}
onOpenChange={handleEditOpenChange}
/>
<ConfirmActionDialog
open={removeOpen && !!cardToRemove}
onOpenChange={handleRemoveOpenChange}
title={removeTitle}
description="Ao remover este cartão, os registros relacionados a ele serão excluídos permanentemente."
confirmLabel="Remover cartão"
pendingLabel="Removendo..."
confirmVariant="destructive"
onConfirm={handleRemoveConfirm}
/>
</>
);
<ConfirmActionDialog
open={removeOpen && !!cardToRemove}
onOpenChange={handleRemoveOpenChange}
title={removeTitle}
description="Ao remover este cartão, os registros relacionados a ele serão excluídos permanentemente."
confirmLabel="Remover cartão"
pendingLabel="Removendo..."
confirmVariant="destructive"
onConfirm={handleRemoveConfirm}
/>
</>
);
}

View File

@@ -3,5 +3,5 @@ export const DEFAULT_CARD_BRANDS = ["Visa", "Mastercard", "Elo"] as const;
export const DEFAULT_CARD_STATUS = ["Ativo", "Inativo"] as const;
export const DAYS_IN_MONTH = Array.from({ length: 31 }, (_, index) =>
String(index + 1).padStart(2, "0")
String(index + 1).padStart(2, "0"),
);

View File

@@ -1,27 +1,27 @@
export type Card = {
id: string;
name: string;
brand: string;
status: string;
closingDay: string;
dueDay: string;
note: string | null;
logo: string | null;
limit: number | null;
contaId: string;
contaName: string;
limitInUse?: number | null;
limitAvailable?: number | null;
id: string;
name: string;
brand: string;
status: string;
closingDay: string;
dueDay: string;
note: string | null;
logo: string | null;
limit: number | null;
contaId: string;
contaName: string;
limitInUse?: number | null;
limitAvailable?: number | null;
};
export type CardFormValues = {
name: string;
brand: string;
status: string;
closingDay: string;
dueDay: string;
limit: string;
note: string;
logo: string;
contaId: string;
name: string;
brand: string;
status: string;
closingDay: string;
dueDay: string;
limit: string;
note: string;
logo: string;
contaId: string;
};

View File

@@ -1,167 +1,167 @@
"use client";
import { RiAddCircleLine } from "@remixicon/react";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import { deleteCategoryAction } from "@/app/(dashboard)/categorias/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
CATEGORY_TYPE_LABEL,
CATEGORY_TYPES,
CATEGORY_TYPE_LABEL,
CATEGORY_TYPES,
} from "@/lib/categorias/constants";
import { RiAddCircleLine } from "@remixicon/react";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import { CategoryCard } from "./category-card";
import { CategoryDialog } from "./category-dialog";
import type { Category, CategoryType } from "./types";
interface CategoriesPageProps {
categories: Category[];
categories: Category[];
}
export function CategoriesPage({ categories }: CategoriesPageProps) {
const [activeType, setActiveType] = useState<CategoryType>(CATEGORY_TYPES[0]);
const [editOpen, setEditOpen] = useState(false);
const [selectedCategory, setSelectedCategory] = useState<Category | null>(
null
);
const [removeOpen, setRemoveOpen] = useState(false);
const [categoryToRemove, setCategoryToRemove] = useState<Category | null>(
null
);
const [activeType, setActiveType] = useState<CategoryType>(CATEGORY_TYPES[0]);
const [editOpen, setEditOpen] = useState(false);
const [selectedCategory, setSelectedCategory] = useState<Category | null>(
null,
);
const [removeOpen, setRemoveOpen] = useState(false);
const [categoryToRemove, setCategoryToRemove] = useState<Category | null>(
null,
);
const categoriesByType = useMemo(() => {
const base = Object.fromEntries(
CATEGORY_TYPES.map((type) => [type, [] as Category[]])
) as Record<CategoryType, Category[]>;
const categoriesByType = useMemo(() => {
const base = Object.fromEntries(
CATEGORY_TYPES.map((type) => [type, [] as Category[]]),
) as Record<CategoryType, Category[]>;
categories.forEach((category) => {
base[category.type]?.push(category);
});
categories.forEach((category) => {
base[category.type]?.push(category);
});
CATEGORY_TYPES.forEach((type) => {
base[type].sort((a, b) =>
a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" })
);
});
CATEGORY_TYPES.forEach((type) => {
base[type].sort((a, b) =>
a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" }),
);
});
return base;
}, [categories]);
return base;
}, [categories]);
const handleEdit = useCallback((category: Category) => {
setSelectedCategory(category);
setEditOpen(true);
}, []);
const handleEdit = useCallback((category: Category) => {
setSelectedCategory(category);
setEditOpen(true);
}, []);
const handleEditOpenChange = useCallback((open: boolean) => {
setEditOpen(open);
if (!open) {
setSelectedCategory(null);
}
}, []);
const handleEditOpenChange = useCallback((open: boolean) => {
setEditOpen(open);
if (!open) {
setSelectedCategory(null);
}
}, []);
const handleRemoveRequest = useCallback((category: Category) => {
setCategoryToRemove(category);
setRemoveOpen(true);
}, []);
const handleRemoveRequest = useCallback((category: Category) => {
setCategoryToRemove(category);
setRemoveOpen(true);
}, []);
const handleRemoveOpenChange = useCallback((open: boolean) => {
setRemoveOpen(open);
if (!open) {
setCategoryToRemove(null);
}
}, []);
const handleRemoveOpenChange = useCallback((open: boolean) => {
setRemoveOpen(open);
if (!open) {
setCategoryToRemove(null);
}
}, []);
const handleRemoveConfirm = useCallback(async () => {
if (!categoryToRemove) {
return;
}
const handleRemoveConfirm = useCallback(async () => {
if (!categoryToRemove) {
return;
}
const result = await deleteCategoryAction({ id: categoryToRemove.id });
const result = await deleteCategoryAction({ id: categoryToRemove.id });
if (result.success) {
toast.success(result.message);
return;
}
if (result.success) {
toast.success(result.message);
return;
}
toast.error(result.error);
throw new Error(result.error);
}, [categoryToRemove]);
toast.error(result.error);
throw new Error(result.error);
}, [categoryToRemove]);
const removeTitle = categoryToRemove
? `Remover categoria "${categoryToRemove.name}"?`
: "Remover categoria?";
const removeTitle = categoryToRemove
? `Remover categoria "${categoryToRemove.name}"?`
: "Remover categoria?";
return (
<>
<div className="flex w-full flex-col gap-6">
<div className="flex justify-start">
<CategoryDialog
mode="create"
defaultType={activeType}
trigger={
<Button>
<RiAddCircleLine className="size-4" />
Nova categoria
</Button>
}
/>
</div>
return (
<>
<div className="flex w-full flex-col gap-6">
<div className="flex justify-start">
<CategoryDialog
mode="create"
defaultType={activeType}
trigger={
<Button>
<RiAddCircleLine className="size-4" />
Nova categoria
</Button>
}
/>
</div>
<Tabs
value={activeType}
onValueChange={(value) => setActiveType(value as CategoryType)}
className="w-full"
>
<TabsList>
{CATEGORY_TYPES.map((type) => (
<TabsTrigger key={type} value={type}>
{CATEGORY_TYPE_LABEL[type]}
</TabsTrigger>
))}
</TabsList>
<Tabs
value={activeType}
onValueChange={(value) => setActiveType(value as CategoryType)}
className="w-full"
>
<TabsList>
{CATEGORY_TYPES.map((type) => (
<TabsTrigger key={type} value={type}>
{CATEGORY_TYPE_LABEL[type]}
</TabsTrigger>
))}
</TabsList>
{CATEGORY_TYPES.map((type) => (
<TabsContent key={type} value={type} className="mt-4">
{categoriesByType[type].length === 0 ? (
<div className="flex min-h-[280px] items-center justify-center rounded-lg border border-dashed bg-muted/10 p-10 text-center text-sm text-muted-foreground">
Ainda não categorias de{" "}
{CATEGORY_TYPE_LABEL[type].toLowerCase()}.
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{categoriesByType[type].map((category) => (
<CategoryCard
key={category.id}
category={category}
onEdit={handleEdit}
onRemove={handleRemoveRequest}
/>
))}
</div>
)}
</TabsContent>
))}
</Tabs>
</div>
{CATEGORY_TYPES.map((type) => (
<TabsContent key={type} value={type} className="mt-4">
{categoriesByType[type].length === 0 ? (
<div className="flex min-h-[280px] items-center justify-center rounded-lg border border-dashed bg-muted/10 p-10 text-center text-sm text-muted-foreground">
Ainda não categorias de{" "}
{CATEGORY_TYPE_LABEL[type].toLowerCase()}.
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{categoriesByType[type].map((category) => (
<CategoryCard
key={category.id}
category={category}
onEdit={handleEdit}
onRemove={handleRemoveRequest}
/>
))}
</div>
)}
</TabsContent>
))}
</Tabs>
</div>
<CategoryDialog
mode="update"
category={selectedCategory ?? undefined}
open={editOpen && !!selectedCategory}
onOpenChange={handleEditOpenChange}
/>
<CategoryDialog
mode="update"
category={selectedCategory ?? undefined}
open={editOpen && !!selectedCategory}
onOpenChange={handleEditOpenChange}
/>
<ConfirmActionDialog
open={removeOpen && !!categoryToRemove}
onOpenChange={handleRemoveOpenChange}
title={removeTitle}
description="Ao remover esta categoria, os lançamentos associados serão desrelacionados."
confirmLabel="Remover categoria"
pendingLabel="Removendo..."
confirmVariant="destructive"
onConfirm={handleRemoveConfirm}
/>
</>
);
<ConfirmActionDialog
open={removeOpen && !!categoryToRemove}
onOpenChange={handleRemoveOpenChange}
title={removeTitle}
description="Ao remover esta categoria, os lançamentos associados serão desrelacionados."
confirmLabel="Remover categoria"
pendingLabel="Removendo..."
confirmVariant="destructive"
onConfirm={handleRemoveConfirm}
/>
</>
);
}

View File

@@ -1,94 +1,94 @@
"use client";
import { RiDeleteBin5Line, RiMore2Fill, RiPencilLine } from "@remixicon/react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { RiDeleteBin5Line, RiMore2Fill, RiPencilLine } from "@remixicon/react";
import Link from "next/link";
import { TypeBadge } from "../type-badge";
import { CategoryIcon } from "./category-icon";
import type { Category } from "./types";
interface CategoryCardProps {
category: Category;
onEdit: (category: Category) => void;
onRemove: (category: Category) => void;
category: Category;
onEdit: (category: Category) => void;
onRemove: (category: Category) => void;
}
export function CategoryCard({
category,
onEdit,
onRemove,
category,
onEdit,
onRemove,
}: CategoryCardProps) {
// Categorias protegidas que não podem ser editadas ou removidas
const categoriasProtegidas = [
"Transferência interna",
"Saldo inicial",
"Pagamentos",
];
const isProtegida = categoriasProtegidas.includes(category.name);
const canEdit = !isProtegida;
const canRemove = !isProtegida;
// Categorias protegidas que não podem ser editadas ou removidas
const categoriasProtegidas = [
"Transferência interna",
"Saldo inicial",
"Pagamentos",
];
const isProtegida = categoriasProtegidas.includes(category.name);
const canEdit = !isProtegida;
const canRemove = !isProtegida;
return (
<Card className="group py-2">
<CardContent className="p-2">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2">
<span className="flex size-11 items-center justify-center text-primary">
<CategoryIcon name={category.icon} className="size-6" />
</span>
<div className="space-y-1">
<h3 className="text-base font-medium leading-tight">
<Link
href={`/categorias/${category.id}`}
className="underline-offset-4 hover:underline"
>
{category.name}
</Link>
</h3>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<TypeBadge type={category.type} />
</div>
</div>
</div>
return (
<Card className="group py-2">
<CardContent className="p-2">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2">
<span className="flex size-11 items-center justify-center text-primary">
<CategoryIcon name={category.icon} className="size-6" />
</span>
<div className="space-y-1">
<h3 className="text-base font-medium leading-tight">
<Link
href={`/categorias/${category.id}`}
className="underline-offset-4 hover:underline"
>
{category.name}
</Link>
</h3>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<TypeBadge type={category.type} />
</div>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="opacity-0 transition-opacity group-hover:opacity-100"
>
<RiMore2Fill className="size-4" />
<span className="sr-only">Abrir ações da categoria</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => onEdit(category)}
disabled={!canEdit}
>
<RiPencilLine className="mr-2 size-4" />
Editar
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => onRemove(category)}
disabled={!canRemove}
>
<RiDeleteBin5Line className="mr-2 size-4" />
Remover
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardContent>
</Card>
);
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="opacity-0 transition-opacity group-hover:opacity-100"
>
<RiMore2Fill className="size-4" />
<span className="sr-only">Abrir ações da categoria</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => onEdit(category)}
disabled={!canEdit}
>
<RiPencilLine className="mr-2 size-4" />
Editar
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => onRemove(category)}
disabled={!canRemove}
>
<RiDeleteBin5Line className="mr-2 size-4" />
Remover
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,149 +1,149 @@
import { type CategoryType } from "@/lib/categorias/constants";
import { RiArrowDownLine, RiArrowUpLine } from "@remixicon/react";
import type { CategoryType } from "@/lib/categorias/constants";
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
import { getIconComponent } from "@/lib/utils/icons";
import { cn } from "@/lib/utils/ui";
import { RiArrowDownLine, RiArrowUpLine } from "@remixicon/react";
import { TypeBadge } from "../type-badge";
import { Card } from "../ui/card";
const buildInitials = (value: string) => {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
return "CT";
}
if (parts.length === 1) {
const firstPart = parts[0];
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CT";
}
const firstChar = parts[0]?.[0] ?? "";
const secondChar = parts[1]?.[0] ?? "";
return `${firstChar}${secondChar}`.toUpperCase() || "CT";
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
return "CT";
}
if (parts.length === 1) {
const firstPart = parts[0];
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CT";
}
const firstChar = parts[0]?.[0] ?? "";
const secondChar = parts[1]?.[0] ?? "";
return `${firstChar}${secondChar}`.toUpperCase() || "CT";
};
type CategorySummary = {
id: string;
name: string;
icon: string | null;
type: CategoryType;
id: string;
name: string;
icon: string | null;
type: CategoryType;
};
type CategoryDetailHeaderProps = {
category: CategorySummary;
currentPeriodLabel: string;
previousPeriodLabel: string;
currentTotal: number;
previousTotal: number;
percentageChange: number | null;
transactionCount: number;
category: CategorySummary;
currentPeriodLabel: string;
previousPeriodLabel: string;
currentTotal: number;
previousTotal: number;
percentageChange: number | null;
transactionCount: number;
};
export function CategoryDetailHeader({
category,
currentPeriodLabel,
previousPeriodLabel,
currentTotal,
previousTotal,
percentageChange,
transactionCount,
category,
currentPeriodLabel,
previousPeriodLabel,
currentTotal,
previousTotal,
percentageChange,
transactionCount,
}: CategoryDetailHeaderProps) {
const IconComponent = category.icon ? getIconComponent(category.icon) : null;
const initials = buildInitials(category.name);
const IconComponent = category.icon ? getIconComponent(category.icon) : null;
const initials = buildInitials(category.name);
const isIncrease =
typeof percentageChange === "number" && percentageChange > 0;
const isDecrease =
typeof percentageChange === "number" && percentageChange < 0;
const isIncrease =
typeof percentageChange === "number" && percentageChange > 0;
const isDecrease =
typeof percentageChange === "number" && percentageChange < 0;
const variationColor =
category.type === "receita"
? isIncrease
? "text-emerald-600"
: isDecrease
? "text-rose-600"
: "text-muted-foreground"
: isIncrease
? "text-rose-600"
: isDecrease
? "text-emerald-600"
: "text-muted-foreground";
const variationColor =
category.type === "receita"
? isIncrease
? "text-emerald-600"
: isDecrease
? "text-rose-600"
: "text-muted-foreground"
: isIncrease
? "text-rose-600"
: isDecrease
? "text-emerald-600"
: "text-muted-foreground";
const variationIcon =
isIncrease || isDecrease ? (
isIncrease ? (
<RiArrowUpLine className="size-4" aria-hidden />
) : (
<RiArrowDownLine className="size-4" aria-hidden />
)
) : null;
const variationIcon =
isIncrease || isDecrease ? (
isIncrease ? (
<RiArrowUpLine className="size-4" aria-hidden />
) : (
<RiArrowDownLine className="size-4" aria-hidden />
)
) : null;
const variationLabel =
typeof percentageChange === "number"
? `${percentageChange > 0 ? "+" : ""}${Math.abs(percentageChange).toFixed(
1
)}%`
: "—";
const variationLabel =
typeof percentageChange === "number"
? `${percentageChange > 0 ? "+" : ""}${Math.abs(percentageChange).toFixed(
1,
)}%`
: "—";
return (
<Card className="px-4">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-start gap-3">
<span className="flex size-12 items-center justify-center rounded-xl bg-muted">
{IconComponent ? (
<IconComponent className="size-6" aria-hidden />
) : (
<span className="text-sm font-semibold uppercase text-muted-foreground">
{initials}
</span>
)}
</span>
<div className="space-y-2">
<h1 className="text-xl font-semibold leading-tight">
{category.name}
</h1>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<TypeBadge type={category.type} />
<span>
{transactionCount}{" "}
{transactionCount === 1 ? "lançamento" : "lançamentos"} no{" "}
período
</span>
</div>
</div>
</div>
return (
<Card className="px-4">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-start gap-3">
<span className="flex size-12 items-center justify-center rounded-xl bg-muted">
{IconComponent ? (
<IconComponent className="size-6" aria-hidden />
) : (
<span className="text-sm font-semibold uppercase text-muted-foreground">
{initials}
</span>
)}
</span>
<div className="space-y-2">
<h1 className="text-xl font-semibold leading-tight">
{category.name}
</h1>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<TypeBadge type={category.type} />
<span>
{transactionCount}{" "}
{transactionCount === 1 ? "lançamento" : "lançamentos"} no{" "}
período
</span>
</div>
</div>
</div>
<div className="grid w-full gap-4 sm:grid-cols-2 lg:w-auto lg:grid-cols-3">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total em {currentPeriodLabel}
</p>
<p className="mt-1 text-2xl font-semibold">
{currencyFormatter.format(currentTotal)}
</p>
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total em {previousPeriodLabel}
</p>
<p className="mt-1 text-lg font-medium text-muted-foreground">
{currencyFormatter.format(previousTotal)}
</p>
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Variação vs mês anterior
</p>
<div
className={cn(
"mt-1 flex items-center gap-1 text-xl font-semibold",
variationColor
)}
>
{variationIcon}
<span>{variationLabel}</span>
</div>
</div>
</div>
</div>
</Card>
);
<div className="grid w-full gap-4 sm:grid-cols-2 lg:w-auto lg:grid-cols-3">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total em {currentPeriodLabel}
</p>
<p className="mt-1 text-2xl font-semibold">
{currencyFormatter.format(currentTotal)}
</p>
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total em {previousPeriodLabel}
</p>
<p className="mt-1 text-lg font-medium text-muted-foreground">
{currencyFormatter.format(previousTotal)}
</p>
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Variação vs mês anterior
</p>
<div
className={cn(
"mt-1 flex items-center gap-1 text-xl font-semibold",
variationColor,
)}
>
{variationIcon}
<span>{variationLabel}</span>
</div>
</div>
</div>
</div>
</Card>
);
}

View File

@@ -1,189 +1,189 @@
"use client";
import {
createCategoryAction,
updateCategoryAction,
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { toast } from "sonner";
import {
createCategoryAction,
updateCategoryAction,
} from "@/app/(dashboard)/categorias/actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useControlledState } from "@/hooks/use-controlled-state";
import { useFormState } from "@/hooks/use-form-state";
import { CATEGORY_TYPES } from "@/lib/categorias/constants";
import { getDefaultIconForType } from "@/lib/categorias/icons";
import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { toast } from "sonner";
import { CategoryFormFields } from "./category-form-fields";
import type { Category, CategoryFormValues } from "./types";
interface CategoryDialogProps {
mode: "create" | "update";
trigger?: React.ReactNode;
category?: Category;
defaultType?: CategoryFormValues["type"];
open?: boolean;
onOpenChange?: (open: boolean) => void;
mode: "create" | "update";
trigger?: React.ReactNode;
category?: Category;
defaultType?: CategoryFormValues["type"];
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
const buildInitialValues = ({
category,
defaultType,
category,
defaultType,
}: {
category?: Category;
defaultType?: CategoryFormValues["type"];
category?: Category;
defaultType?: CategoryFormValues["type"];
}): CategoryFormValues => {
const initialType = category?.type ?? defaultType ?? CATEGORY_TYPES[0];
const fallbackIcon = getDefaultIconForType(initialType);
const existingIcon = category?.icon ?? "";
const icon = existingIcon || fallbackIcon;
const initialType = category?.type ?? defaultType ?? CATEGORY_TYPES[0];
const fallbackIcon = getDefaultIconForType(initialType);
const existingIcon = category?.icon ?? "";
const icon = existingIcon || fallbackIcon;
return {
name: category?.name ?? "",
type: initialType,
icon,
};
return {
name: category?.name ?? "",
type: initialType,
icon,
};
};
export function CategoryDialog({
mode,
trigger,
category,
defaultType,
open,
onOpenChange,
mode,
trigger,
category,
defaultType,
open,
onOpenChange,
}: CategoryDialogProps) {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
// Use controlled state hook for dialog open state
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange
);
// Use controlled state hook for dialog open state
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange,
);
const initialState = useMemo(
() =>
buildInitialValues({
category,
defaultType,
}),
[category, defaultType]
);
const initialState = useMemo(
() =>
buildInitialValues({
category,
defaultType,
}),
[category, defaultType],
);
// Use form state hook for form management
const { formState, updateField, setFormState } =
useFormState<CategoryFormValues>(initialState);
// Use form state hook for form management
const { formState, updateField, setFormState } =
useFormState<CategoryFormValues>(initialState);
// Reset form when dialog opens
useEffect(() => {
if (dialogOpen) {
setFormState(initialState);
setErrorMessage(null);
}
}, [dialogOpen, initialState, setFormState]);
// Reset form when dialog opens
useEffect(() => {
if (dialogOpen) {
setFormState(initialState);
setErrorMessage(null);
}
}, [dialogOpen, initialState, setFormState]);
// Clear error when dialog closes
useEffect(() => {
if (!dialogOpen) {
setErrorMessage(null);
}
}, [dialogOpen]);
// Clear error when dialog closes
useEffect(() => {
if (!dialogOpen) {
setErrorMessage(null);
}
}, [dialogOpen]);
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
if (mode === "update" && !category?.id) {
const message = "Categoria inválida.";
setErrorMessage(message);
toast.error(message);
return;
}
if (mode === "update" && !category?.id) {
const message = "Categoria inválida.";
setErrorMessage(message);
toast.error(message);
return;
}
const payload = {
name: formState.name.trim(),
type: formState.type,
icon: formState.icon.trim(),
};
const payload = {
name: formState.name.trim(),
type: formState.type,
icon: formState.icon.trim(),
};
startTransition(async () => {
const result =
mode === "create"
? await createCategoryAction(payload)
: await updateCategoryAction({
id: category?.id ?? "",
...payload,
});
startTransition(async () => {
const result =
mode === "create"
? await createCategoryAction(payload)
: await updateCategoryAction({
id: category?.id ?? "",
...payload,
});
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
return;
}
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
});
},
[category?.id, formState, initialState, mode, setDialogOpen, setFormState]
);
setErrorMessage(result.error);
toast.error(result.error);
});
},
[category?.id, formState, initialState, mode, setDialogOpen, setFormState],
);
const title = mode === "create" ? "Nova categoria" : "Editar categoria";
const description =
mode === "create"
? "Crie uma categoria para organizar seus lançamentos."
: "Atualize os detalhes da categoria selecionada.";
const submitLabel =
mode === "create" ? "Salvar categoria" : "Atualizar categoria";
const title = mode === "create" ? "Nova categoria" : "Editar categoria";
const description =
mode === "create"
? "Crie uma categoria para organizar seus lançamentos."
: "Atualize os detalhes da categoria selecionada.";
const submitLabel =
mode === "create" ? "Salvar categoria" : "Atualizar categoria";
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<CategoryFormFields values={formState} onChange={updateField} />
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<CategoryFormFields values={formState} onChange={updateField} />
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
<DialogFooter className="gap-3">
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Salvando..." : submitLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
<DialogFooter className="gap-3">
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Salvando..." : submitLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,128 +1,128 @@
"use client";
import { RiMoreLine } from "@remixicon/react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
CATEGORY_TYPE_LABEL,
CATEGORY_TYPES,
CATEGORY_TYPE_LABEL,
CATEGORY_TYPES,
} from "@/lib/categorias/constants";
import { getCategoryIconOptions } from "@/lib/categorias/icons";
import { cn } from "@/lib/utils/ui";
import { RiMoreLine } from "@remixicon/react";
import { useState } from "react";
import { CategoryIcon } from "./category-icon";
import { TypeSelectContent } from "./category-select-items";
import type { CategoryFormValues } from "./types";
interface CategoryFormFieldsProps {
values: CategoryFormValues;
onChange: (field: keyof CategoryFormValues, value: string) => void;
values: CategoryFormValues;
onChange: (field: keyof CategoryFormValues, value: string) => void;
}
export function CategoryFormFields({
values,
onChange,
values,
onChange,
}: CategoryFormFieldsProps) {
const [popoverOpen, setPopoverOpen] = useState(false);
const iconOptions = getCategoryIconOptions();
const [popoverOpen, setPopoverOpen] = useState(false);
const iconOptions = getCategoryIconOptions();
const handleIconSelect = (icon: string) => {
onChange("icon", icon);
setPopoverOpen(false);
};
const handleIconSelect = (icon: string) => {
onChange("icon", icon);
setPopoverOpen(false);
};
return (
<div className="grid grid-cols-1 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="category-name">Nome</Label>
<Input
id="category-name"
value={values.name}
onChange={(event) => onChange("name", event.target.value)}
placeholder="Ex.: Alimentação"
required
/>
</div>
return (
<div className="grid grid-cols-1 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="category-name">Nome</Label>
<Input
id="category-name"
value={values.name}
onChange={(event) => onChange("name", event.target.value)}
placeholder="Ex.: Alimentação"
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="category-type">Tipo da categoria</Label>
<Select
value={values.type}
onValueChange={(value) => onChange("type", value)}
>
<SelectTrigger id="category-type" className="w-full">
<SelectValue placeholder="Selecione o tipo">
{values.type && (
<TypeSelectContent label={CATEGORY_TYPE_LABEL[values.type]} />
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{CATEGORY_TYPES.map((type) => (
<SelectItem key={type} value={type}>
<TypeSelectContent label={CATEGORY_TYPE_LABEL[type]} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="category-type">Tipo da categoria</Label>
<Select
value={values.type}
onValueChange={(value) => onChange("type", value)}
>
<SelectTrigger id="category-type" className="w-full">
<SelectValue placeholder="Selecione o tipo">
{values.type && (
<TypeSelectContent label={CATEGORY_TYPE_LABEL[values.type]} />
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{CATEGORY_TYPES.map((type) => (
<SelectItem key={type} value={type}>
<TypeSelectContent label={CATEGORY_TYPE_LABEL[type]} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Ícone</Label>
<div className="flex items-center gap-3">
<div className="flex size-12 items-center justify-center rounded-lg border bg-muted/30 text-primary">
{values.icon ? (
<CategoryIcon name={values.icon} className="size-7" />
) : (
<RiMoreLine className="size-6 text-muted-foreground" />
)}
</div>
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" className="flex-1">
Selecionar ícone
</Button>
</PopoverTrigger>
<PopoverContent className="w-[480px] p-3" align="start">
<div className="grid max-h-96 grid-cols-8 gap-2 overflow-y-auto">
{iconOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => handleIconSelect(option.value)}
className={cn(
"flex size-12 items-center justify-center rounded-lg border transition-all hover:border-primary hover:bg-primary/5",
values.icon === option.value
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-primary"
)}
title={option.label}
>
<CategoryIcon name={option.value} className="size-6" />
</button>
))}
</div>
</PopoverContent>
</Popover>
</div>
<p className="text-xs text-muted-foreground">
Escolha um ícone que represente melhor esta categoria.
</p>
</div>
</div>
);
<div className="flex flex-col gap-2">
<Label>Ícone</Label>
<div className="flex items-center gap-3">
<div className="flex size-12 items-center justify-center rounded-lg border bg-muted/30 text-primary">
{values.icon ? (
<CategoryIcon name={values.icon} className="size-7" />
) : (
<RiMoreLine className="size-6 text-muted-foreground" />
)}
</div>
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" className="flex-1">
Selecionar ícone
</Button>
</PopoverTrigger>
<PopoverContent className="w-[480px] p-3" align="start">
<div className="grid max-h-96 grid-cols-8 gap-2 overflow-y-auto">
{iconOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => handleIconSelect(option.value)}
className={cn(
"flex size-12 items-center justify-center rounded-lg border transition-all hover:border-primary hover:bg-primary/5",
values.icon === option.value
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-primary",
)}
title={option.label}
>
<CategoryIcon name={option.value} className="size-6" />
</button>
))}
</div>
</PopoverContent>
</Popover>
</div>
<p className="text-xs text-muted-foreground">
Escolha um ícone que represente melhor esta categoria.
</p>
</div>
</div>
);
}

View File

@@ -5,24 +5,24 @@ import * as RemixIcons from "@remixicon/react";
import { cn } from "@/lib/utils/ui";
const ICONS = RemixIcons as Record<string, RemixiconComponentType | undefined>;
const FALLBACK_ICON = ICONS["RiPriceTag3Line"];
const FALLBACK_ICON = ICONS.RiPriceTag3Line;
interface CategoryIconProps {
name?: string | null;
className?: string;
name?: string | null;
className?: string;
}
export function CategoryIcon({ name, className }: CategoryIconProps) {
const IconComponent =
(name ? ICONS[name] : undefined) ?? FALLBACK_ICON ?? null;
const IconComponent =
(name ? ICONS[name] : undefined) ?? FALLBACK_ICON ?? null;
if (!IconComponent) {
return (
<span className={cn("text-xs text-muted-foreground", className)}>
{name ?? "Categoria"}
</span>
);
}
if (!IconComponent) {
return (
<span className={cn("text-xs text-muted-foreground", className)}>
{name ?? "Categoria"}
</span>
);
}
return <IconComponent className={cn("size-5", className)} aria-hidden />;
return <IconComponent className={cn("size-5", className)} aria-hidden />;
}

View File

@@ -3,18 +3,18 @@
import DotIcon from "@/components/dot-icon";
export function TypeSelectContent({ label }: { label: string }) {
const isReceita = label === "Receita";
const isReceita = label === "Receita";
return (
<span className="flex items-center gap-2">
<DotIcon
bg_dot={
isReceita
? "bg-emerald-600 dark:bg-emerald-300"
: "bg-rose-600 dark:bg-rose-300"
}
/>
<span>{label}</span>
</span>
);
return (
<span className="flex items-center gap-2">
<DotIcon
bg_dot={
isReceita
? "bg-emerald-600 dark:bg-emerald-300"
: "bg-rose-600 dark:bg-rose-300"
}
/>
<span>{label}</span>
</span>
);
}

View File

@@ -1,18 +1,18 @@
export {
CATEGORY_TYPES,
CATEGORY_TYPE_LABEL,
} from "@/lib/categorias/constants";
export type { CategoryType } from "@/lib/categorias/constants";
export {
CATEGORY_TYPE_LABEL,
CATEGORY_TYPES,
} from "@/lib/categorias/constants";
export type Category = {
id: string;
name: string;
type: CategoryType;
icon: string | null;
id: string;
name: string;
type: CategoryType;
icon: string | null;
};
export type CategoryFormValues = {
name: string;
type: CategoryType;
icon: string;
name: string;
type: CategoryType;
icon: string;
};

View File

@@ -1,116 +1,116 @@
"use client";
import type { VariantProps } from "class-variance-authority";
import { useCallback, useMemo, useState, useTransition } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils/ui";
import { type VariantProps } from "class-variance-authority";
import { useCallback, useMemo, useState, useTransition } from "react";
interface ConfirmActionDialogProps {
trigger?: React.ReactNode;
title: string;
description?: string;
confirmLabel?: string;
cancelLabel?: string;
pendingLabel?: string;
confirmVariant?: VariantProps<typeof buttonVariants>["variant"];
open?: boolean;
onOpenChange?: (open: boolean) => void;
onConfirm?: () => Promise<void> | void;
disabled?: boolean;
className?: string;
trigger?: React.ReactNode;
title: string;
description?: string;
confirmLabel?: string;
cancelLabel?: string;
pendingLabel?: string;
confirmVariant?: VariantProps<typeof buttonVariants>["variant"];
open?: boolean;
onOpenChange?: (open: boolean) => void;
onConfirm?: () => Promise<void> | void;
disabled?: boolean;
className?: string;
}
export function ConfirmActionDialog({
trigger,
title,
description,
confirmLabel = "Confirmar",
cancelLabel = "Cancelar",
pendingLabel,
confirmVariant = "default",
open,
onOpenChange,
onConfirm,
disabled = false,
className,
trigger,
title,
description,
confirmLabel = "Confirmar",
cancelLabel = "Cancelar",
pendingLabel,
confirmVariant = "default",
open,
onOpenChange,
onConfirm,
disabled = false,
className,
}: ConfirmActionDialogProps) {
const [internalOpen, setInternalOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const dialogOpen = open ?? internalOpen;
const [internalOpen, setInternalOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const dialogOpen = open ?? internalOpen;
const setDialogOpen = useCallback(
(value: boolean) => {
if (open === undefined) {
setInternalOpen(value);
}
onOpenChange?.(value);
},
[onOpenChange, open]
);
const setDialogOpen = useCallback(
(value: boolean) => {
if (open === undefined) {
setInternalOpen(value);
}
onOpenChange?.(value);
},
[onOpenChange, open],
);
const resolvedPendingLabel = useMemo(
() => pendingLabel ?? confirmLabel,
[pendingLabel, confirmLabel]
);
const resolvedPendingLabel = useMemo(
() => pendingLabel ?? confirmLabel,
[pendingLabel, confirmLabel],
);
const handleConfirm = useCallback(() => {
if (!onConfirm) {
setDialogOpen(false);
return;
}
const handleConfirm = useCallback(() => {
if (!onConfirm) {
setDialogOpen(false);
return;
}
startTransition(async () => {
try {
await onConfirm();
setDialogOpen(false);
} catch {
// Mantém o diálogo aberto para que o chamador trate o erro.
}
});
}, [onConfirm, setDialogOpen]);
startTransition(async () => {
try {
await onConfirm();
setDialogOpen(false);
} catch {
// Mantém o diálogo aberto para que o chamador trate o erro.
}
});
}, [onConfirm, setDialogOpen]);
return (
<AlertDialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? (
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
) : null}
<AlertDialogContent className={className}>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
{description ? (
<AlertDialogDescription>{description}</AlertDialogDescription>
) : null}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
disabled={isPending || disabled}
className="w-full sm:w-auto"
>
{cancelLabel}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
disabled={isPending || disabled}
className={cn(
buttonVariants({ variant: confirmVariant }),
"w-full sm:w-auto"
)}
>
{isPending ? resolvedPendingLabel : confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
return (
<AlertDialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? (
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
) : null}
<AlertDialogContent className={className}>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
{description ? (
<AlertDialogDescription>{description}</AlertDialogDescription>
) : null}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
disabled={isPending || disabled}
className="w-full sm:w-auto"
>
{cancelLabel}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
disabled={isPending || disabled}
className={cn(
buttonVariants({ variant: confirmVariant }),
"w-full sm:w-auto",
)}
>
{isPending ? resolvedPendingLabel : confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -1,148 +1,148 @@
"use client";
import { cn } from "@/lib/utils/ui";
import {
RiArrowLeftRightLine,
RiDeleteBin5Line,
RiFileList2Line,
RiPencilLine,
RiInformationLine,
RiArrowLeftRightLine,
RiDeleteBin5Line,
RiFileList2Line,
RiInformationLine,
RiPencilLine,
} from "@remixicon/react";
import type React from "react";
import { cn } from "@/lib/utils/ui";
import MoneyValues from "../money-values";
import { Card, CardContent, CardFooter } from "../ui/card";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
interface AccountCardProps {
accountName: string;
accountType: string;
balance: number;
status?: string;
icon?: React.ReactNode;
excludeFromBalance?: boolean;
excludeInitialBalanceFromIncome?: boolean;
onViewStatement?: () => void;
onEdit?: () => void;
onRemove?: () => void;
onTransfer?: () => void;
className?: string;
accountName: string;
accountType: string;
balance: number;
status?: string;
icon?: React.ReactNode;
excludeFromBalance?: boolean;
excludeInitialBalanceFromIncome?: boolean;
onViewStatement?: () => void;
onEdit?: () => void;
onRemove?: () => void;
onTransfer?: () => void;
className?: string;
}
export function AccountCard({
accountName,
accountType,
balance,
status,
icon,
excludeFromBalance,
excludeInitialBalanceFromIncome,
onViewStatement,
onEdit,
onRemove,
onTransfer,
className,
accountName,
accountType,
balance,
status,
icon,
excludeFromBalance,
excludeInitialBalanceFromIncome,
onViewStatement,
onEdit,
onRemove,
onTransfer,
className,
}: AccountCardProps) {
const isInactive = status?.toLowerCase() === "inativa";
const isInactive = status?.toLowerCase() === "inativa";
const actions = [
{
label: "editar",
icon: <RiPencilLine className="size-4" aria-hidden />,
onClick: onEdit,
variant: "default" as const,
},
{
label: "extrato",
icon: <RiFileList2Line className="size-4" aria-hidden />,
onClick: onViewStatement,
variant: "default" as const,
},
{
label: "transferir",
icon: <RiArrowLeftRightLine className="size-4" aria-hidden />,
onClick: onTransfer,
variant: "default" as const,
},
{
label: "remover",
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
onClick: onRemove,
variant: "destructive" as const,
},
].filter((action) => typeof action.onClick === "function");
const actions = [
{
label: "editar",
icon: <RiPencilLine className="size-4" aria-hidden />,
onClick: onEdit,
variant: "default" as const,
},
{
label: "extrato",
icon: <RiFileList2Line className="size-4" aria-hidden />,
onClick: onViewStatement,
variant: "default" as const,
},
{
label: "transferir",
icon: <RiArrowLeftRightLine className="size-4" aria-hidden />,
onClick: onTransfer,
variant: "default" as const,
},
{
label: "remover",
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
onClick: onRemove,
variant: "destructive" as const,
},
].filter((action) => typeof action.onClick === "function");
return (
<Card className={cn("h-full w-96 gap-0", className)}>
<CardContent className="flex flex-1 flex-col gap-4">
<div className="flex items-center gap-2">
{icon ? (
<div
className={cn(
"flex items-center justify-center",
isInactive && "[&_img]:grayscale [&_img]:opacity-40"
)}
>
{icon}
</div>
) : null}
<h2 className="text-lg font-semibold text-foreground">
{accountName}
</h2>
return (
<Card className={cn("h-full w-96 gap-0", className)}>
<CardContent className="flex flex-1 flex-col gap-4">
<div className="flex items-center gap-2">
{icon ? (
<div
className={cn(
"flex items-center justify-center",
isInactive && "[&_img]:grayscale [&_img]:opacity-40",
)}
>
{icon}
</div>
) : null}
<h2 className="text-lg font-semibold text-foreground">
{accountName}
</h2>
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RiInformationLine className="size-5 text-muted-foreground hover:text-foreground transition-colors cursor-help" />
</div>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<div className="space-y-1">
{excludeFromBalance && (
<p className="text-xs">
<strong>Desconsiderado do saldo total:</strong> Esta conta
não é incluída no cálculo do saldo total geral.
</p>
)}
{excludeInitialBalanceFromIncome && (
<p className="text-xs">
<strong>
Saldo inicial desconsiderado das receitas:
</strong>{" "}
O saldo inicial desta conta não é contabilizado como
receita nas métricas.
</p>
)}
</div>
</TooltipContent>
</Tooltip>
)}
</div>
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RiInformationLine className="size-5 text-muted-foreground hover:text-foreground transition-colors cursor-help" />
</div>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<div className="space-y-1">
{excludeFromBalance && (
<p className="text-xs">
<strong>Desconsiderado do saldo total:</strong> Esta conta
não é incluída no cálculo do saldo total geral.
</p>
)}
{excludeInitialBalanceFromIncome && (
<p className="text-xs">
<strong>
Saldo inicial desconsiderado das receitas:
</strong>{" "}
O saldo inicial desta conta não é contabilizado como
receita nas métricas.
</p>
)}
</div>
</TooltipContent>
</Tooltip>
)}
</div>
<div className="space-y-2">
<MoneyValues amount={balance} className="text-3xl" />
<p className="text-sm text-muted-foreground">{accountType}</p>
</div>
</CardContent>
<div className="space-y-2">
<MoneyValues amount={balance} className="text-3xl" />
<p className="text-sm text-muted-foreground">{accountType}</p>
</div>
</CardContent>
{actions.length > 0 ? (
<CardFooter className="flex flex-wrap gap-3 px-6 pt-6 text-sm">
{actions.map(({ label, icon, onClick, variant }) => (
<button
key={label}
type="button"
onClick={onClick}
className={cn(
"flex items-center gap-1 font-medium transition-opacity hover:opacity-80",
variant === "destructive" ? "text-destructive" : "text-primary"
)}
aria-label={`${label} conta`}
>
{icon}
{label}
</button>
))}
</CardFooter>
) : null}
</Card>
);
{actions.length > 0 ? (
<CardFooter className="flex flex-wrap gap-3 px-6 pt-6 text-sm">
{actions.map(({ label, icon, onClick, variant }) => (
<button
key={label}
type="button"
onClick={onClick}
className={cn(
"flex items-center gap-1 font-medium transition-opacity hover:opacity-80",
variant === "destructive" ? "text-destructive" : "text-primary",
)}
aria-label={`${label} conta`}
>
{icon}
{label}
</button>
))}
</CardFooter>
) : null}
</Card>
);
}

View File

@@ -1,29 +1,28 @@
"use client";
import {
createAccountAction,
updateAccountAction,
} from "@/app/(dashboard)/contas/actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { toast } from "sonner";
import {
createAccountAction,
updateAccountAction,
} from "@/app/(dashboard)/contas/actions";
import { LogoPickerDialog, LogoPickerTrigger } from "@/components/logo-picker";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useControlledState } from "@/hooks/use-controlled-state";
import { useFormState } from "@/hooks/use-form-state";
import { useLogoSelection } from "@/hooks/use-logo-selection";
@@ -34,237 +33,237 @@ import { AccountFormFields } from "./account-form-fields";
import type { Account, AccountFormValues } from "./types";
const DEFAULT_ACCOUNT_TYPES = [
"Conta Corrente",
"Conta Poupança",
"Carteira Digital",
"Conta Investimento",
"Pré-Pago | VR/VA",
"Conta Corrente",
"Conta Poupança",
"Carteira Digital",
"Conta Investimento",
"Pré-Pago | VR/VA",
] as const;
const DEFAULT_ACCOUNT_STATUS = ["Ativa", "Inativa"] as const;
interface AccountDialogProps {
mode: "create" | "update";
trigger?: React.ReactNode;
logoOptions: string[];
account?: Account;
open?: boolean;
onOpenChange?: (open: boolean) => void;
mode: "create" | "update";
trigger?: React.ReactNode;
logoOptions: string[];
account?: Account;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
const buildInitialValues = ({
account,
logoOptions,
accountTypes,
accountStatuses,
account,
logoOptions,
accountTypes,
accountStatuses,
}: {
account?: Account;
logoOptions: string[];
accountTypes: string[];
accountStatuses: string[];
account?: Account;
logoOptions: string[];
accountTypes: string[];
accountStatuses: string[];
}): AccountFormValues => {
const fallbackLogo = logoOptions[0] ?? "";
const selectedLogo = normalizeLogo(account?.logo) || fallbackLogo;
const derivedName = deriveNameFromLogo(selectedLogo);
const fallbackLogo = logoOptions[0] ?? "";
const selectedLogo = normalizeLogo(account?.logo) || fallbackLogo;
const derivedName = deriveNameFromLogo(selectedLogo);
return {
name: account?.name ?? derivedName,
accountType: account?.accountType ?? accountTypes[0] ?? "",
status: account?.status ?? accountStatuses[0] ?? "",
note: account?.note ?? "",
logo: selectedLogo,
initialBalance: formatInitialBalanceInput(account?.initialBalance ?? 0),
excludeFromBalance: account?.excludeFromBalance ?? false,
excludeInitialBalanceFromIncome:
account?.excludeInitialBalanceFromIncome ?? false,
};
return {
name: account?.name ?? derivedName,
accountType: account?.accountType ?? accountTypes[0] ?? "",
status: account?.status ?? accountStatuses[0] ?? "",
note: account?.note ?? "",
logo: selectedLogo,
initialBalance: formatInitialBalanceInput(account?.initialBalance ?? 0),
excludeFromBalance: account?.excludeFromBalance ?? false,
excludeInitialBalanceFromIncome:
account?.excludeInitialBalanceFromIncome ?? false,
};
};
export function AccountDialog({
mode,
trigger,
logoOptions,
account,
open,
onOpenChange,
mode,
trigger,
logoOptions,
account,
open,
onOpenChange,
}: AccountDialogProps) {
const [logoDialogOpen, setLogoDialogOpen] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [logoDialogOpen, setLogoDialogOpen] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
// Use controlled state hook for dialog open state
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange
);
// Use controlled state hook for dialog open state
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange,
);
const accountTypes = useMemo(() => {
const values = new Set<string>(DEFAULT_ACCOUNT_TYPES);
if (account?.accountType) {
values.add(account.accountType);
}
return Array.from(values);
}, [account?.accountType]);
const accountTypes = useMemo(() => {
const values = new Set<string>(DEFAULT_ACCOUNT_TYPES);
if (account?.accountType) {
values.add(account.accountType);
}
return Array.from(values);
}, [account?.accountType]);
const accountStatuses = useMemo(() => {
const values = new Set<string>(DEFAULT_ACCOUNT_STATUS);
if (account?.status) {
values.add(account.status);
}
return Array.from(values);
}, [account?.status]);
const accountStatuses = useMemo(() => {
const values = new Set<string>(DEFAULT_ACCOUNT_STATUS);
if (account?.status) {
values.add(account.status);
}
return Array.from(values);
}, [account?.status]);
const initialState = useMemo(
() =>
buildInitialValues({
account,
logoOptions,
accountTypes,
accountStatuses,
}),
[account, logoOptions, accountTypes, accountStatuses]
);
const initialState = useMemo(
() =>
buildInitialValues({
account,
logoOptions,
accountTypes,
accountStatuses,
}),
[account, logoOptions, accountTypes, accountStatuses],
);
// Use form state hook for form management
const { formState, updateField, updateFields, setFormState } =
useFormState<AccountFormValues>(initialState);
// Use form state hook for form management
const { formState, updateField, updateFields, setFormState } =
useFormState<AccountFormValues>(initialState);
// Reset form when dialog opens
useEffect(() => {
if (dialogOpen) {
setFormState(initialState);
setErrorMessage(null);
}
}, [dialogOpen, initialState, setFormState]);
// Reset form when dialog opens
useEffect(() => {
if (dialogOpen) {
setFormState(initialState);
setErrorMessage(null);
}
}, [dialogOpen, initialState, setFormState]);
// Close logo dialog when main dialog closes
useEffect(() => {
if (!dialogOpen) {
setErrorMessage(null);
setLogoDialogOpen(false);
}
}, [dialogOpen]);
// Close logo dialog when main dialog closes
useEffect(() => {
if (!dialogOpen) {
setErrorMessage(null);
setLogoDialogOpen(false);
}
}, [dialogOpen]);
// Use logo selection hook
const handleLogoSelection = useLogoSelection({
mode,
currentLogo: formState.logo,
currentName: formState.name,
onUpdate: (updates) => {
updateFields(updates);
setLogoDialogOpen(false);
},
});
// Use logo selection hook
const handleLogoSelection = useLogoSelection({
mode,
currentLogo: formState.logo,
currentName: formState.name,
onUpdate: (updates) => {
updateFields(updates);
setLogoDialogOpen(false);
},
});
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
if (mode === "update" && !account?.id) {
const message = "Conta inválida.";
setErrorMessage(message);
toast.error(message);
return;
}
if (mode === "update" && !account?.id) {
const message = "Conta inválida.";
setErrorMessage(message);
toast.error(message);
return;
}
const payload = { ...formState };
const payload = { ...formState };
if (!payload.logo) {
setErrorMessage("Selecione um logo.");
return;
}
if (!payload.logo) {
setErrorMessage("Selecione um logo.");
return;
}
startTransition(async () => {
const result =
mode === "create"
? await createAccountAction(payload)
: await updateAccountAction({
id: account?.id ?? "",
...payload,
});
startTransition(async () => {
const result =
mode === "create"
? await createAccountAction(payload)
: await updateAccountAction({
id: account?.id ?? "",
...payload,
});
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
return;
}
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
});
},
[account?.id, formState, initialState, mode, setDialogOpen, setFormState]
);
setErrorMessage(result.error);
toast.error(result.error);
});
},
[account?.id, formState, initialState, mode, setDialogOpen, setFormState],
);
const title = mode === "create" ? "Nova conta" : "Editar conta";
const description =
mode === "create"
? "Cadastre uma nova conta para organizar seus lançamentos."
: "Atualize as informações da conta selecionada.";
const submitLabel = mode === "create" ? "Salvar conta" : "Atualizar conta";
const title = mode === "create" ? "Nova conta" : "Editar conta";
const description =
mode === "create"
? "Cadastre uma nova conta para organizar seus lançamentos."
: "Atualize as informações da conta selecionada.";
const submitLabel = mode === "create" ? "Salvar conta" : "Atualizar conta";
return (
<>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
return (
<>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<LogoPickerTrigger
selectedLogo={formState.logo}
disabled={logoOptions.length === 0}
onOpen={() => {
if (logoOptions.length > 0) {
setLogoDialogOpen(true);
}
}}
/>
</div>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<LogoPickerTrigger
selectedLogo={formState.logo}
disabled={logoOptions.length === 0}
onOpen={() => {
if (logoOptions.length > 0) {
setLogoDialogOpen(true);
}
}}
/>
</div>
<AccountFormFields
values={formState}
accountTypes={accountTypes}
accountStatuses={accountStatuses}
onChange={updateField}
showInitialBalance={mode === "create"}
/>
<AccountFormFields
values={formState}
accountTypes={accountTypes}
accountStatuses={accountStatuses}
onChange={updateField}
showInitialBalance={mode === "create"}
/>
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
<DialogFooter className="gap-3">
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Salvando..." : submitLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<DialogFooter className="gap-3">
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Salvando..." : submitLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<LogoPickerDialog
open={logoDialogOpen}
logos={logoOptions}
value={formState.logo}
onOpenChange={setLogoDialogOpen}
onSelect={handleLogoSelection}
/>
</>
);
<LogoPickerDialog
open={logoDialogOpen}
logos={logoOptions}
value={formState.logo}
onOpenChange={setLogoDialogOpen}
onSelect={handleLogoSelection}
/>
</>
);
}

View File

@@ -1,145 +1,154 @@
"use client";
import { Checkbox } from "@/components/ui/checkbox";
import { CurrencyInput } from "@/components/ui/currency-input";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { StatusSelectContent } from "./account-select-items";
import type { AccountFormValues } from "./types";
interface AccountFormFieldsProps {
values: AccountFormValues;
accountTypes: string[];
accountStatuses: string[];
onChange: (field: keyof AccountFormValues, value: string) => void;
showInitialBalance?: boolean;
values: AccountFormValues;
accountTypes: string[];
accountStatuses: string[];
onChange: (field: keyof AccountFormValues, value: string) => void;
showInitialBalance?: boolean;
}
export function AccountFormFields({
values,
accountTypes,
accountStatuses,
onChange,
showInitialBalance = true,
values,
accountTypes,
accountStatuses,
onChange,
showInitialBalance = true,
}: AccountFormFieldsProps) {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="account-name">Nome</Label>
<Input
id="account-name"
value={values.name}
onChange={(event) => onChange("name", event.target.value)}
placeholder="Ex.: Nubank"
required
/>
</div>
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="account-name">Nome</Label>
<Input
id="account-name"
value={values.name}
onChange={(event) => onChange("name", event.target.value)}
placeholder="Ex.: Nubank"
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="account-type">Tipo de conta</Label>
<Select
value={values.accountType}
onValueChange={(value) => onChange("accountType", value)}
>
<SelectTrigger id="account-type" className="w-full">
<SelectValue placeholder="Selecione o tipo" />
</SelectTrigger>
<SelectContent>
{accountTypes.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="account-type">Tipo de conta</Label>
<Select
value={values.accountType}
onValueChange={(value) => onChange("accountType", value)}
>
<SelectTrigger id="account-type" className="w-full">
<SelectValue placeholder="Selecione o tipo" />
</SelectTrigger>
<SelectContent>
{accountTypes.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="account-status">Status</Label>
<Select
value={values.status}
onValueChange={(value) => onChange("status", value)}
>
<SelectTrigger id="account-status" className="w-full">
<SelectValue placeholder="Selecione o status">
{values.status && <StatusSelectContent label={values.status} />}
</SelectValue>
</SelectTrigger>
<SelectContent>
{accountStatuses.map((status) => (
<SelectItem key={status} value={status}>
<StatusSelectContent label={status} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="account-status">Status</Label>
<Select
value={values.status}
onValueChange={(value) => onChange("status", value)}
>
<SelectTrigger id="account-status" className="w-full">
<SelectValue placeholder="Selecione o status">
{values.status && <StatusSelectContent label={values.status} />}
</SelectValue>
</SelectTrigger>
<SelectContent>
{accountStatuses.map((status) => (
<SelectItem key={status} value={status}>
<StatusSelectContent label={status} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{showInitialBalance ? (
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="account-initial-balance">Saldo inicial</Label>
<CurrencyInput
id="account-initial-balance"
value={values.initialBalance}
onValueChange={(value) => onChange("initialBalance", value)}
placeholder="R$ 0,00"
/>
</div>
) : null}
{showInitialBalance ? (
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="account-initial-balance">Saldo inicial</Label>
<CurrencyInput
id="account-initial-balance"
value={values.initialBalance}
onValueChange={(value) => onChange("initialBalance", value)}
placeholder="R$ 0,00"
/>
</div>
) : null}
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="account-note">Anotação</Label>
<Textarea
id="account-note"
value={values.note}
onChange={(event) => onChange("note", event.target.value)}
placeholder="Informações adicionais sobre a conta"
/>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="account-note">Anotação</Label>
<Textarea
id="account-note"
value={values.note}
onChange={(event) => onChange("note", event.target.value)}
placeholder="Informações adicionais sobre a conta"
/>
</div>
<div className="flex flex-col gap-3 sm:col-span-2">
<div className="flex items-center gap-2">
<Checkbox
id="exclude-from-balance"
checked={values.excludeFromBalance === true || values.excludeFromBalance === "true"}
onCheckedChange={(checked) =>
onChange("excludeFromBalance", !!checked ? "true" : "false")
}
/>
<Label
htmlFor="exclude-from-balance"
className="cursor-pointer text-sm font-normal leading-tight"
>
Desconsiderar do saldo total (útil para contas de investimento ou
reserva)
</Label>
</div>
<div className="flex flex-col gap-3 sm:col-span-2">
<div className="flex items-center gap-2">
<Checkbox
id="exclude-from-balance"
checked={
values.excludeFromBalance === true ||
values.excludeFromBalance === "true"
}
onCheckedChange={(checked) =>
onChange("excludeFromBalance", checked ? "true" : "false")
}
/>
<Label
htmlFor="exclude-from-balance"
className="cursor-pointer text-sm font-normal leading-tight"
>
Desconsiderar do saldo total (útil para contas de investimento ou
reserva)
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="exclude-initial-balance-from-income"
checked={values.excludeInitialBalanceFromIncome === true || values.excludeInitialBalanceFromIncome === "true"}
onCheckedChange={(checked) =>
onChange("excludeInitialBalanceFromIncome", !!checked ? "true" : "false")
}
/>
<Label
htmlFor="exclude-initial-balance-from-income"
className="cursor-pointer text-sm font-normal leading-tight"
>
Desconsiderar o saldo inicial ao calcular o total de receitas
</Label>
</div>
</div>
</div>
);
<div className="flex items-center gap-2">
<Checkbox
id="exclude-initial-balance-from-income"
checked={
values.excludeInitialBalanceFromIncome === true ||
values.excludeInitialBalanceFromIncome === "true"
}
onCheckedChange={(checked) =>
onChange(
"excludeInitialBalanceFromIncome",
checked ? "true" : "false",
)
}
/>
<Label
htmlFor="exclude-initial-balance-from-income"
className="cursor-pointer text-sm font-normal leading-tight"
>
Desconsiderar o saldo inicial ao calcular o total de receitas
</Label>
</div>
</div>
</div>
);
}

View File

@@ -3,18 +3,18 @@
import DotIcon from "@/components/dot-icon";
export function StatusSelectContent({ label }: { label: string }) {
const isActive = label === "Ativa";
const isActive = label === "Ativa";
return (
<span className="flex items-center gap-2">
<DotIcon
bg_dot={
isActive
? "bg-emerald-600 dark:bg-emerald-300"
: "bg-slate-400 dark:bg-slate-500"
}
/>
<span>{label}</span>
</span>
);
return (
<span className="flex items-center gap-2">
<DotIcon
bg_dot={
isActive
? "bg-emerald-600 dark:bg-emerald-300"
: "bg-slate-400 dark:bg-slate-500"
}
/>
<span>{label}</span>
</span>
);
}

View File

@@ -1,217 +1,217 @@
"use client";
import { RiInformationLine } from "@remixicon/react";
import Image from "next/image";
import { type ReactNode, useMemo } from "react";
import MoneyValues from "@/components/money-values";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils/ui";
import { RiInformationLine } from "@remixicon/react";
import Image from "next/image";
import { useMemo, type ReactNode } from "react";
type DetailValue = string | number | ReactNode;
type AccountStatementCardProps = {
accountName: string;
accountType: string;
status: string;
periodLabel: string;
currentBalance: number;
openingBalance: number;
totalIncomes: number;
totalExpenses: number;
logo?: string | null;
actions?: React.ReactNode;
accountName: string;
accountType: string;
status: string;
periodLabel: string;
currentBalance: number;
openingBalance: number;
totalIncomes: number;
totalExpenses: number;
logo?: string | null;
actions?: React.ReactNode;
};
const resolveLogoPath = (logo?: string | null) => {
if (!logo) return null;
if (
logo.startsWith("http://") ||
logo.startsWith("https://") ||
logo.startsWith("data:")
) {
return logo;
}
if (!logo) return null;
if (
logo.startsWith("http://") ||
logo.startsWith("https://") ||
logo.startsWith("data:")
) {
return logo;
}
return logo.startsWith("/") ? logo : `/logos/${logo}`;
return logo.startsWith("/") ? logo : `/logos/${logo}`;
};
const getAccountStatusBadgeVariant = (
status: string
status: string,
): "success" | "secondary" => {
const normalizedStatus = status.toLowerCase();
if (normalizedStatus === "ativa") {
return "success";
}
return "outline";
const normalizedStatus = status.toLowerCase();
if (normalizedStatus === "ativa") {
return "success";
}
return "outline";
};
export function AccountStatementCard({
accountName,
accountType,
status,
periodLabel,
currentBalance,
openingBalance,
totalIncomes,
totalExpenses,
logo,
actions,
accountName,
accountType,
status,
periodLabel,
currentBalance,
openingBalance,
totalIncomes,
totalExpenses,
logo,
actions,
}: AccountStatementCardProps) {
const logoPath = useMemo(() => resolveLogoPath(logo), [logo]);
const logoPath = useMemo(() => resolveLogoPath(logo), [logo]);
const formatCurrency = (value: number) =>
value.toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
});
const formatCurrency = (value: number) =>
value.toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
});
return (
<Card className="border">
<CardHeader className="flex flex-col gap-3">
<div className="flex items-start gap-3">
{logoPath ? (
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border/60 bg-background">
<Image
src={logoPath}
alt={`Logo da conta ${accountName}`}
width={48}
height={48}
className="h-full w-full object-contain"
/>
</div>
) : null}
return (
<Card className="border">
<CardHeader className="flex flex-col gap-3">
<div className="flex items-start gap-3">
{logoPath ? (
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border/60 bg-background">
<Image
src={logoPath}
alt={`Logo da conta ${accountName}`}
width={48}
height={48}
className="h-full w-full object-contain"
/>
</div>
) : null}
<div className="flex w-full items-start justify-between gap-3">
<div className="space-y-1">
<CardTitle className="text-xl font-semibold text-foreground">
{accountName}
</CardTitle>
<p className="text-sm text-muted-foreground">
Extrato de {periodLabel}
</p>
</div>
{actions ? <div className="shrink-0">{actions}</div> : null}
</div>
</div>
</CardHeader>
<div className="flex w-full items-start justify-between gap-3">
<div className="space-y-1">
<CardTitle className="text-xl font-semibold text-foreground">
{accountName}
</CardTitle>
<p className="text-sm text-muted-foreground">
Extrato de {periodLabel}
</p>
</div>
{actions ? <div className="shrink-0">{actions}</div> : null}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4 border-t border-border/60 border-dashed pt-4">
{/* Composição do Saldo */}
<div className="space-y-3">
<DetailItem
label="Saldo no início do período"
value={<MoneyValues amount={openingBalance} className="text-2xl" />}
tooltip="Saldo inicial cadastrado na conta somado aos lançamentos pagos anteriores a este mês."
/>
<CardContent className="space-y-4 border-t border-border/60 border-dashed pt-4">
{/* Composição do Saldo */}
<div className="space-y-3">
<DetailItem
label="Saldo no início do período"
value={<MoneyValues amount={openingBalance} className="text-2xl" />}
tooltip="Saldo inicial cadastrado na conta somado aos lançamentos pagos anteriores a este mês."
/>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<DetailItem
label="Entradas"
value={
<span className="font-medium text-emerald-600">
{formatCurrency(totalIncomes)}
</span>
}
tooltip="Total de receitas deste mês classificadas como pagas para esta conta."
/>
<DetailItem
label="Saídas"
value={
<span className="font-medium text-destructive">
{formatCurrency(totalExpenses)}
</span>
}
tooltip="Total de despesas pagas neste mês (considerando divisão entre pagadores)."
/>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<DetailItem
label="Entradas"
value={
<span className="font-medium text-emerald-600">
{formatCurrency(totalIncomes)}
</span>
}
tooltip="Total de receitas deste mês classificadas como pagas para esta conta."
/>
<DetailItem
label="Saídas"
value={
<span className="font-medium text-destructive">
{formatCurrency(totalExpenses)}
</span>
}
tooltip="Total de despesas pagas neste mês (considerando divisão entre pagadores)."
/>
<DetailItem
label="Resultado do período"
value={
<MoneyValues
amount={totalIncomes - totalExpenses}
className={cn(
"font-semibold text-xl",
totalIncomes - totalExpenses >= 0
? "text-emerald-600"
: "text-destructive"
)}
/>
}
tooltip="Diferença entre entradas e saídas do mês; positivo indica saldo crescente."
/>
</div>
<DetailItem
label="Resultado do período"
value={
<MoneyValues
amount={totalIncomes - totalExpenses}
className={cn(
"font-semibold text-xl",
totalIncomes - totalExpenses >= 0
? "text-emerald-600"
: "text-destructive",
)}
/>
}
tooltip="Diferença entre entradas e saídas do mês; positivo indica saldo crescente."
/>
</div>
{/* Saldo Atual - Destaque Principal */}
<DetailItem
label="Saldo ao final do período"
value={<MoneyValues amount={currentBalance} className="text-2xl" />}
tooltip="Saldo inicial do período + entradas - saídas realizadas neste mês."
/>
</div>
{/* Saldo Atual - Destaque Principal */}
<DetailItem
label="Saldo ao final do período"
value={<MoneyValues amount={currentBalance} className="text-2xl" />}
tooltip="Saldo inicial do período + entradas - saídas realizadas neste mês."
/>
</div>
{/* Informações da Conta */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 pt-2 border-t border-border/60 border-dashed">
<DetailItem
label="Tipo da conta"
value={accountType}
tooltip="Classificação definida na criação da conta (corrente, poupança, etc.)."
/>
<DetailItem
label="Status da conta"
value={
<div className="flex items-center">
<Badge
variant={getAccountStatusBadgeVariant(status)}
className="text-xs"
>
{status}
</Badge>
</div>
}
tooltip="Indica se a conta está ativa para lançamentos ou foi desativada."
/>
</div>
</CardContent>
</Card>
);
{/* Informações da Conta */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 pt-2 border-t border-border/60 border-dashed">
<DetailItem
label="Tipo da conta"
value={accountType}
tooltip="Classificação definida na criação da conta (corrente, poupança, etc.)."
/>
<DetailItem
label="Status da conta"
value={
<div className="flex items-center">
<Badge
variant={getAccountStatusBadgeVariant(status)}
className="text-xs"
>
{status}
</Badge>
</div>
}
tooltip="Indica se a conta está ativa para lançamentos ou foi desativada."
/>
</div>
</CardContent>
</Card>
);
}
function DetailItem({
label,
value,
className,
tooltip,
label,
value,
className,
tooltip,
}: {
label: string;
value: DetailValue;
className?: string;
tooltip?: string;
label: string;
value: DetailValue;
className?: string;
tooltip?: string;
}) {
return (
<div className={cn("space-y-1", className)}>
<span className="flex items-center gap-1 text-xs font-medium uppercase text-muted-foreground/80">
{label}
{tooltip ? (
<Tooltip>
<TooltipTrigger asChild>
<RiInformationLine className="size-3.5 cursor-help text-muted-foreground/60" />
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-xs text-xs"
>
{tooltip}
</TooltipContent>
</Tooltip>
) : null}
</span>
<div className="text-base text-foreground">{value}</div>
</div>
);
return (
<div className={cn("space-y-1", className)}>
<span className="flex items-center gap-1 text-xs font-medium uppercase text-muted-foreground/80">
{label}
{tooltip ? (
<Tooltip>
<TooltipTrigger asChild>
<RiInformationLine className="size-3.5 cursor-help text-muted-foreground/60" />
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-xs text-xs"
>
{tooltip}
</TooltipContent>
</Tooltip>
) : null}
</span>
<div className="text-base text-foreground">{value}</div>
</div>
);
}

View File

@@ -1,214 +1,226 @@
"use client";
import { RiAddCircleLine, RiBankLine } from "@remixicon/react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import { deleteAccountAction } from "@/app/(dashboard)/contas/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { AccountCard } from "@/components/contas/account-card";
import { EmptyState } from "@/components/empty-state";
import { Button } from "@/components/ui/button";
import { getCurrentPeriod } from "@/lib/utils/period";
import { RiAddCircleLine, RiBankLine } from "@remixicon/react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import { Card } from "../ui/card";
import { AccountDialog } from "./account-dialog";
import { TransferDialog } from "./transfer-dialog";
import type { Account } from "./types";
interface AccountsPageProps {
accounts: Account[];
logoOptions: string[];
isInativos?: boolean;
accounts: Account[];
logoOptions: string[];
isInativos?: boolean;
}
const resolveLogoSrc = (logo: string | null) => {
if (!logo) {
return undefined;
}
if (!logo) {
return undefined;
}
const fileName = logo.split("/").filter(Boolean).pop() ?? logo;
return `/logos/${fileName}`;
const fileName = logo.split("/").filter(Boolean).pop() ?? logo;
return `/logos/${fileName}`;
};
export function AccountsPage({ accounts, logoOptions, isInativos = false }: AccountsPageProps) {
const router = useRouter();
const [editOpen, setEditOpen] = useState(false);
const [selectedAccount, setSelectedAccount] = useState<Account | null>(null);
const [removeOpen, setRemoveOpen] = useState(false);
const [accountToRemove, setAccountToRemove] = useState<Account | null>(null);
const [transferOpen, setTransferOpen] = useState(false);
const [transferFromAccount, setTransferFromAccount] =
useState<Account | null>(null);
export function AccountsPage({
accounts,
logoOptions,
isInativos = false,
}: AccountsPageProps) {
const router = useRouter();
const [editOpen, setEditOpen] = useState(false);
const [selectedAccount, setSelectedAccount] = useState<Account | null>(null);
const [removeOpen, setRemoveOpen] = useState(false);
const [accountToRemove, setAccountToRemove] = useState<Account | null>(null);
const [transferOpen, setTransferOpen] = useState(false);
const [transferFromAccount, setTransferFromAccount] =
useState<Account | null>(null);
const hasAccounts = accounts.length > 0;
const hasAccounts = accounts.length > 0;
const orderedAccounts = useMemo(() => {
return [...accounts].sort((a, b) => {
// Coloca inativas no final
const aIsInactive = a.status?.toLowerCase() === "inativa";
const bIsInactive = b.status?.toLowerCase() === "inativa";
const orderedAccounts = useMemo(() => {
return [...accounts].sort((a, b) => {
// Coloca inativas no final
const aIsInactive = a.status?.toLowerCase() === "inativa";
const bIsInactive = b.status?.toLowerCase() === "inativa";
if (aIsInactive && !bIsInactive) return 1;
if (!aIsInactive && bIsInactive) return -1;
if (aIsInactive && !bIsInactive) return 1;
if (!aIsInactive && bIsInactive) return -1;
// Mesma ordem alfabética dentro de cada grupo
return a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" });
});
}, [accounts]);
// Mesma ordem alfabética dentro de cada grupo
return a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" });
});
}, [accounts]);
const handleEdit = useCallback((account: Account) => {
setSelectedAccount(account);
setEditOpen(true);
}, []);
const handleEdit = useCallback((account: Account) => {
setSelectedAccount(account);
setEditOpen(true);
}, []);
const handleEditOpenChange = useCallback((open: boolean) => {
setEditOpen(open);
if (!open) {
setSelectedAccount(null);
}
}, []);
const handleEditOpenChange = useCallback((open: boolean) => {
setEditOpen(open);
if (!open) {
setSelectedAccount(null);
}
}, []);
const handleRemoveRequest = useCallback((account: Account) => {
setAccountToRemove(account);
setRemoveOpen(true);
}, []);
const handleRemoveRequest = useCallback((account: Account) => {
setAccountToRemove(account);
setRemoveOpen(true);
}, []);
const handleRemoveOpenChange = useCallback((open: boolean) => {
setRemoveOpen(open);
if (!open) {
setAccountToRemove(null);
}
}, []);
const handleRemoveOpenChange = useCallback((open: boolean) => {
setRemoveOpen(open);
if (!open) {
setAccountToRemove(null);
}
}, []);
const handleRemoveConfirm = useCallback(async () => {
if (!accountToRemove) {
return;
}
const handleRemoveConfirm = useCallback(async () => {
if (!accountToRemove) {
return;
}
const result = await deleteAccountAction({ id: accountToRemove.id });
const result = await deleteAccountAction({ id: accountToRemove.id });
if (result.success) {
toast.success(result.message);
return;
}
if (result.success) {
toast.success(result.message);
return;
}
toast.error(result.error);
throw new Error(result.error);
}, [accountToRemove]);
toast.error(result.error);
throw new Error(result.error);
}, [accountToRemove]);
const handleTransferRequest = useCallback((account: Account) => {
setTransferFromAccount(account);
setTransferOpen(true);
}, []);
const handleTransferRequest = useCallback((account: Account) => {
setTransferFromAccount(account);
setTransferOpen(true);
}, []);
const handleTransferOpenChange = useCallback((open: boolean) => {
setTransferOpen(open);
if (!open) {
setTransferFromAccount(null);
}
}, []);
const handleTransferOpenChange = useCallback((open: boolean) => {
setTransferOpen(open);
if (!open) {
setTransferFromAccount(null);
}
}, []);
const removeTitle = accountToRemove
? `Remover conta "${accountToRemove.name}"?`
: "Remover conta?";
const removeTitle = accountToRemove
? `Remover conta "${accountToRemove.name}"?`
: "Remover conta?";
return (
<>
<div className="flex w-full flex-col gap-6">
<div className="flex justify-start">
<AccountDialog
mode="create"
logoOptions={logoOptions}
trigger={
<Button>
<RiAddCircleLine className="size-4" />
Nova conta
</Button>
}
/>
</div>
return (
<>
<div className="flex w-full flex-col gap-6">
<div className="flex justify-start">
<AccountDialog
mode="create"
logoOptions={logoOptions}
trigger={
<Button>
<RiAddCircleLine className="size-4" />
Nova conta
</Button>
}
/>
</div>
{hasAccounts ? (
<div className="flex flex-wrap gap-4">
{orderedAccounts.map((account) => {
const logoSrc = resolveLogoSrc(account.logo);
{hasAccounts ? (
<div className="flex flex-wrap gap-4">
{orderedAccounts.map((account) => {
const logoSrc = resolveLogoSrc(account.logo);
return (
<AccountCard
key={account.id}
accountName={account.name}
accountType={`${account.accountType}`}
balance={account.balance ?? account.initialBalance ?? 0}
status={account.status}
excludeFromBalance={account.excludeFromBalance}
excludeInitialBalanceFromIncome={
account.excludeInitialBalanceFromIncome
}
icon={
logoSrc ? (
<Image
src={logoSrc}
alt={`Logo da conta ${account.name}`}
width={42}
height={42}
className="rounded-lg"
/>
) : undefined
}
onEdit={() => handleEdit(account)}
onRemove={() => handleRemoveRequest(account)}
onTransfer={() => handleTransferRequest(account)}
onViewStatement={() =>
router.push(`/contas/${account.id}/extrato`)
}
/>
);
})}
</div>
) : (
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
<EmptyState
media={<RiBankLine className="size-6 text-primary" />}
title={isInativos ? "Nenhuma conta inativa" : "Nenhuma conta cadastrada"}
description={isInativos ? "Não há contas inativas no momento." : "Cadastre sua primeira conta para começar a organizar os lançamentos."}
/>
</Card>
)}
</div>
return (
<AccountCard
key={account.id}
accountName={account.name}
accountType={`${account.accountType}`}
balance={account.balance ?? account.initialBalance ?? 0}
status={account.status}
excludeFromBalance={account.excludeFromBalance}
excludeInitialBalanceFromIncome={
account.excludeInitialBalanceFromIncome
}
icon={
logoSrc ? (
<Image
src={logoSrc}
alt={`Logo da conta ${account.name}`}
width={42}
height={42}
className="rounded-lg"
/>
) : undefined
}
onEdit={() => handleEdit(account)}
onRemove={() => handleRemoveRequest(account)}
onTransfer={() => handleTransferRequest(account)}
onViewStatement={() =>
router.push(`/contas/${account.id}/extrato`)
}
/>
);
})}
</div>
) : (
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
<EmptyState
media={<RiBankLine className="size-6 text-primary" />}
title={
isInativos
? "Nenhuma conta inativa"
: "Nenhuma conta cadastrada"
}
description={
isInativos
? "Não há contas inativas no momento."
: "Cadastre sua primeira conta para começar a organizar os lançamentos."
}
/>
</Card>
)}
</div>
<AccountDialog
mode="update"
logoOptions={logoOptions}
account={selectedAccount ?? undefined}
open={editOpen && !!selectedAccount}
onOpenChange={handleEditOpenChange}
/>
<AccountDialog
mode="update"
logoOptions={logoOptions}
account={selectedAccount ?? undefined}
open={editOpen && !!selectedAccount}
onOpenChange={handleEditOpenChange}
/>
<ConfirmActionDialog
open={removeOpen && !!accountToRemove}
onOpenChange={handleRemoveOpenChange}
title={removeTitle}
description="Ao remover esta conta, todos os dados relacionados a ela serão perdidos."
confirmLabel="Remover conta"
pendingLabel="Removendo..."
confirmVariant="destructive"
onConfirm={handleRemoveConfirm}
/>
<ConfirmActionDialog
open={removeOpen && !!accountToRemove}
onOpenChange={handleRemoveOpenChange}
title={removeTitle}
description="Ao remover esta conta, todos os dados relacionados a ela serão perdidos."
confirmLabel="Remover conta"
pendingLabel="Removendo..."
confirmVariant="destructive"
onConfirm={handleRemoveConfirm}
/>
{transferFromAccount && (
<TransferDialog
accounts={accounts.map((a) => ({
...a,
balance: a.balance ?? a.initialBalance ?? 0,
excludeFromBalance: a.excludeFromBalance ?? false,
}))}
fromAccountId={transferFromAccount.id}
currentPeriod={getCurrentPeriod()}
open={transferOpen}
onOpenChange={handleTransferOpenChange}
/>
)}
</>
);
{transferFromAccount && (
<TransferDialog
accounts={accounts.map((a) => ({
...a,
balance: a.balance ?? a.initialBalance ?? 0,
excludeFromBalance: a.excludeFromBalance ?? false,
}))}
fromAccountId={transferFromAccount.id}
currentPeriod={getCurrentPeriod()}
open={transferOpen}
onOpenChange={handleTransferOpenChange}
/>
)}
</>
);
}

View File

@@ -1,5 +1,7 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { transferBetweenAccountsAction } from "@/app/(dashboard)/contas/actions";
import type { AccountData } from "@/app/(dashboard)/contas/data";
import { ContaCartaoSelectContent } from "@/components/lancamentos/select-items";
@@ -8,250 +10,248 @@ import { Button } from "@/components/ui/button";
import { CurrencyInput } from "@/components/ui/currency-input";
import { DatePicker } from "@/components/ui/date-picker";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useControlledState } from "@/hooks/use-controlled-state";
import { getTodayDateString } from "@/lib/utils/date";
import { useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
interface TransferDialogProps {
trigger?: React.ReactNode;
accounts: AccountData[];
fromAccountId: string;
currentPeriod: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
trigger?: React.ReactNode;
accounts: AccountData[];
fromAccountId: string;
currentPeriod: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function TransferDialog({
trigger,
accounts,
fromAccountId,
currentPeriod,
open,
onOpenChange,
trigger,
accounts,
fromAccountId,
currentPeriod,
open,
onOpenChange,
}: TransferDialogProps) {
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange
);
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange,
);
const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Form state
const [toAccountId, setToAccountId] = useState("");
const [amount, setAmount] = useState("");
const [date, setDate] = useState(getTodayDateString());
const [period, setPeriod] = useState(currentPeriod);
// Form state
const [toAccountId, setToAccountId] = useState("");
const [amount, setAmount] = useState("");
const [date, setDate] = useState(getTodayDateString());
const [period, setPeriod] = useState(currentPeriod);
// Available destination accounts (exclude source account)
const availableAccounts = useMemo(
() => accounts.filter((account) => account.id !== fromAccountId),
[accounts, fromAccountId]
);
// Available destination accounts (exclude source account)
const availableAccounts = useMemo(
() => accounts.filter((account) => account.id !== fromAccountId),
[accounts, fromAccountId],
);
// Source account info
const fromAccount = useMemo(
() => accounts.find((account) => account.id === fromAccountId),
[accounts, fromAccountId]
);
// Source account info
const fromAccount = useMemo(
() => accounts.find((account) => account.id === fromAccountId),
[accounts, fromAccountId],
);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
if (!toAccountId) {
setErrorMessage("Selecione a conta de destino.");
return;
}
if (!toAccountId) {
setErrorMessage("Selecione a conta de destino.");
return;
}
if (toAccountId === fromAccountId) {
setErrorMessage("Selecione uma conta de destino diferente da origem.");
return;
}
if (toAccountId === fromAccountId) {
setErrorMessage("Selecione uma conta de destino diferente da origem.");
return;
}
if (!amount || parseFloat(amount.replace(",", ".")) <= 0) {
setErrorMessage("Informe um valor válido maior que zero.");
return;
}
if (!amount || parseFloat(amount.replace(",", ".")) <= 0) {
setErrorMessage("Informe um valor válido maior que zero.");
return;
}
startTransition(async () => {
const result = await transferBetweenAccountsAction({
fromAccountId,
toAccountId,
amount,
date: new Date(date),
period,
});
startTransition(async () => {
const result = await transferBetweenAccountsAction({
fromAccountId,
toAccountId,
amount,
date: new Date(date),
period,
});
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
// Reset form
setToAccountId("");
setAmount("");
setDate(getTodayDateString());
setPeriod(currentPeriod);
return;
}
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
// Reset form
setToAccountId("");
setAmount("");
setDate(getTodayDateString());
setPeriod(currentPeriod);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
});
};
setErrorMessage(result.error);
toast.error(result.error);
});
};
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Transferir entre contas</DialogTitle>
<DialogDescription>
Registre uma transferência de valores entre suas contas.
</DialogDescription>
</DialogHeader>
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Transferir entre contas</DialogTitle>
<DialogDescription>
Registre uma transferência de valores entre suas contas.
</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="transfer-date">Data da transferência</Label>
<DatePicker
id="transfer-date"
value={date}
onChange={setDate}
required
/>
</div>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="transfer-date">Data da transferência</Label>
<DatePicker
id="transfer-date"
value={date}
onChange={setDate}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="transfer-period">Período</Label>
<PeriodPicker
value={period}
onChange={setPeriod}
className="w-full"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="transfer-period">Período</Label>
<PeriodPicker
value={period}
onChange={setPeriod}
className="w-full"
/>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="transfer-amount">Valor</Label>
<CurrencyInput
id="transfer-amount"
value={amount}
onValueChange={setAmount}
placeholder="R$ 0,00"
required
/>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="transfer-amount">Valor</Label>
<CurrencyInput
id="transfer-amount"
value={amount}
onValueChange={setAmount}
placeholder="R$ 0,00"
required
/>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="from-account">Conta de origem</Label>
<Select value={fromAccountId} disabled>
<SelectTrigger id="from-account" className="w-full">
<SelectValue>
{fromAccount && (
<ContaCartaoSelectContent
label={fromAccount.name}
logo={fromAccount.logo}
isCartao={false}
/>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{fromAccount && (
<SelectItem value={fromAccount.id}>
<ContaCartaoSelectContent
label={fromAccount.name}
logo={fromAccount.logo}
isCartao={false}
/>
</SelectItem>
)}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="from-account">Conta de origem</Label>
<Select value={fromAccountId} disabled>
<SelectTrigger id="from-account" className="w-full">
<SelectValue>
{fromAccount && (
<ContaCartaoSelectContent
label={fromAccount.name}
logo={fromAccount.logo}
isCartao={false}
/>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{fromAccount && (
<SelectItem value={fromAccount.id}>
<ContaCartaoSelectContent
label={fromAccount.name}
logo={fromAccount.logo}
isCartao={false}
/>
</SelectItem>
)}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="to-account">Conta de destino</Label>
{availableAccounts.length === 0 ? (
<div className="rounded-md border border-border bg-muted p-3 text-sm text-muted-foreground">
É necessário ter mais de uma conta cadastrada para realizar
transferências.
</div>
) : (
<Select value={toAccountId} onValueChange={setToAccountId}>
<SelectTrigger id="to-account" className="w-full">
<SelectValue placeholder="Selecione a conta de destino">
{toAccountId &&
(() => {
const selectedAccount = availableAccounts.find(
(acc) => acc.id === toAccountId,
);
return selectedAccount ? (
<ContaCartaoSelectContent
label={selectedAccount.name}
logo={selectedAccount.logo}
isCartao={false}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent className="w-full">
{availableAccounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
<ContaCartaoSelectContent
label={account.name}
logo={account.logo}
isCartao={false}
/>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="to-account">Conta de destino</Label>
{availableAccounts.length === 0 ? (
<div className="rounded-md border border-border bg-muted p-3 text-sm text-muted-foreground">
É necessário ter mais de uma conta cadastrada para realizar
transferências.
</div>
) : (
<Select value={toAccountId} onValueChange={setToAccountId}>
<SelectTrigger id="to-account" className="w-full">
<SelectValue placeholder="Selecione a conta de destino">
{toAccountId &&
(() => {
const selectedAccount = availableAccounts.find(
(acc) => acc.id === toAccountId,
);
return selectedAccount ? (
<ContaCartaoSelectContent
label={selectedAccount.name}
logo={selectedAccount.logo}
isCartao={false}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent className="w-full">
{availableAccounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
<ContaCartaoSelectContent
label={account.name}
logo={account.logo}
isCartao={false}
/>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
<DialogFooter className="gap-3">
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button
type="submit"
disabled={isPending || availableAccounts.length === 0}
>
{isPending ? "Processando..." : "Confirmar transferência"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
<DialogFooter className="gap-3">
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button
type="submit"
disabled={isPending || availableAccounts.length === 0}
>
{isPending ? "Processando..." : "Confirmar transferência"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,23 +1,23 @@
export type Account = {
id: string;
name: string;
accountType: string;
status: string;
note: string | null;
logo: string | null;
initialBalance: number;
balance?: number | null;
excludeFromBalance?: boolean;
excludeInitialBalanceFromIncome?: boolean;
id: string;
name: string;
accountType: string;
status: string;
note: string | null;
logo: string | null;
initialBalance: number;
balance?: number | null;
excludeFromBalance?: boolean;
excludeInitialBalanceFromIncome?: boolean;
};
export type AccountFormValues = {
name: string;
accountType: string;
status: string;
note: string;
logo: string;
initialBalance: string;
excludeFromBalance: boolean;
excludeInitialBalanceFromIncome: boolean;
name: string;
accountType: string;
status: string;
note: string;
logo: string;
initialBalance: string;
excludeFromBalance: boolean;
excludeInitialBalanceFromIncome: boolean;
};

View File

@@ -1,363 +1,363 @@
"use client";
import {
RiBarcodeFill,
RiCheckboxCircleFill,
RiCheckboxCircleLine,
RiLoader4Line,
} from "@remixicon/react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { toggleLancamentoSettlementAction } from "@/app/(dashboard)/lancamentos/actions";
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
import MoneyValues from "@/components/money-values";
import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter as ModalFooter,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter as ModalFooter,
} from "@/components/ui/dialog";
import type { DashboardBoleto } from "@/lib/dashboard/boletos";
import { cn } from "@/lib/utils/ui";
import {
RiBarcodeFill,
RiCheckboxCircleFill,
RiCheckboxCircleLine,
RiLoader4Line,
} from "@remixicon/react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { Badge } from "../ui/badge";
import { WidgetEmptyState } from "../widget-empty-state";
type BoletosWidgetProps = {
boletos: DashboardBoleto[];
boletos: DashboardBoleto[];
};
type ModalState = "idle" | "processing" | "success";
const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
day: "2-digit",
month: "short",
year: "numeric",
timeZone: "UTC",
day: "2-digit",
month: "short",
year: "numeric",
timeZone: "UTC",
});
const buildDateLabel = (value: string | null, prefix?: string) => {
if (!value) {
return null;
}
if (!value) {
return null;
}
const [year, month, day] = value.split("-").map((part) => Number(part));
if (!year || !month || !day) {
return null;
}
const [year, month, day] = value.split("-").map((part) => Number(part));
if (!year || !month || !day) {
return null;
}
const formatted = DATE_FORMATTER.format(
new Date(Date.UTC(year, month - 1, day))
);
const formatted = DATE_FORMATTER.format(
new Date(Date.UTC(year, month - 1, day)),
);
return prefix ? `${prefix} ${formatted}` : formatted;
return prefix ? `${prefix} ${formatted}` : formatted;
};
const buildStatusLabel = (boleto: DashboardBoleto) => {
if (boleto.isSettled) {
return buildDateLabel(boleto.boletoPaymentDate, "Pago em");
}
if (boleto.isSettled) {
return buildDateLabel(boleto.boletoPaymentDate, "Pago em");
}
return buildDateLabel(boleto.dueDate, "Vence em");
return buildDateLabel(boleto.dueDate, "Vence em");
};
const getTodayDateString = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
export function BoletosWidget({ boletos }: BoletosWidgetProps) {
const router = useRouter();
const [items, setItems] = useState(boletos);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalState, setModalState] = useState<ModalState>("idle");
const [isPending, startTransition] = useTransition();
const router = useRouter();
const [items, setItems] = useState(boletos);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalState, setModalState] = useState<ModalState>("idle");
const [isPending, startTransition] = useTransition();
useEffect(() => {
setItems(boletos);
}, [boletos]);
useEffect(() => {
setItems(boletos);
}, [boletos]);
const selectedBoleto = useMemo(
() => items.find((boleto) => boleto.id === selectedId) ?? null,
[items, selectedId]
);
const selectedBoleto = useMemo(
() => items.find((boleto) => boleto.id === selectedId) ?? null,
[items, selectedId],
);
const isProcessing = modalState === "processing" || isPending;
const isProcessing = modalState === "processing" || isPending;
const selectedBoletoDueLabel = selectedBoleto
? buildDateLabel(selectedBoleto.dueDate, "Vencimento:")
: null;
const selectedBoletoDueLabel = selectedBoleto
? buildDateLabel(selectedBoleto.dueDate, "Vencimento:")
: null;
const handleOpenModal = (boletoId: string) => {
setSelectedId(boletoId);
setModalState("idle");
setIsModalOpen(true);
};
const handleOpenModal = (boletoId: string) => {
setSelectedId(boletoId);
setModalState("idle");
setIsModalOpen(true);
};
const resetModalState = () => {
setIsModalOpen(false);
setSelectedId(null);
setModalState("idle");
};
const resetModalState = () => {
setIsModalOpen(false);
setSelectedId(null);
setModalState("idle");
};
const handleConfirmPayment = () => {
if (!selectedBoleto || selectedBoleto.isSettled || isProcessing) {
return;
}
const handleConfirmPayment = () => {
if (!selectedBoleto || selectedBoleto.isSettled || isProcessing) {
return;
}
setModalState("processing");
setModalState("processing");
startTransition(async () => {
const result = await toggleLancamentoSettlementAction({
id: selectedBoleto.id,
value: true,
});
startTransition(async () => {
const result = await toggleLancamentoSettlementAction({
id: selectedBoleto.id,
value: true,
});
if (!result.success) {
toast.error(result.error);
setModalState("idle");
return;
}
if (!result.success) {
toast.error(result.error);
setModalState("idle");
return;
}
setItems((previous) =>
previous.map((boleto) =>
boleto.id === selectedBoleto.id
? {
...boleto,
isSettled: true,
boletoPaymentDate: getTodayDateString(),
}
: boleto
)
);
toast.success(result.message);
router.refresh();
setModalState("success");
});
};
setItems((previous) =>
previous.map((boleto) =>
boleto.id === selectedBoleto.id
? {
...boleto,
isSettled: true,
boletoPaymentDate: getTodayDateString(),
}
: boleto,
),
);
toast.success(result.message);
router.refresh();
setModalState("success");
});
};
const getStatusBadgeVariant = (status: string): "success" | "info" => {
const normalizedStatus = status.toLowerCase();
if (normalizedStatus === "pendente") {
return "info";
}
return "success";
};
const getStatusBadgeVariant = (status: string): "success" | "info" => {
const normalizedStatus = status.toLowerCase();
if (normalizedStatus === "pendente") {
return "info";
}
return "success";
};
return (
<>
<CardContent className="flex flex-col gap-4 px-0">
{items.length === 0 ? (
<WidgetEmptyState
icon={<RiBarcodeFill className="size-6 text-muted-foreground" />}
title="Nenhum boleto cadastrado para o período selecionado"
description="Cadastre boletos para monitorar os pagamentos aqui."
/>
) : (
<ul className="flex flex-col">
{items.map((boleto) => {
const statusLabel = buildStatusLabel(boleto);
return (
<>
<CardContent className="flex flex-col gap-4 px-0">
{items.length === 0 ? (
<WidgetEmptyState
icon={<RiBarcodeFill className="size-6 text-muted-foreground" />}
title="Nenhum boleto cadastrado para o período selecionado"
description="Cadastre boletos para monitorar os pagamentos aqui."
/>
) : (
<ul className="flex flex-col">
{items.map((boleto) => {
const statusLabel = buildStatusLabel(boleto);
return (
<li
key={boleto.id}
className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0"
>
<div className="flex min-w-0 flex-1 items-center gap-3 py-2">
<EstabelecimentoLogo name={boleto.name} size={38} />
return (
<li
key={boleto.id}
className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0"
>
<div className="flex min-w-0 flex-1 items-center gap-3 py-2">
<EstabelecimentoLogo name={boleto.name} size={38} />
<div className="min-w-0">
<span className="block truncate text-sm font-medium text-foreground">
{boleto.name}
</span>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span
className={cn(
"rounded-full py-0.5",
boleto.isSettled &&
"text-green-600 dark:text-green-400"
)}
>
{statusLabel}
</span>
</div>
</div>
</div>
<div className="min-w-0">
<span className="block truncate text-sm font-medium text-foreground">
{boleto.name}
</span>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span
className={cn(
"rounded-full py-0.5",
boleto.isSettled &&
"text-green-600 dark:text-green-400",
)}
>
{statusLabel}
</span>
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end">
<MoneyValues amount={boleto.amount} />
<Button
type="button"
size="sm"
variant="link"
className="h-auto p-0 disabled:opacity-100"
disabled={boleto.isSettled}
onClick={() => handleOpenModal(boleto.id)}
>
{boleto.isSettled ? (
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<RiCheckboxCircleFill className="size-3" /> Pago
</span>
) : (
"Pagar"
)}
</Button>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
<div className="flex shrink-0 flex-col items-end">
<MoneyValues amount={boleto.amount} />
<Button
type="button"
size="sm"
variant="link"
className="h-auto p-0 disabled:opacity-100"
disabled={boleto.isSettled}
onClick={() => handleOpenModal(boleto.id)}
>
{boleto.isSettled ? (
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<RiCheckboxCircleFill className="size-3" /> Pago
</span>
) : (
"Pagar"
)}
</Button>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
<Dialog
open={isModalOpen}
onOpenChange={(open) => {
if (!open) {
if (isProcessing) {
return;
}
resetModalState();
return;
}
setIsModalOpen(true);
}}
>
<DialogContent
className="max-w-md"
onEscapeKeyDown={(event) => {
if (isProcessing) {
event.preventDefault();
return;
}
resetModalState();
}}
onPointerDownOutside={(event) => {
if (isProcessing) {
event.preventDefault();
}
}}
>
{modalState === "success" ? (
<div className="flex flex-col items-center gap-4 py-6 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-emerald-500/10 text-emerald-500">
<RiCheckboxCircleLine className="size-8" />
</div>
<div className="space-y-2">
<DialogTitle className="text-base">
Pagamento registrado!
</DialogTitle>
<DialogDescription className="text-sm">
Atualizamos o status do boleto para pago. Em instantes ele
aparecerá como baixado no histórico.
</DialogDescription>
</div>
<ModalFooter className="sm:justify-center">
<Button
type="button"
onClick={resetModalState}
className="sm:w-auto"
>
Fechar
</Button>
</ModalFooter>
</div>
) : (
<>
<DialogHeader className="gap-3 text-center sm:text-left">
<DialogTitle>Confirmar pagamento do boleto</DialogTitle>
<DialogDescription>
Confirme os dados para registrar o pagamento. Você poderá
editar o lançamento depois, se necessário.
</DialogDescription>
</DialogHeader>
<Dialog
open={isModalOpen}
onOpenChange={(open) => {
if (!open) {
if (isProcessing) {
return;
}
resetModalState();
return;
}
setIsModalOpen(true);
}}
>
<DialogContent
className="max-w-md"
onEscapeKeyDown={(event) => {
if (isProcessing) {
event.preventDefault();
return;
}
resetModalState();
}}
onPointerDownOutside={(event) => {
if (isProcessing) {
event.preventDefault();
}
}}
>
{modalState === "success" ? (
<div className="flex flex-col items-center gap-4 py-6 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-emerald-500/10 text-emerald-500">
<RiCheckboxCircleLine className="size-8" />
</div>
<div className="space-y-2">
<DialogTitle className="text-base">
Pagamento registrado!
</DialogTitle>
<DialogDescription className="text-sm">
Atualizamos o status do boleto para pago. Em instantes ele
aparecerá como baixado no histórico.
</DialogDescription>
</div>
<ModalFooter className="sm:justify-center">
<Button
type="button"
onClick={resetModalState}
className="sm:w-auto"
>
Fechar
</Button>
</ModalFooter>
</div>
) : (
<>
<DialogHeader className="gap-3 text-center sm:text-left">
<DialogTitle>Confirmar pagamento do boleto</DialogTitle>
<DialogDescription>
Confirme os dados para registrar o pagamento. Você poderá
editar o lançamento depois, se necessário.
</DialogDescription>
</DialogHeader>
{selectedBoleto ? (
<div className="flex flex-col gap-4">
<div className="flex flex-col items-center gap-3 rounded-lg border border-border/60 bg-muted/50 p-4 text-center sm:flex-row sm:text-left">
<div className="flex size-12 shrink-0 items-center justify-center">
<RiBarcodeFill className="size-8" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
{selectedBoleto.name}
</p>
{selectedBoletoDueLabel ? (
<p className="text-xs text-muted-foreground">
{selectedBoletoDueLabel}
</p>
) : null}
</div>
</div>
{selectedBoleto ? (
<div className="flex flex-col gap-4">
<div className="flex flex-col items-center gap-3 rounded-lg border border-border/60 bg-muted/50 p-4 text-center sm:flex-row sm:text-left">
<div className="flex size-12 shrink-0 items-center justify-center">
<RiBarcodeFill className="size-8" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
{selectedBoleto.name}
</p>
{selectedBoletoDueLabel ? (
<p className="text-xs text-muted-foreground">
{selectedBoletoDueLabel}
</p>
) : null}
</div>
</div>
<div className="grid grid-cols-1 gap-3 text-sm">
<div className="flex items-center justify-between rounded border border-border/60 px-3 py-2">
<span className="text-xs uppercase text-muted-foreground/80">
Valor do boleto
</span>
<MoneyValues
amount={selectedBoleto.amount}
className="text-lg"
/>
</div>
<div className="flex items-center justify-between rounded border border-border/60 px-3 py-2">
<span className="text-xs uppercase text-muted-foreground/80">
Status atual
</span>
<span className="text-sm font-medium">
<Badge
variant={getStatusBadgeVariant(
selectedBoleto.isSettled ? "Pago" : "Pendente"
)}
className="text-xs"
>
{selectedBoleto.isSettled ? "Pago" : "Pendente"}
</Badge>
</span>
</div>
</div>
</div>
) : null}
<div className="grid grid-cols-1 gap-3 text-sm">
<div className="flex items-center justify-between rounded border border-border/60 px-3 py-2">
<span className="text-xs uppercase text-muted-foreground/80">
Valor do boleto
</span>
<MoneyValues
amount={selectedBoleto.amount}
className="text-lg"
/>
</div>
<div className="flex items-center justify-between rounded border border-border/60 px-3 py-2">
<span className="text-xs uppercase text-muted-foreground/80">
Status atual
</span>
<span className="text-sm font-medium">
<Badge
variant={getStatusBadgeVariant(
selectedBoleto.isSettled ? "Pago" : "Pendente",
)}
className="text-xs"
>
{selectedBoleto.isSettled ? "Pago" : "Pendente"}
</Badge>
</span>
</div>
</div>
</div>
) : null}
<ModalFooter className="sm:justify-end">
<Button
type="button"
variant="outline"
onClick={resetModalState}
disabled={isProcessing}
>
Cancelar
</Button>
<Button
type="button"
onClick={handleConfirmPayment}
disabled={
isProcessing || !selectedBoleto || selectedBoleto.isSettled
}
className="relative"
>
{isProcessing ? (
<>
<RiLoader4Line className="mr-1.5 size-4 animate-spin" />
Processando...
</>
) : (
"Confirmar pagamento"
)}
</Button>
</ModalFooter>
</>
)}
</DialogContent>
</Dialog>
</>
);
<ModalFooter className="sm:justify-end">
<Button
type="button"
variant="outline"
onClick={resetModalState}
disabled={isProcessing}
>
Cancelar
</Button>
<Button
type="button"
onClick={handleConfirmPayment}
disabled={
isProcessing || !selectedBoleto || selectedBoleto.isSettled
}
className="relative"
>
{isProcessing ? (
<>
<RiLoader4Line className="mr-1.5 size-4 animate-spin" />
Processando...
</>
) : (
"Confirmar pagamento"
)}
</Button>
</ModalFooter>
</>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,472 +1,472 @@
"use client";
import {
RiArrowDownSLine,
RiBarChartBoxLine,
RiCloseLine,
} from "@remixicon/react";
import { useEffect, useMemo, useState } from "react";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
ChartContainer,
ChartTooltip,
type ChartConfig,
type ChartConfig,
ChartContainer,
ChartTooltip,
} from "@/components/ui/chart";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { WidgetEmptyState } from "@/components/widget-empty-state";
import type { CategoryHistoryData } from "@/lib/dashboard/categories/category-history";
import { getIconComponent } from "@/lib/utils/icons";
import {
RiArrowDownSLine,
RiBarChartBoxLine,
RiCloseLine,
} from "@remixicon/react";
import { useEffect, useMemo, useState } from "react";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
type CategoryHistoryWidgetProps = {
data: CategoryHistoryData;
data: CategoryHistoryData;
};
const STORAGE_KEY_SELECTED = "dashboard-category-history-selected";
// Vibrant colors for categories
const CHART_COLORS = [
"#ef4444", // red-500
"#3b82f6", // blue-500
"#10b981", // emerald-500
"#f59e0b", // amber-500
"#8b5cf6", // violet-500
"#ef4444", // red-500
"#3b82f6", // blue-500
"#10b981", // emerald-500
"#f59e0b", // amber-500
"#8b5cf6", // violet-500
];
export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [isClient, setIsClient] = useState(false);
const [open, setOpen] = useState(false);
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [isClient, setIsClient] = useState(false);
const [open, setOpen] = useState(false);
// Load selected categories from sessionStorage on mount
useEffect(() => {
setIsClient(true);
// Load selected categories from sessionStorage on mount
useEffect(() => {
setIsClient(true);
const stored = sessionStorage.getItem(STORAGE_KEY_SELECTED);
if (stored) {
try {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
const validCategories = parsed.filter((id) =>
data.allCategories.some((cat) => cat.id === id)
);
setSelectedCategories(validCategories.slice(0, 5));
}
} catch (e) {
// Invalid JSON, ignore
}
}
}, [data.allCategories]);
const stored = sessionStorage.getItem(STORAGE_KEY_SELECTED);
if (stored) {
try {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
const validCategories = parsed.filter((id) =>
data.allCategories.some((cat) => cat.id === id),
);
setSelectedCategories(validCategories.slice(0, 5));
}
} catch (_e) {
// Invalid JSON, ignore
}
}
}, [data.allCategories]);
// Save to sessionStorage when selection changes
useEffect(() => {
if (isClient) {
sessionStorage.setItem(
STORAGE_KEY_SELECTED,
JSON.stringify(selectedCategories)
);
}
}, [selectedCategories, isClient]);
// Save to sessionStorage when selection changes
useEffect(() => {
if (isClient) {
sessionStorage.setItem(
STORAGE_KEY_SELECTED,
JSON.stringify(selectedCategories),
);
}
}, [selectedCategories, isClient]);
// Filter data to show only selected categories with vibrant colors
const filteredCategories = useMemo(() => {
return selectedCategories
.map((id, index) => {
const cat = data.categories.find((c) => c.id === id);
if (!cat) return null;
return {
...cat,
color: CHART_COLORS[index % CHART_COLORS.length],
};
})
.filter(Boolean) as Array<{
id: string;
name: string;
icon: string | null;
color: string;
data: Record<string, number>;
}>;
}, [data.categories, selectedCategories]);
// Filter data to show only selected categories with vibrant colors
const filteredCategories = useMemo(() => {
return selectedCategories
.map((id, index) => {
const cat = data.categories.find((c) => c.id === id);
if (!cat) return null;
return {
...cat,
color: CHART_COLORS[index % CHART_COLORS.length],
};
})
.filter(Boolean) as Array<{
id: string;
name: string;
icon: string | null;
color: string;
data: Record<string, number>;
}>;
}, [data.categories, selectedCategories]);
// Filter chart data to include only selected categories
const filteredChartData = useMemo(() => {
if (filteredCategories.length === 0) {
return data.chartData.map((item) => ({ month: item.month }));
}
// Filter chart data to include only selected categories
const filteredChartData = useMemo(() => {
if (filteredCategories.length === 0) {
return data.chartData.map((item) => ({ month: item.month }));
}
return data.chartData.map((item) => {
const filtered: Record<string, number | string> = { month: item.month };
filteredCategories.forEach((category) => {
filtered[category.name] = item[category.name] || 0;
});
return filtered;
});
}, [data.chartData, filteredCategories]);
return data.chartData.map((item) => {
const filtered: Record<string, number | string> = { month: item.month };
filteredCategories.forEach((category) => {
filtered[category.name] = item[category.name] || 0;
});
return filtered;
});
}, [data.chartData, filteredCategories]);
// Build chart config dynamically from filtered categories
const chartConfig = useMemo(() => {
const config: ChartConfig = {};
// Build chart config dynamically from filtered categories
const chartConfig = useMemo(() => {
const config: ChartConfig = {};
filteredCategories.forEach((category) => {
config[category.name] = {
label: category.name,
color: category.color,
};
});
filteredCategories.forEach((category) => {
config[category.name] = {
label: category.name,
color: category.color,
};
});
return config;
}, [filteredCategories]);
return config;
}, [filteredCategories]);
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
};
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
};
const formatCurrencyCompact = (value: number) => {
if (value >= 1000) {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
notation: "compact",
}).format(value);
}
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const formatCurrencyCompact = (value: number) => {
if (value >= 1000) {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
notation: "compact",
}).format(value);
}
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const handleAddCategory = (categoryId: string) => {
if (
categoryId &&
!selectedCategories.includes(categoryId) &&
selectedCategories.length < 5
) {
setSelectedCategories([...selectedCategories, categoryId]);
setOpen(false);
}
};
const handleAddCategory = (categoryId: string) => {
if (
categoryId &&
!selectedCategories.includes(categoryId) &&
selectedCategories.length < 5
) {
setSelectedCategories([...selectedCategories, categoryId]);
setOpen(false);
}
};
const handleRemoveCategory = (categoryId: string) => {
setSelectedCategories(selectedCategories.filter((id) => id !== categoryId));
};
const handleRemoveCategory = (categoryId: string) => {
setSelectedCategories(selectedCategories.filter((id) => id !== categoryId));
};
const handleClearAll = () => {
setSelectedCategories([]);
};
const handleClearAll = () => {
setSelectedCategories([]);
};
const availableCategories = useMemo(() => {
return data.allCategories.filter(
(cat) => !selectedCategories.includes(cat.id)
);
}, [data.allCategories, selectedCategories]);
const availableCategories = useMemo(() => {
return data.allCategories.filter(
(cat) => !selectedCategories.includes(cat.id),
);
}, [data.allCategories, selectedCategories]);
const selectedCategoryDetails = useMemo(() => {
return selectedCategories
.map((id) => data.allCategories.find((cat) => cat.id === id))
.filter(Boolean);
}, [selectedCategories, data.allCategories]);
const selectedCategoryDetails = useMemo(() => {
return selectedCategories
.map((id) => data.allCategories.find((cat) => cat.id === id))
.filter(Boolean);
}, [selectedCategories, data.allCategories]);
const isEmpty = filteredCategories.length === 0;
const isEmpty = filteredCategories.length === 0;
// Group available categories by type
const { despesaCategories, receitaCategories } = useMemo(() => {
const despesa = availableCategories.filter((cat) => cat.type === "despesa");
const receita = availableCategories.filter((cat) => cat.type === "receita");
return { despesaCategories: despesa, receitaCategories: receita };
}, [availableCategories]);
// Group available categories by type
const { despesaCategories, receitaCategories } = useMemo(() => {
const despesa = availableCategories.filter((cat) => cat.type === "despesa");
const receita = availableCategories.filter((cat) => cat.type === "receita");
return { despesaCategories: despesa, receitaCategories: receita };
}, [availableCategories]);
if (!isClient) {
return null;
}
if (!isClient) {
return null;
}
return (
<Card className="h-auto">
<CardContent className="space-y-2.5">
<div className="space-y-2">
{selectedCategoryDetails.length > 0 && (
<div className="flex items-start justify-between gap-4 mb-4">
<div className="flex flex-wrap gap-2">
{selectedCategoryDetails.map((category) => {
if (!category) return null;
const IconComponent = category.icon
? getIconComponent(category.icon)
: null;
const colorIndex = selectedCategories.indexOf(category.id);
const color = CHART_COLORS[colorIndex % CHART_COLORS.length];
return (
<Card className="h-auto">
<CardContent className="space-y-2.5">
<div className="space-y-2">
{selectedCategoryDetails.length > 0 && (
<div className="flex items-start justify-between gap-4 mb-4">
<div className="flex flex-wrap gap-2">
{selectedCategoryDetails.map((category) => {
if (!category) return null;
const IconComponent = category.icon
? getIconComponent(category.icon)
: null;
const colorIndex = selectedCategories.indexOf(category.id);
const color = CHART_COLORS[colorIndex % CHART_COLORS.length];
return (
<div
key={category.id}
className="flex items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm"
style={{ borderColor: color }}
>
{IconComponent ? (
<IconComponent className="size-4" style={{ color }} />
) : (
<div
className="size-3 rounded-sm"
style={{ backgroundColor: color }}
/>
)}
<span className="text-foreground">{category.name}</span>
<Button
variant="ghost"
size="sm"
className="h-4 w-4 p-0 hover:bg-transparent"
onClick={() => handleRemoveCategory(category.id)}
>
<RiCloseLine className="size-3" />
</Button>
</div>
);
})}
</div>
<div className="flex items-center gap-2 shrink-0 pt-1.5">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{selectedCategories.length}/5 selecionadas
</span>
<Button
variant="ghost"
size="sm"
onClick={handleClearAll}
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
>
Limpar
</Button>
</div>
</div>
)}
return (
<div
key={category.id}
className="flex items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm"
style={{ borderColor: color }}
>
{IconComponent ? (
<IconComponent className="size-4" style={{ color }} />
) : (
<div
className="size-3 rounded-sm"
style={{ backgroundColor: color }}
/>
)}
<span className="text-foreground">{category.name}</span>
<Button
variant="ghost"
size="sm"
className="h-4 w-4 p-0 hover:bg-transparent"
onClick={() => handleRemoveCategory(category.id)}
>
<RiCloseLine className="size-3" />
</Button>
</div>
);
})}
</div>
<div className="flex items-center gap-2 shrink-0 pt-1.5">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{selectedCategories.length}/5 selecionadas
</span>
<Button
variant="ghost"
size="sm"
onClick={handleClearAll}
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
>
Limpar
</Button>
</div>
</div>
)}
{selectedCategories.length < 5 && availableCategories.length > 0 && (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between hover:scale-none"
>
Selecionar categorias
<RiArrowDownSLine className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-(--radix-popover-trigger-width) p-0"
align="start"
>
<Command>
<CommandInput placeholder="Pesquisar categoria..." />
<CommandList>
<CommandEmpty>Nenhuma categoria encontrada.</CommandEmpty>
{selectedCategories.length < 5 && availableCategories.length > 0 && (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between hover:scale-none"
>
Selecionar categorias
<RiArrowDownSLine className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-(--radix-popover-trigger-width) p-0"
align="start"
>
<Command>
<CommandInput placeholder="Pesquisar categoria..." />
<CommandList>
<CommandEmpty>Nenhuma categoria encontrada.</CommandEmpty>
{despesaCategories.length > 0 && (
<CommandGroup heading="Despesas">
{despesaCategories.map((category) => {
const IconComponent = category.icon
? getIconComponent(category.icon)
: null;
return (
<CommandItem
key={category.id}
value={category.name}
onSelect={() => handleAddCategory(category.id)}
className="gap-2"
>
{IconComponent ? (
<IconComponent className="size-4 text-red-600" />
) : (
<div className="size-3 rounded-sm bg-red-600" />
)}
<span>{category.name}</span>
</CommandItem>
);
})}
</CommandGroup>
)}
{despesaCategories.length > 0 && (
<CommandGroup heading="Despesas">
{despesaCategories.map((category) => {
const IconComponent = category.icon
? getIconComponent(category.icon)
: null;
return (
<CommandItem
key={category.id}
value={category.name}
onSelect={() => handleAddCategory(category.id)}
className="gap-2"
>
{IconComponent ? (
<IconComponent className="size-4 text-red-600" />
) : (
<div className="size-3 rounded-sm bg-red-600" />
)}
<span>{category.name}</span>
</CommandItem>
);
})}
</CommandGroup>
)}
{receitaCategories.length > 0 && (
<CommandGroup heading="Receitas">
{receitaCategories.map((category) => {
const IconComponent = category.icon
? getIconComponent(category.icon)
: null;
return (
<CommandItem
key={category.id}
value={category.name}
onSelect={() => handleAddCategory(category.id)}
className="gap-2"
>
{IconComponent ? (
<IconComponent className="size-4 text-green-600" />
) : (
<div className="size-3 rounded-sm bg-green-600" />
)}
<span>{category.name}</span>
</CommandItem>
);
})}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</div>
{receitaCategories.length > 0 && (
<CommandGroup heading="Receitas">
{receitaCategories.map((category) => {
const IconComponent = category.icon
? getIconComponent(category.icon)
: null;
return (
<CommandItem
key={category.id}
value={category.name}
onSelect={() => handleAddCategory(category.id)}
className="gap-2"
>
{IconComponent ? (
<IconComponent className="size-4 text-green-600" />
) : (
<div className="size-3 rounded-sm bg-green-600" />
)}
<span>{category.name}</span>
</CommandItem>
);
})}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</div>
{isEmpty ? (
<div className="h-[450px] flex items-center justify-center">
<WidgetEmptyState
icon={
<RiBarChartBoxLine className="size-6 text-muted-foreground" />
}
title="Selecione categorias para visualizar"
description="Escolha até 5 categorias para acompanhar o histórico dos últimos 8 meses, mês atual e próximo mês."
/>
</div>
) : (
<ChartContainer config={chartConfig} className="h-[450px] w-full">
<AreaChart
data={filteredChartData}
margin={{ top: 10, right: 20, left: 10, bottom: 0 }}
>
<defs>
{filteredCategories.map((category) => (
<linearGradient
key={`gradient-${category.id}`}
id={`gradient-${category.id}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor={category.color}
stopOpacity={0.4}
/>
<stop
offset="95%"
stopColor={category.color}
stopOpacity={0.05}
/>
</linearGradient>
))}
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
className="text-xs"
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
className="text-xs"
tickFormatter={formatCurrencyCompact}
/>
<ChartTooltip
content={({ active, payload }) => {
if (!active || !payload || payload.length === 0) {
return null;
}
{isEmpty ? (
<div className="h-[450px] flex items-center justify-center">
<WidgetEmptyState
icon={
<RiBarChartBoxLine className="size-6 text-muted-foreground" />
}
title="Selecione categorias para visualizar"
description="Escolha até 5 categorias para acompanhar o histórico dos últimos 8 meses, mês atual e próximo mês."
/>
</div>
) : (
<ChartContainer config={chartConfig} className="h-[450px] w-full">
<AreaChart
data={filteredChartData}
margin={{ top: 10, right: 20, left: 10, bottom: 0 }}
>
<defs>
{filteredCategories.map((category) => (
<linearGradient
key={`gradient-${category.id}`}
id={`gradient-${category.id}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor={category.color}
stopOpacity={0.4}
/>
<stop
offset="95%"
stopColor={category.color}
stopOpacity={0.05}
/>
</linearGradient>
))}
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
className="text-xs"
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
className="text-xs"
tickFormatter={formatCurrencyCompact}
/>
<ChartTooltip
content={({ active, payload }) => {
if (!active || !payload || payload.length === 0) {
return null;
}
// Sort payload by value (descending)
const sortedPayload = [...payload].sort(
(a, b) => (b.value as number) - (a.value as number)
);
// Sort payload by value (descending)
const sortedPayload = [...payload].sort(
(a, b) => (b.value as number) - (a.value as number),
);
return (
<div className="rounded-lg border bg-background p-3 shadow-lg">
<div className="mb-2 text-xs font-medium text-muted-foreground">
{payload[0].payload.month}
</div>
<div className="grid gap-1.5">
{sortedPayload
.filter((entry) => (entry.value as number) > 0)
.map((entry) => {
const config =
chartConfig[
entry.dataKey as keyof typeof chartConfig
];
const value = entry.value as number;
return (
<div className="rounded-lg border bg-background p-3 shadow-lg">
<div className="mb-2 text-xs font-medium text-muted-foreground">
{payload[0].payload.month}
</div>
<div className="grid gap-1.5">
{sortedPayload
.filter((entry) => (entry.value as number) > 0)
.map((entry) => {
const config =
chartConfig[
entry.dataKey as keyof typeof chartConfig
];
const value = entry.value as number;
return (
<div
key={entry.dataKey}
className="flex items-center justify-between gap-4"
>
<div className="flex items-center gap-2">
<div
className="h-2.5 w-2.5 rounded-sm shrink-0"
style={{ backgroundColor: config?.color }}
/>
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
{config?.label}
</span>
</div>
<span className="text-xs font-medium tabular-nums">
{formatCurrency(value)}
</span>
</div>
);
})}
</div>
</div>
);
}}
cursor={{
stroke: "hsl(var(--muted-foreground))",
strokeWidth: 1,
}}
/>
{filteredCategories.map((category) => (
<Area
key={category.id}
type="monotone"
dataKey={category.name}
stroke={category.color}
strokeWidth={1}
fill={`url(#gradient-${category.id})`}
fillOpacity={1}
dot={false}
activeDot={{
r: 5,
fill: category.color,
stroke: "hsl(var(--background))",
strokeWidth: 2,
}}
/>
))}
</AreaChart>
</ChartContainer>
)}
</CardContent>
</Card>
);
return (
<div
key={entry.dataKey}
className="flex items-center justify-between gap-4"
>
<div className="flex items-center gap-2">
<div
className="h-2.5 w-2.5 rounded-sm shrink-0"
style={{ backgroundColor: config?.color }}
/>
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
{config?.label}
</span>
</div>
<span className="text-xs font-medium tabular-nums">
{formatCurrency(value)}
</span>
</div>
);
})}
</div>
</div>
);
}}
cursor={{
stroke: "hsl(var(--muted-foreground))",
strokeWidth: 1,
}}
/>
{filteredCategories.map((category) => (
<Area
key={category.id}
type="monotone"
dataKey={category.name}
stroke={category.color}
strokeWidth={1}
fill={`url(#gradient-${category.id})`}
fillOpacity={1}
dot={false}
activeDot={{
r: 5,
fill: category.color,
stroke: "hsl(var(--background))",
strokeWidth: 2,
}}
/>
))}
</AreaChart>
</ChartContainer>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,287 +1,287 @@
"use client";
import {
closestCenter,
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
rectSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import {
RiCheckLine,
RiCloseLine,
RiDragMove2Line,
RiEyeOffLine,
} from "@remixicon/react";
import { useCallback, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { SortableWidget } from "@/components/dashboard/sortable-widget";
import { WidgetSettingsDialog } from "@/components/dashboard/widget-settings-dialog";
import { Button } from "@/components/ui/button";
import WidgetCard from "@/components/widget-card";
import type { DashboardData } from "@/lib/dashboard/fetch-dashboard-data";
import {
resetWidgetPreferences,
updateWidgetPreferences,
type WidgetPreferences,
resetWidgetPreferences,
updateWidgetPreferences,
type WidgetPreferences,
} from "@/lib/dashboard/widgets/actions";
import {
widgetsConfig,
type WidgetConfig,
type WidgetConfig,
widgetsConfig,
} from "@/lib/dashboard/widgets/widgets-config";
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
rectSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import {
RiCheckLine,
RiCloseLine,
RiDragMove2Line,
RiEyeOffLine,
} from "@remixicon/react";
import { useCallback, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
type DashboardGridEditableProps = {
data: DashboardData;
period: string;
initialPreferences: WidgetPreferences | null;
data: DashboardData;
period: string;
initialPreferences: WidgetPreferences | null;
};
export function DashboardGridEditable({
data,
period,
initialPreferences,
data,
period,
initialPreferences,
}: DashboardGridEditableProps) {
const [isEditing, setIsEditing] = useState(false);
const [isPending, startTransition] = useTransition();
const [isEditing, setIsEditing] = useState(false);
const [isPending, startTransition] = useTransition();
// Initialize widget order and hidden state
const defaultOrder = widgetsConfig.map((w) => w.id);
const [widgetOrder, setWidgetOrder] = useState<string[]>(
initialPreferences?.order ?? defaultOrder,
);
const [hiddenWidgets, setHiddenWidgets] = useState<string[]>(
initialPreferences?.hidden ?? [],
);
// Initialize widget order and hidden state
const defaultOrder = widgetsConfig.map((w) => w.id);
const [widgetOrder, setWidgetOrder] = useState<string[]>(
initialPreferences?.order ?? defaultOrder,
);
const [hiddenWidgets, setHiddenWidgets] = useState<string[]>(
initialPreferences?.hidden ?? [],
);
// Keep track of original state for cancel
const [originalOrder, setOriginalOrder] = useState(widgetOrder);
const [originalHidden, setOriginalHidden] = useState(hiddenWidgets);
// Keep track of original state for cancel
const [originalOrder, setOriginalOrder] = useState(widgetOrder);
const [originalHidden, setOriginalHidden] = useState(hiddenWidgets);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// Get ordered and visible widgets
const orderedWidgets = useMemo(() => {
// Create a map for quick lookup
const widgetMap = new Map(widgetsConfig.map((w) => [w.id, w]));
// Get ordered and visible widgets
const orderedWidgets = useMemo(() => {
// Create a map for quick lookup
const widgetMap = new Map(widgetsConfig.map((w) => [w.id, w]));
// Get widgets in order, filtering out hidden ones
const ordered: WidgetConfig[] = [];
for (const id of widgetOrder) {
const widget = widgetMap.get(id);
if (widget && !hiddenWidgets.includes(id)) {
ordered.push(widget);
}
}
// Get widgets in order, filtering out hidden ones
const ordered: WidgetConfig[] = [];
for (const id of widgetOrder) {
const widget = widgetMap.get(id);
if (widget && !hiddenWidgets.includes(id)) {
ordered.push(widget);
}
}
// Add any new widgets that might not be in the order yet
for (const widget of widgetsConfig) {
if (
!widgetOrder.includes(widget.id) &&
!hiddenWidgets.includes(widget.id)
) {
ordered.push(widget);
}
}
// Add any new widgets that might not be in the order yet
for (const widget of widgetsConfig) {
if (
!widgetOrder.includes(widget.id) &&
!hiddenWidgets.includes(widget.id)
) {
ordered.push(widget);
}
}
return ordered;
}, [widgetOrder, hiddenWidgets]);
return ordered;
}, [widgetOrder, hiddenWidgets]);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setWidgetOrder((items) => {
const oldIndex = items.indexOf(active.id as string);
const newIndex = items.indexOf(over.id as string);
return arrayMove(items, oldIndex, newIndex);
});
}
}, []);
if (over && active.id !== over.id) {
setWidgetOrder((items) => {
const oldIndex = items.indexOf(active.id as string);
const newIndex = items.indexOf(over.id as string);
return arrayMove(items, oldIndex, newIndex);
});
}
}, []);
const handleToggleWidget = useCallback((widgetId: string) => {
setHiddenWidgets((prev) =>
prev.includes(widgetId)
? prev.filter((id) => id !== widgetId)
: [...prev, widgetId],
);
}, []);
const handleToggleWidget = useCallback((widgetId: string) => {
setHiddenWidgets((prev) =>
prev.includes(widgetId)
? prev.filter((id) => id !== widgetId)
: [...prev, widgetId],
);
}, []);
const handleHideWidget = useCallback((widgetId: string) => {
setHiddenWidgets((prev) => [...prev, widgetId]);
}, []);
const handleHideWidget = useCallback((widgetId: string) => {
setHiddenWidgets((prev) => [...prev, widgetId]);
}, []);
const handleStartEditing = useCallback(() => {
setOriginalOrder(widgetOrder);
setOriginalHidden(hiddenWidgets);
setIsEditing(true);
}, [widgetOrder, hiddenWidgets]);
const handleStartEditing = useCallback(() => {
setOriginalOrder(widgetOrder);
setOriginalHidden(hiddenWidgets);
setIsEditing(true);
}, [widgetOrder, hiddenWidgets]);
const handleCancelEditing = useCallback(() => {
setWidgetOrder(originalOrder);
setHiddenWidgets(originalHidden);
setIsEditing(false);
}, [originalOrder, originalHidden]);
const handleCancelEditing = useCallback(() => {
setWidgetOrder(originalOrder);
setHiddenWidgets(originalHidden);
setIsEditing(false);
}, [originalOrder, originalHidden]);
const handleSave = useCallback(() => {
startTransition(async () => {
const result = await updateWidgetPreferences({
order: widgetOrder,
hidden: hiddenWidgets,
});
const handleSave = useCallback(() => {
startTransition(async () => {
const result = await updateWidgetPreferences({
order: widgetOrder,
hidden: hiddenWidgets,
});
if (result.success) {
toast.success("Preferências salvas!");
setIsEditing(false);
} else {
toast.error(result.error ?? "Erro ao salvar");
}
});
}, [widgetOrder, hiddenWidgets]);
if (result.success) {
toast.success("Preferências salvas!");
setIsEditing(false);
} else {
toast.error(result.error ?? "Erro ao salvar");
}
});
}, [widgetOrder, hiddenWidgets]);
const handleReset = useCallback(() => {
startTransition(async () => {
const result = await resetWidgetPreferences();
const handleReset = useCallback(() => {
startTransition(async () => {
const result = await resetWidgetPreferences();
if (result.success) {
setWidgetOrder(defaultOrder);
setHiddenWidgets([]);
toast.success("Preferências restauradas!");
} else {
toast.error(result.error ?? "Erro ao restaurar");
}
});
}, [defaultOrder]);
if (result.success) {
setWidgetOrder(defaultOrder);
setHiddenWidgets([]);
toast.success("Preferências restauradas!");
} else {
toast.error(result.error ?? "Erro ao restaurar");
}
});
}, [defaultOrder]);
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-end gap-2">
{isEditing ? (
<>
<Button
variant="outline"
size="sm"
onClick={handleCancelEditing}
disabled={isPending}
className="gap-2"
>
<RiCloseLine className="size-4" />
Cancelar
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={isPending}
className="gap-2"
>
<RiCheckLine className="size-4" />
Salvar
</Button>
</>
) : (
<>
<WidgetSettingsDialog
hiddenWidgets={hiddenWidgets}
onToggleWidget={handleToggleWidget}
onReset={handleReset}
/>
<Button
variant="outline"
size="sm"
onClick={handleStartEditing}
className="gap-2"
>
<RiDragMove2Line className="size-4" />
Reordenar
</Button>
</>
)}
</div>
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-end gap-2">
{isEditing ? (
<>
<Button
variant="outline"
size="sm"
onClick={handleCancelEditing}
disabled={isPending}
className="gap-2"
>
<RiCloseLine className="size-4" />
Cancelar
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={isPending}
className="gap-2"
>
<RiCheckLine className="size-4" />
Salvar
</Button>
</>
) : (
<>
<WidgetSettingsDialog
hiddenWidgets={hiddenWidgets}
onToggleWidget={handleToggleWidget}
onReset={handleReset}
/>
<Button
variant="outline"
size="sm"
onClick={handleStartEditing}
className="gap-2"
>
<RiDragMove2Line className="size-4" />
Reordenar
</Button>
</>
)}
</div>
{/* Grid */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={orderedWidgets.map((w) => w.id)}
strategy={rectSortingStrategy}
>
<section className="grid grid-cols-1 gap-3 @4xl/main:grid-cols-2 @6xl/main:grid-cols-3">
{orderedWidgets.map((widget) => (
<SortableWidget
key={widget.id}
id={widget.id}
isEditing={isEditing}
>
<div className="relative">
{isEditing && (
<div className="absolute inset-0 z-10 bg-background/50 backdrop-blur-[1px] rounded-lg border-2 border-dashed border-primary/50 flex items-center justify-center">
<div className="flex flex-col items-center gap-2">
<RiDragMove2Line className="size-8 text-primary" />
<span className="text-xs font-bold">
Arraste para mover
</span>
<Button
variant="destructive"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleHideWidget(widget.id);
}}
className="gap-1 mt-2"
>
<RiEyeOffLine className="size-4" />
Ocultar
</Button>
</div>
</div>
)}
<WidgetCard
title={widget.title}
subtitle={widget.subtitle}
icon={widget.icon}
action={widget.action}
>
{widget.component({ data, period })}
</WidgetCard>
</div>
</SortableWidget>
))}
</section>
</SortableContext>
</DndContext>
{/* Grid */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={orderedWidgets.map((w) => w.id)}
strategy={rectSortingStrategy}
>
<section className="grid grid-cols-1 gap-3 @4xl/main:grid-cols-2 @6xl/main:grid-cols-3">
{orderedWidgets.map((widget) => (
<SortableWidget
key={widget.id}
id={widget.id}
isEditing={isEditing}
>
<div className="relative">
{isEditing && (
<div className="absolute inset-0 z-10 bg-background/50 backdrop-blur-[1px] rounded-lg border-2 border-dashed border-primary/50 flex items-center justify-center">
<div className="flex flex-col items-center gap-2">
<RiDragMove2Line className="size-8 text-primary" />
<span className="text-xs font-bold">
Arraste para mover
</span>
<Button
variant="destructive"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleHideWidget(widget.id);
}}
className="gap-1 mt-2"
>
<RiEyeOffLine className="size-4" />
Ocultar
</Button>
</div>
</div>
)}
<WidgetCard
title={widget.title}
subtitle={widget.subtitle}
icon={widget.icon}
action={widget.action}
>
{widget.component({ data, period })}
</WidgetCard>
</div>
</SortableWidget>
))}
</section>
</SortableContext>
</DndContext>
{/* Hidden widgets indicator */}
{hiddenWidgets.length > 0 && !isEditing && (
<p className="text-center text-sm text-muted-foreground">
{hiddenWidgets.length} widget(s) oculto(s) {" "}
<button
onClick={handleReset}
className="text-primary hover:underline"
>
Restaurar todos
</button>
</p>
)}
</div>
);
{/* Hidden widgets indicator */}
{hiddenWidgets.length > 0 && !isEditing && (
<p className="text-center text-sm text-muted-foreground">
{hiddenWidgets.length} widget(s) oculto(s) {" "}
<button
onClick={handleReset}
className="text-primary hover:underline"
>
Restaurar todos
</button>
</p>
)}
</div>
);
}

View File

@@ -3,24 +3,24 @@ import type { DashboardData } from "@/lib/dashboard/fetch-dashboard-data";
import { widgetsConfig } from "@/lib/dashboard/widgets/widgets-config";
type DashboardGridProps = {
data: DashboardData;
period: string;
data: DashboardData;
period: string;
};
export function DashboardGrid({ data, period }: DashboardGridProps) {
return (
<section className="grid grid-cols-1 gap-3 @4xl/main:grid-cols-2 @6xl/main:grid-cols-3">
{widgetsConfig.map((widget) => (
<WidgetCard
key={widget.id}
title={widget.title}
subtitle={widget.subtitle}
icon={widget.icon}
action={widget.action}
>
{widget.component({ data, period })}
</WidgetCard>
))}
</section>
);
return (
<section className="grid grid-cols-1 gap-3 @4xl/main:grid-cols-2 @6xl/main:grid-cols-3">
{widgetsConfig.map((widget) => (
<WidgetCard
key={widget.id}
title={widget.title}
subtitle={widget.subtitle}
icon={widget.icon}
action={widget.action}
>
{widget.component({ data, period })}
</WidgetCard>
))}
</section>
);
}

View File

@@ -5,74 +5,77 @@ import MagnetLines from "../magnet-lines";
import { Card } from "../ui/card";
type DashboardWelcomeProps = {
name?: string | null;
disableMagnetlines?: boolean;
name?: string | null;
disableMagnetlines?: boolean;
};
const capitalizeFirstLetter = (value: string) =>
value.length > 0 ? value[0]?.toUpperCase() + value.slice(1) : value;
value.length > 0 ? value[0]?.toUpperCase() + value.slice(1) : value;
const formatCurrentDate = (date = new Date()) => {
const formatted = new Intl.DateTimeFormat("pt-BR", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
timeZone: "America/Sao_Paulo",
}).format(date);
const formatted = new Intl.DateTimeFormat("pt-BR", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
timeZone: "America/Sao_Paulo",
}).format(date);
return capitalizeFirstLetter(formatted);
return capitalizeFirstLetter(formatted);
};
const getGreeting = () => {
const now = new Date();
const now = new Date();
// Get hour in Brasilia timezone
const brasiliaHour = new Intl.DateTimeFormat("pt-BR", {
hour: "numeric",
hour12: false,
timeZone: "America/Sao_Paulo",
}).format(now);
// Get hour in Brasilia timezone
const brasiliaHour = new Intl.DateTimeFormat("pt-BR", {
hour: "numeric",
hour12: false,
timeZone: "America/Sao_Paulo",
}).format(now);
const hour = parseInt(brasiliaHour, 10);
const hour = parseInt(brasiliaHour, 10);
if (hour >= 5 && hour < 12) {
return "Bom dia";
} else if (hour >= 12 && hour < 18) {
return "Boa tarde";
} else {
return "Boa noite";
}
if (hour >= 5 && hour < 12) {
return "Bom dia";
} else if (hour >= 12 && hour < 18) {
return "Boa tarde";
} else {
return "Boa noite";
}
};
export function DashboardWelcome({ name, disableMagnetlines = false }: DashboardWelcomeProps) {
const displayName = name && name.trim().length > 0 ? name : "Administrador";
const formattedDate = formatCurrentDate();
const greeting = getGreeting();
export function DashboardWelcome({
name,
disableMagnetlines = false,
}: DashboardWelcomeProps) {
const displayName = name && name.trim().length > 0 ? name : "Administrador";
const formattedDate = formatCurrentDate();
const greeting = getGreeting();
return (
<Card
className={`${main_font.className} relative px-6 py-12 bg-welcome-banner border-none shadow-none overflow-hidden`}
>
<div className="absolute inset-0 flex items-center justify-center opacity-20 pointer-events-none">
<MagnetLines
rows={8}
columns={16}
containerSize="100%"
lineColor="currentColor"
lineWidth="0.4vmin"
lineHeight="5vmin"
baseAngle={0}
className="text-welcome-banner-foreground"
disabled={disableMagnetlines}
/>
</div>
<div className="relative tracking-tight text-welcome-banner-foreground">
<h1 className="text-xl font-medium">
{greeting}, {displayName}! <span aria-hidden="true">👋</span>
</h1>
<p className="mt-2 text-sm opacity-90">{formattedDate}</p>
</div>
</Card>
);
return (
<Card
className={`${main_font.className} relative px-6 py-12 bg-welcome-banner border-none shadow-none overflow-hidden`}
>
<div className="absolute inset-0 flex items-center justify-center opacity-20 pointer-events-none">
<MagnetLines
rows={8}
columns={16}
containerSize="100%"
lineColor="currentColor"
lineWidth="0.4vmin"
lineHeight="5vmin"
baseAngle={0}
className="text-welcome-banner-foreground"
disabled={disableMagnetlines}
/>
</div>
<div className="relative tracking-tight text-welcome-banner-foreground">
<h1 className="text-xl font-medium">
{greeting}, {displayName}! <span aria-hidden="true">👋</span>
</h1>
<p className="mt-2 text-sm opacity-90">{formattedDate}</p>
</div>
</Card>
);
}

View File

@@ -1,353 +1,353 @@
"use client";
import MoneyValues from "@/components/money-values";
import { ChartContainer, type ChartConfig } from "@/components/ui/chart";
import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category";
import {
buildCategoryInitials,
getCategoryBgColor,
getCategoryColor,
} from "@/lib/utils/category-colors";
import { getIconComponent } from "@/lib/utils/icons";
import { formatPeriodForUrl } from "@/lib/utils/period";
import {
RiArrowDownLine,
RiArrowUpLine,
RiExternalLinkLine,
RiListUnordered,
RiPieChart2Line,
RiPieChartLine,
RiWallet3Line,
RiArrowDownLine,
RiArrowUpLine,
RiExternalLinkLine,
RiListUnordered,
RiPieChart2Line,
RiPieChartLine,
RiWallet3Line,
} from "@remixicon/react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { Pie, PieChart, Tooltip } from "recharts";
import MoneyValues from "@/components/money-values";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category";
import {
buildCategoryInitials,
getCategoryBgColor,
getCategoryColor,
} from "@/lib/utils/category-colors";
import { getIconComponent } from "@/lib/utils/icons";
import { formatPeriodForUrl } from "@/lib/utils/period";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { WidgetEmptyState } from "../widget-empty-state";
type ExpensesByCategoryWidgetWithChartProps = {
data: ExpensesByCategoryData;
period: string;
data: ExpensesByCategoryData;
period: string;
};
const formatPercentage = (value: number) => {
return `${Math.abs(value).toFixed(0)}%`;
return `${Math.abs(value).toFixed(0)}%`;
};
const formatCurrency = (value: number) =>
new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
export function ExpensesByCategoryWidgetWithChart({
data,
period,
data,
period,
}: ExpensesByCategoryWidgetWithChartProps) {
const [activeTab, setActiveTab] = useState<"list" | "chart">("list");
const periodParam = formatPeriodForUrl(period);
const [activeTab, setActiveTab] = useState<"list" | "chart">("list");
const periodParam = formatPeriodForUrl(period);
// Configuração do chart com cores do CSS
const chartConfig = useMemo(() => {
const config: ChartConfig = {};
const colors = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
"var(--chart-1)",
"var(--chart-2)",
];
// Configuração do chart com cores do CSS
const chartConfig = useMemo(() => {
const config: ChartConfig = {};
const colors = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
"var(--chart-1)",
"var(--chart-2)",
];
if (data.categories.length <= 7) {
data.categories.forEach((category, index) => {
config[category.categoryId] = {
label: category.categoryName,
color: colors[index % colors.length],
};
});
} else {
// Top 7 + Outros
const top7 = data.categories.slice(0, 7);
top7.forEach((category, index) => {
config[category.categoryId] = {
label: category.categoryName,
color: colors[index % colors.length],
};
});
config["outros"] = {
label: "Outros",
color: "var(--chart-6)",
};
}
if (data.categories.length <= 7) {
data.categories.forEach((category, index) => {
config[category.categoryId] = {
label: category.categoryName,
color: colors[index % colors.length],
};
});
} else {
// Top 7 + Outros
const top7 = data.categories.slice(0, 7);
top7.forEach((category, index) => {
config[category.categoryId] = {
label: category.categoryName,
color: colors[index % colors.length],
};
});
config.outros = {
label: "Outros",
color: "var(--chart-6)",
};
}
return config;
}, [data.categories]);
return config;
}, [data.categories]);
// Preparar dados para o gráfico de pizza - Top 7 + Outros
const chartData = useMemo(() => {
if (data.categories.length <= 7) {
return data.categories.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
}
// Preparar dados para o gráfico de pizza - Top 7 + Outros
const chartData = useMemo(() => {
if (data.categories.length <= 7) {
return data.categories.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
}
// Pegar top 7 categorias
const top7 = data.categories.slice(0, 7);
const others = data.categories.slice(7);
// Pegar top 7 categorias
const top7 = data.categories.slice(0, 7);
const others = data.categories.slice(7);
// Somar o restante
const othersTotal = others.reduce((sum, cat) => sum + cat.currentAmount, 0);
const othersPercentage = others.reduce(
(sum, cat) => sum + cat.percentageOfTotal,
0
);
// Somar o restante
const othersTotal = others.reduce((sum, cat) => sum + cat.currentAmount, 0);
const othersPercentage = others.reduce(
(sum, cat) => sum + cat.percentageOfTotal,
0,
);
const top7Data = top7.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
const top7Data = top7.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
// Adicionar "Outros" se houver
if (others.length > 0) {
top7Data.push({
category: "outros",
name: "Outros",
value: othersTotal,
percentage: othersPercentage,
fill: chartConfig["outros"]?.color,
});
}
// Adicionar "Outros" se houver
if (others.length > 0) {
top7Data.push({
category: "outros",
name: "Outros",
value: othersTotal,
percentage: othersPercentage,
fill: chartConfig.outros?.color,
});
}
return top7Data;
}, [data.categories, chartConfig]);
return top7Data;
}, [data.categories, chartConfig]);
if (data.categories.length === 0) {
return (
<WidgetEmptyState
icon={<RiPieChartLine className="size-6 text-muted-foreground" />}
title="Nenhuma despesa encontrada"
description="Quando houver despesas registradas, elas aparecerão aqui."
/>
);
}
if (data.categories.length === 0) {
return (
<WidgetEmptyState
icon={<RiPieChartLine className="size-6 text-muted-foreground" />}
title="Nenhuma despesa encontrada"
description="Quando houver despesas registradas, elas aparecerão aqui."
/>
);
}
return (
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "list" | "chart")}
className="w-full"
>
<div className="flex items-center justify-between">
<TabsList className="grid grid-cols-2">
<TabsTrigger value="list" className="text-xs">
<RiListUnordered className="size-3.5 mr-1" />
Lista
</TabsTrigger>
<TabsTrigger value="chart" className="text-xs">
<RiPieChart2Line className="size-3.5 mr-1" />
Gráfico
</TabsTrigger>
</TabsList>
</div>
return (
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "list" | "chart")}
className="w-full"
>
<div className="flex items-center justify-between">
<TabsList className="grid grid-cols-2">
<TabsTrigger value="list" className="text-xs">
<RiListUnordered className="size-3.5 mr-1" />
Lista
</TabsTrigger>
<TabsTrigger value="chart" className="text-xs">
<RiPieChart2Line className="size-3.5 mr-1" />
Gráfico
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="list" className="mt-0">
<div className="flex flex-col px-0">
{data.categories.map((category, index) => {
const IconComponent = category.categoryIcon
? getIconComponent(category.categoryIcon)
: null;
const initials = buildCategoryInitials(category.categoryName);
const color = getCategoryColor(index);
const bgColor = getCategoryBgColor(index);
const hasIncrease =
category.percentageChange !== null &&
category.percentageChange > 0;
const hasDecrease =
category.percentageChange !== null &&
category.percentageChange < 0;
const hasBudget = category.budgetAmount !== null;
const budgetExceeded =
hasBudget &&
category.budgetUsedPercentage !== null &&
category.budgetUsedPercentage > 100;
<TabsContent value="list" className="mt-0">
<div className="flex flex-col px-0">
{data.categories.map((category, index) => {
const IconComponent = category.categoryIcon
? getIconComponent(category.categoryIcon)
: null;
const initials = buildCategoryInitials(category.categoryName);
const color = getCategoryColor(index);
const bgColor = getCategoryBgColor(index);
const hasIncrease =
category.percentageChange !== null &&
category.percentageChange > 0;
const hasDecrease =
category.percentageChange !== null &&
category.percentageChange < 0;
const hasBudget = category.budgetAmount !== null;
const budgetExceeded =
hasBudget &&
category.budgetUsedPercentage !== null &&
category.budgetUsedPercentage > 100;
const exceededAmount =
budgetExceeded && category.budgetAmount
? category.currentAmount - category.budgetAmount
: 0;
const exceededAmount =
budgetExceeded && category.budgetAmount
? category.currentAmount - category.budgetAmount
: 0;
return (
<div
key={category.categoryId}
className="flex flex-col py-2 border-b border-dashed last:border-0"
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg"
style={{ backgroundColor: bgColor }}
>
{IconComponent ? (
<IconComponent className="size-4" style={{ color }} />
) : (
<span
className="text-xs font-semibold uppercase"
style={{ color }}
>
{initials}
</span>
)}
</div>
return (
<div
key={category.categoryId}
className="flex flex-col py-2 border-b border-dashed last:border-0"
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg"
style={{ backgroundColor: bgColor }}
>
{IconComponent ? (
<IconComponent className="size-4" style={{ color }} />
) : (
<span
className="text-xs font-semibold uppercase"
style={{ color }}
>
{initials}
</span>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Link
href={`/categorias/${category.categoryId}?periodo=${periodParam}`}
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
>
<span className="truncate">
{category.categoryName}
</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
{formatPercentage(category.percentageOfTotal)} da
despesa total
</span>
</div>
</div>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Link
href={`/categorias/${category.categoryId}?periodo=${periodParam}`}
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
>
<span className="truncate">
{category.categoryName}
</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
{formatPercentage(category.percentageOfTotal)} da
despesa total
</span>
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
<MoneyValues
className="text-foreground"
amount={category.currentAmount}
/>
{category.percentageChange !== null && (
<span
className={`flex items-center gap-0.5 text-xs ${
hasIncrease
? "text-red-600 dark:text-red-500"
: hasDecrease
? "text-green-600 dark:text-green-500"
: "text-muted-foreground"
}`}
>
{hasIncrease && <RiArrowUpLine className="size-3" />}
{hasDecrease && <RiArrowDownLine className="size-3" />}
{formatPercentage(category.percentageChange)}
</span>
)}
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
<MoneyValues
className="text-foreground"
amount={category.currentAmount}
/>
{category.percentageChange !== null && (
<span
className={`flex items-center gap-0.5 text-xs ${
hasIncrease
? "text-red-600 dark:text-red-500"
: hasDecrease
? "text-green-600 dark:text-green-500"
: "text-muted-foreground"
}`}
>
{hasIncrease && <RiArrowUpLine className="size-3" />}
{hasDecrease && <RiArrowDownLine className="size-3" />}
{formatPercentage(category.percentageChange)}
</span>
)}
</div>
</div>
{hasBudget && category.budgetUsedPercentage !== null && (
<div className="ml-11 flex items-center gap-1.5 text-xs">
<RiWallet3Line
className={`size-3 ${
budgetExceeded
? "text-red-600"
: "text-blue-600 dark:text-blue-400"
}`}
/>
<span
className={
budgetExceeded
? "text-red-600"
: "text-blue-600 dark:text-blue-400"
}
>
{budgetExceeded ? (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite - excedeu em {formatCurrency(exceededAmount)}
</>
) : (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite
</>
)}
</span>
</div>
)}
</div>
);
})}
</div>
</TabsContent>
{hasBudget && category.budgetUsedPercentage !== null && (
<div className="ml-11 flex items-center gap-1.5 text-xs">
<RiWallet3Line
className={`size-3 ${
budgetExceeded
? "text-red-600"
: "text-blue-600 dark:text-blue-400"
}`}
/>
<span
className={
budgetExceeded
? "text-red-600"
: "text-blue-600 dark:text-blue-400"
}
>
{budgetExceeded ? (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite - excedeu em {formatCurrency(exceededAmount)}
</>
) : (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite
</>
)}
</span>
</div>
)}
</div>
);
})}
</div>
</TabsContent>
<TabsContent value="chart" className="mt-0">
<div className="flex items-center gap-4">
<ChartContainer config={chartConfig} className="h-[280px] flex-1">
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
labelLine={false}
label={(entry) => formatPercentage(entry.percentage)}
outerRadius={75}
dataKey="value"
nameKey="category"
/>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
{data.name}
</span>
<span className="font-bold text-foreground">
{formatCurrency(data.value)}
</span>
<span className="text-xs text-muted-foreground">
{formatPercentage(data.percentage)} do total
</span>
</div>
</div>
</div>
);
}
return null;
}}
/>
</PieChart>
</ChartContainer>
<TabsContent value="chart" className="mt-0">
<div className="flex items-center gap-4">
<ChartContainer config={chartConfig} className="h-[280px] flex-1">
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
labelLine={false}
label={(entry) => formatPercentage(entry.percentage)}
outerRadius={75}
dataKey="value"
nameKey="category"
/>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
{data.name}
</span>
<span className="font-bold text-foreground">
{formatCurrency(data.value)}
</span>
<span className="text-xs text-muted-foreground">
{formatPercentage(data.percentage)} do total
</span>
</div>
</div>
</div>
);
}
return null;
}}
/>
</PieChart>
</ChartContainer>
<div className="flex flex-col gap-2 min-w-[140px]">
{chartData.map((entry, index) => (
<div key={`legend-${index}`} className="flex items-center gap-2">
<div
className="size-3 rounded-sm shrink-0"
style={{ backgroundColor: entry.fill }}
/>
<span className="text-xs text-muted-foreground truncate">
{entry.name}
</span>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
);
<div className="flex flex-col gap-2 min-w-[140px]">
{chartData.map((entry, index) => (
<div key={`legend-${index}`} className="flex items-center gap-2">
<div
className="size-3 rounded-sm shrink-0"
style={{ backgroundColor: entry.fill }}
/>
<span className="text-xs text-muted-foreground truncate">
{entry.name}
</span>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
);
}

View File

@@ -1,179 +1,179 @@
import {
RiArrowDownLine,
RiArrowUpLine,
RiExternalLinkLine,
RiPieChartLine,
RiWallet3Line,
} from "@remixicon/react";
import Link from "next/link";
import MoneyValues from "@/components/money-values";
import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category";
import { getIconComponent } from "@/lib/utils/icons";
import { formatPeriodForUrl } from "@/lib/utils/period";
import {
RiArrowDownLine,
RiArrowUpLine,
RiExternalLinkLine,
RiPieChartLine,
RiWallet3Line,
} from "@remixicon/react";
import Link from "next/link";
import { WidgetEmptyState } from "../widget-empty-state";
type ExpensesByCategoryWidgetProps = {
data: ExpensesByCategoryData;
period: string;
data: ExpensesByCategoryData;
period: string;
};
const buildInitials = (value: string) => {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
return "CT";
}
if (parts.length === 1) {
const firstPart = parts[0];
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CT";
}
const firstChar = parts[0]?.[0] ?? "";
const secondChar = parts[1]?.[0] ?? "";
return `${firstChar}${secondChar}`.toUpperCase() || "CT";
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
return "CT";
}
if (parts.length === 1) {
const firstPart = parts[0];
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CT";
}
const firstChar = parts[0]?.[0] ?? "";
const secondChar = parts[1]?.[0] ?? "";
return `${firstChar}${secondChar}`.toUpperCase() || "CT";
};
const formatPercentage = (value: number) => {
return `${Math.abs(value).toFixed(0)}%`;
return `${Math.abs(value).toFixed(0)}%`;
};
export function ExpensesByCategoryWidget({
data,
period,
data,
period,
}: ExpensesByCategoryWidgetProps) {
const periodParam = formatPeriodForUrl(period);
const periodParam = formatPeriodForUrl(period);
if (data.categories.length === 0) {
return (
<WidgetEmptyState
icon={<RiPieChartLine className="size-6 text-muted-foreground" />}
title="Nenhuma despesa encontrada"
description="Quando houver despesas registradas, elas aparecerão aqui."
/>
);
}
if (data.categories.length === 0) {
return (
<WidgetEmptyState
icon={<RiPieChartLine className="size-6 text-muted-foreground" />}
title="Nenhuma despesa encontrada"
description="Quando houver despesas registradas, elas aparecerão aqui."
/>
);
}
return (
<div className="flex flex-col px-0">
{data.categories.map((category) => {
const IconComponent = category.categoryIcon
? getIconComponent(category.categoryIcon)
: null;
const initials = buildInitials(category.categoryName);
const hasIncrease =
category.percentageChange !== null && category.percentageChange > 0;
const hasDecrease =
category.percentageChange !== null && category.percentageChange < 0;
const hasBudget = category.budgetAmount !== null;
const budgetExceeded =
hasBudget &&
category.budgetUsedPercentage !== null &&
category.budgetUsedPercentage > 100;
return (
<div className="flex flex-col px-0">
{data.categories.map((category) => {
const IconComponent = category.categoryIcon
? getIconComponent(category.categoryIcon)
: null;
const initials = buildInitials(category.categoryName);
const hasIncrease =
category.percentageChange !== null && category.percentageChange > 0;
const hasDecrease =
category.percentageChange !== null && category.percentageChange < 0;
const hasBudget = category.budgetAmount !== null;
const budgetExceeded =
hasBudget &&
category.budgetUsedPercentage !== null &&
category.budgetUsedPercentage > 100;
const formatCurrency = (value: number) =>
new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
const formatCurrency = (value: number) =>
new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
const exceededAmount =
budgetExceeded && category.budgetAmount
? category.currentAmount - category.budgetAmount
: 0;
const exceededAmount =
budgetExceeded && category.budgetAmount
? category.currentAmount - category.budgetAmount
: 0;
return (
<div
key={category.categoryId}
className="flex flex-col py-2 border-b border-dashed last:border-0"
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted">
{IconComponent ? (
<IconComponent className="size-4 text-foreground" />
) : (
<span className="text-xs font-semibold uppercase text-muted-foreground">
{initials}
</span>
)}
</div>
return (
<div
key={category.categoryId}
className="flex flex-col py-2 border-b border-dashed last:border-0"
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted">
{IconComponent ? (
<IconComponent className="size-4 text-foreground" />
) : (
<span className="text-xs font-semibold uppercase text-muted-foreground">
{initials}
</span>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Link
href={`/categorias/${category.categoryId}?periodo=${periodParam}`}
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
>
<span className="truncate">{category.categoryName}</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
{formatPercentage(category.percentageOfTotal)} da despesa
total
</span>
</div>
</div>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Link
href={`/categorias/${category.categoryId}?periodo=${periodParam}`}
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
>
<span className="truncate">{category.categoryName}</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
{formatPercentage(category.percentageOfTotal)} da despesa
total
</span>
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
<MoneyValues
className="text-foreground"
amount={category.currentAmount}
/>
{category.percentageChange !== null && (
<span
className={`flex items-center gap-0.5 text-xs ${
hasIncrease
? "text-red-600 dark:text-red-500"
: hasDecrease
? "text-green-600 dark:text-green-500"
: "text-muted-foreground"
}`}
>
{hasIncrease && <RiArrowUpLine className="size-3" />}
{hasDecrease && <RiArrowDownLine className="size-3" />}
{formatPercentage(category.percentageChange)}
</span>
)}
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
<MoneyValues
className="text-foreground"
amount={category.currentAmount}
/>
{category.percentageChange !== null && (
<span
className={`flex items-center gap-0.5 text-xs ${
hasIncrease
? "text-red-600 dark:text-red-500"
: hasDecrease
? "text-green-600 dark:text-green-500"
: "text-muted-foreground"
}`}
>
{hasIncrease && <RiArrowUpLine className="size-3" />}
{hasDecrease && <RiArrowDownLine className="size-3" />}
{formatPercentage(category.percentageChange)}
</span>
)}
</div>
</div>
{hasBudget && category.budgetUsedPercentage !== null && (
<div className="ml-11 flex items-center gap-1.5 text-xs">
<RiWallet3Line
className={`size-3 ${
budgetExceeded
? "text-red-600"
: "text-blue-600 dark:text-blue-400"
}`}
/>
<span
className={
budgetExceeded
? "text-red-600"
: "text-blue-600 dark:text-blue-400"
}
>
{budgetExceeded ? (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite - excedeu em {formatCurrency(exceededAmount)}
</>
) : (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite
</>
)}
</span>
</div>
)}
</div>
);
})}
</div>
);
{hasBudget && category.budgetUsedPercentage !== null && (
<div className="ml-11 flex items-center gap-1.5 text-xs">
<RiWallet3Line
className={`size-3 ${
budgetExceeded
? "text-red-600"
: "text-blue-600 dark:text-blue-400"
}`}
/>
<span
className={
budgetExceeded
? "text-red-600"
: "text-blue-600 dark:text-blue-400"
}
>
{budgetExceeded ? (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite - excedeu em {formatCurrency(exceededAmount)}
</>
) : (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite
</>
)}
</span>
</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -1,356 +1,356 @@
"use client";
import MoneyValues from "@/components/money-values";
import { ChartContainer, type ChartConfig } from "@/components/ui/chart";
import type { IncomeByCategoryData } from "@/lib/dashboard/categories/income-by-category";
import {
buildCategoryInitials,
getCategoryBgColor,
getCategoryColor,
} from "@/lib/utils/category-colors";
import { getIconComponent } from "@/lib/utils/icons";
import { formatPeriodForUrl } from "@/lib/utils/period";
import {
RiArrowDownLine,
RiArrowUpLine,
RiExternalLinkLine,
RiListUnordered,
RiPieChart2Line,
RiPieChartLine,
RiWallet3Line,
RiArrowDownLine,
RiArrowUpLine,
RiExternalLinkLine,
RiListUnordered,
RiPieChart2Line,
RiPieChartLine,
RiWallet3Line,
} from "@remixicon/react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { Pie, PieChart, Tooltip } from "recharts";
import MoneyValues from "@/components/money-values";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
import type { IncomeByCategoryData } from "@/lib/dashboard/categories/income-by-category";
import {
buildCategoryInitials,
getCategoryBgColor,
getCategoryColor,
} from "@/lib/utils/category-colors";
import { getIconComponent } from "@/lib/utils/icons";
import { formatPeriodForUrl } from "@/lib/utils/period";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { WidgetEmptyState } from "../widget-empty-state";
type IncomeByCategoryWidgetWithChartProps = {
data: IncomeByCategoryData;
period: string;
data: IncomeByCategoryData;
period: string;
};
const formatPercentage = (value: number) => {
return `${Math.abs(value).toFixed(1)}%`;
return `${Math.abs(value).toFixed(1)}%`;
};
const formatCurrency = (value: number) =>
new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
export function IncomeByCategoryWidgetWithChart({
data,
period,
data,
period,
}: IncomeByCategoryWidgetWithChartProps) {
const [activeTab, setActiveTab] = useState<"list" | "chart">("list");
const periodParam = formatPeriodForUrl(period);
const [activeTab, setActiveTab] = useState<"list" | "chart">("list");
const periodParam = formatPeriodForUrl(period);
// Configuração do chart com cores do CSS
const chartConfig = useMemo(() => {
const config: ChartConfig = {};
const colors = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
"var(--chart-1)",
"var(--chart-2)",
];
// Configuração do chart com cores do CSS
const chartConfig = useMemo(() => {
const config: ChartConfig = {};
const colors = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
"var(--chart-1)",
"var(--chart-2)",
];
if (data.categories.length <= 7) {
data.categories.forEach((category, index) => {
config[category.categoryId] = {
label: category.categoryName,
color: colors[index % colors.length],
};
});
} else {
// Top 7 + Outros
const top7 = data.categories.slice(0, 7);
top7.forEach((category, index) => {
config[category.categoryId] = {
label: category.categoryName,
color: colors[index % colors.length],
};
});
config["outros"] = {
label: "Outros",
color: "var(--chart-6)",
};
}
if (data.categories.length <= 7) {
data.categories.forEach((category, index) => {
config[category.categoryId] = {
label: category.categoryName,
color: colors[index % colors.length],
};
});
} else {
// Top 7 + Outros
const top7 = data.categories.slice(0, 7);
top7.forEach((category, index) => {
config[category.categoryId] = {
label: category.categoryName,
color: colors[index % colors.length],
};
});
config.outros = {
label: "Outros",
color: "var(--chart-6)",
};
}
return config;
}, [data.categories]);
return config;
}, [data.categories]);
// Preparar dados para o gráfico de pizza - Top 7 + Outros
const chartData = useMemo(() => {
if (data.categories.length <= 7) {
return data.categories.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
}
// Preparar dados para o gráfico de pizza - Top 7 + Outros
const chartData = useMemo(() => {
if (data.categories.length <= 7) {
return data.categories.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
}
// Pegar top 7 categorias
const top7 = data.categories.slice(0, 7);
const others = data.categories.slice(7);
// Pegar top 7 categorias
const top7 = data.categories.slice(0, 7);
const others = data.categories.slice(7);
// Somar o restante
const othersTotal = others.reduce((sum, cat) => sum + cat.currentAmount, 0);
const othersPercentage = others.reduce(
(sum, cat) => sum + cat.percentageOfTotal,
0
);
// Somar o restante
const othersTotal = others.reduce((sum, cat) => sum + cat.currentAmount, 0);
const othersPercentage = others.reduce(
(sum, cat) => sum + cat.percentageOfTotal,
0,
);
const top7Data = top7.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
const top7Data = top7.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
// Adicionar "Outros" se houver
if (others.length > 0) {
top7Data.push({
category: "outros",
name: "Outros",
value: othersTotal,
percentage: othersPercentage,
fill: chartConfig["outros"]?.color,
});
}
// Adicionar "Outros" se houver
if (others.length > 0) {
top7Data.push({
category: "outros",
name: "Outros",
value: othersTotal,
percentage: othersPercentage,
fill: chartConfig.outros?.color,
});
}
return top7Data;
}, [data.categories, chartConfig]);
return top7Data;
}, [data.categories, chartConfig]);
if (data.categories.length === 0) {
return (
<WidgetEmptyState
icon={<RiPieChartLine className="size-6 text-muted-foreground" />}
title="Nenhuma receita encontrada"
description="Quando houver receitas registradas, elas aparecerão aqui."
/>
);
}
if (data.categories.length === 0) {
return (
<WidgetEmptyState
icon={<RiPieChartLine className="size-6 text-muted-foreground" />}
title="Nenhuma receita encontrada"
description="Quando houver receitas registradas, elas aparecerão aqui."
/>
);
}
return (
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "list" | "chart")}
className="w-full"
>
<div className="flex items-center justify-between">
<TabsList className="grid grid-cols-2">
<TabsTrigger value="list" className="text-xs">
<RiListUnordered className="size-3.5 mr-1" />
Lista
</TabsTrigger>
<TabsTrigger value="chart" className="text-xs">
<RiPieChart2Line className="size-3.5 mr-1" />
Gráfico
</TabsTrigger>
</TabsList>
</div>
return (
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "list" | "chart")}
className="w-full"
>
<div className="flex items-center justify-between">
<TabsList className="grid grid-cols-2">
<TabsTrigger value="list" className="text-xs">
<RiListUnordered className="size-3.5 mr-1" />
Lista
</TabsTrigger>
<TabsTrigger value="chart" className="text-xs">
<RiPieChart2Line className="size-3.5 mr-1" />
Gráfico
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="list" className="mt-0">
<div className="flex flex-col px-0">
{data.categories.map((category, index) => {
const IconComponent = category.categoryIcon
? getIconComponent(category.categoryIcon)
: null;
const initials = buildCategoryInitials(category.categoryName);
const color = getCategoryColor(index);
const bgColor = getCategoryBgColor(index);
const hasIncrease =
category.percentageChange !== null &&
category.percentageChange > 0;
const hasDecrease =
category.percentageChange !== null &&
category.percentageChange < 0;
const hasBudget = category.budgetAmount !== null;
const budgetExceeded =
hasBudget &&
category.budgetUsedPercentage !== null &&
category.budgetUsedPercentage > 100;
<TabsContent value="list" className="mt-0">
<div className="flex flex-col px-0">
{data.categories.map((category, index) => {
const IconComponent = category.categoryIcon
? getIconComponent(category.categoryIcon)
: null;
const initials = buildCategoryInitials(category.categoryName);
const color = getCategoryColor(index);
const bgColor = getCategoryBgColor(index);
const hasIncrease =
category.percentageChange !== null &&
category.percentageChange > 0;
const hasDecrease =
category.percentageChange !== null &&
category.percentageChange < 0;
const hasBudget = category.budgetAmount !== null;
const budgetExceeded =
hasBudget &&
category.budgetUsedPercentage !== null &&
category.budgetUsedPercentage > 100;
const exceededAmount =
budgetExceeded && category.budgetAmount
? category.currentAmount - category.budgetAmount
: 0;
const exceededAmount =
budgetExceeded && category.budgetAmount
? category.currentAmount - category.budgetAmount
: 0;
return (
<div
key={category.categoryId}
className="flex flex-col gap-1.5 py-2 border-b border-dashed last:border-0"
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg"
style={{ backgroundColor: bgColor }}
>
{IconComponent ? (
<IconComponent className="size-4" style={{ color }} />
) : (
<span
className="text-xs font-semibold uppercase"
style={{ color }}
>
{initials}
</span>
)}
</div>
return (
<div
key={category.categoryId}
className="flex flex-col gap-1.5 py-2 border-b border-dashed last:border-0"
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg"
style={{ backgroundColor: bgColor }}
>
{IconComponent ? (
<IconComponent className="size-4" style={{ color }} />
) : (
<span
className="text-xs font-semibold uppercase"
style={{ color }}
>
{initials}
</span>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Link
href={`/categorias/${category.categoryId}?periodo=${periodParam}`}
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
>
<span className="truncate">
{category.categoryName}
</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
{formatPercentage(category.percentageOfTotal)} da
receita total
</span>
</div>
</div>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Link
href={`/categorias/${category.categoryId}?periodo=${periodParam}`}
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
>
<span className="truncate">
{category.categoryName}
</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
{formatPercentage(category.percentageOfTotal)} da
receita total
</span>
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
<MoneyValues
className="text-foreground"
amount={category.currentAmount}
/>
{category.percentageChange !== null && (
<span
className={`flex items-center gap-0.5 text-xs ${
hasIncrease
? "text-green-600 dark:text-green-500"
: hasDecrease
? "text-red-600 dark:text-red-500"
: "text-muted-foreground"
}`}
>
{hasIncrease && <RiArrowUpLine className="size-3" />}
{hasDecrease && <RiArrowDownLine className="size-3" />}
{formatPercentage(category.percentageChange)}
</span>
)}
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
<MoneyValues
className="text-foreground"
amount={category.currentAmount}
/>
{category.percentageChange !== null && (
<span
className={`flex items-center gap-0.5 text-xs ${
hasIncrease
? "text-green-600 dark:text-green-500"
: hasDecrease
? "text-red-600 dark:text-red-500"
: "text-muted-foreground"
}`}
>
{hasIncrease && <RiArrowUpLine className="size-3" />}
{hasDecrease && <RiArrowDownLine className="size-3" />}
{formatPercentage(category.percentageChange)}
</span>
)}
</div>
</div>
{hasBudget &&
category.budgetUsedPercentage !== null &&
category.budgetAmount !== null && (
<div className="ml-11 flex items-center gap-1.5 text-xs">
<RiWallet3Line
className={`size-3 ${
budgetExceeded
? "text-red-600"
: "text-blue-600 dark:text-blue-400"
}`}
/>
<span
className={
budgetExceeded
? "text-red-600"
: "text-blue-600 dark:text-blue-400"
}
>
{budgetExceeded ? (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite {formatCurrency(category.budgetAmount)} -
excedeu em {formatCurrency(exceededAmount)}
</>
) : (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite {formatCurrency(category.budgetAmount)}
</>
)}
</span>
</div>
)}
</div>
);
})}
</div>
</TabsContent>
{hasBudget &&
category.budgetUsedPercentage !== null &&
category.budgetAmount !== null && (
<div className="ml-11 flex items-center gap-1.5 text-xs">
<RiWallet3Line
className={`size-3 ${
budgetExceeded
? "text-red-600"
: "text-blue-600 dark:text-blue-400"
}`}
/>
<span
className={
budgetExceeded
? "text-red-600"
: "text-blue-600 dark:text-blue-400"
}
>
{budgetExceeded ? (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite {formatCurrency(category.budgetAmount)} -
excedeu em {formatCurrency(exceededAmount)}
</>
) : (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite {formatCurrency(category.budgetAmount)}
</>
)}
</span>
</div>
)}
</div>
);
})}
</div>
</TabsContent>
<TabsContent value="chart" className="mt-0">
<div className="flex items-center gap-4">
<ChartContainer config={chartConfig} className="h-[280px] flex-1">
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
labelLine={false}
label={(entry) => formatPercentage(entry.percentage)}
outerRadius={75}
dataKey="value"
nameKey="category"
/>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
{data.name}
</span>
<span className="font-bold text-foreground">
{formatCurrency(data.value)}
</span>
<span className="text-xs text-muted-foreground">
{formatPercentage(data.percentage)} do total
</span>
</div>
</div>
</div>
);
}
return null;
}}
/>
</PieChart>
</ChartContainer>
<TabsContent value="chart" className="mt-0">
<div className="flex items-center gap-4">
<ChartContainer config={chartConfig} className="h-[280px] flex-1">
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
labelLine={false}
label={(entry) => formatPercentage(entry.percentage)}
outerRadius={75}
dataKey="value"
nameKey="category"
/>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
{data.name}
</span>
<span className="font-bold text-foreground">
{formatCurrency(data.value)}
</span>
<span className="text-xs text-muted-foreground">
{formatPercentage(data.percentage)} do total
</span>
</div>
</div>
</div>
);
}
return null;
}}
/>
</PieChart>
</ChartContainer>
<div className="flex flex-col gap-2 min-w-[140px]">
{chartData.map((entry, index) => (
<div key={`legend-${index}`} className="flex items-center gap-2">
<div
className="size-3 rounded-sm shrink-0"
style={{ backgroundColor: entry.fill }}
/>
<span className="text-xs text-muted-foreground truncate">
{entry.name}
</span>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
);
<div className="flex flex-col gap-2 min-w-[140px]">
{chartData.map((entry, index) => (
<div key={`legend-${index}`} className="flex items-center gap-2">
<div
className="size-3 rounded-sm shrink-0"
style={{ backgroundColor: entry.fill }}
/>
<span className="text-xs text-muted-foreground truncate">
{entry.name}
</span>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
);
}

View File

@@ -1,182 +1,182 @@
import {
RiArrowDownLine,
RiArrowUpLine,
RiExternalLinkLine,
RiPieChartLine,
RiWallet3Line,
} from "@remixicon/react";
import Link from "next/link";
import MoneyValues from "@/components/money-values";
import type { IncomeByCategoryData } from "@/lib/dashboard/categories/income-by-category";
import { getIconComponent } from "@/lib/utils/icons";
import { formatPeriodForUrl } from "@/lib/utils/period";
import {
RiArrowDownLine,
RiArrowUpLine,
RiExternalLinkLine,
RiPieChartLine,
RiWallet3Line,
} from "@remixicon/react";
import Link from "next/link";
import { WidgetEmptyState } from "../widget-empty-state";
type IncomeByCategoryWidgetProps = {
data: IncomeByCategoryData;
period: string;
data: IncomeByCategoryData;
period: string;
};
const buildInitials = (value: string) => {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
return "CT";
}
if (parts.length === 1) {
const firstPart = parts[0];
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CT";
}
const firstChar = parts[0]?.[0] ?? "";
const secondChar = parts[1]?.[0] ?? "";
return `${firstChar}${secondChar}`.toUpperCase() || "CT";
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
return "CT";
}
if (parts.length === 1) {
const firstPart = parts[0];
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CT";
}
const firstChar = parts[0]?.[0] ?? "";
const secondChar = parts[1]?.[0] ?? "";
return `${firstChar}${secondChar}`.toUpperCase() || "CT";
};
const formatPercentage = (value: number) => {
return `${Math.abs(value).toFixed(1)}%`;
return `${Math.abs(value).toFixed(1)}%`;
};
export function IncomeByCategoryWidget({
data,
period,
data,
period,
}: IncomeByCategoryWidgetProps) {
const periodParam = formatPeriodForUrl(period);
const periodParam = formatPeriodForUrl(period);
if (data.categories.length === 0) {
return (
<WidgetEmptyState
icon={<RiPieChartLine className="size-6 text-muted-foreground" />}
title="Nenhuma receita encontrada"
description="Quando houver receitas registradas, elas aparecerão aqui."
/>
);
}
if (data.categories.length === 0) {
return (
<WidgetEmptyState
icon={<RiPieChartLine className="size-6 text-muted-foreground" />}
title="Nenhuma receita encontrada"
description="Quando houver receitas registradas, elas aparecerão aqui."
/>
);
}
return (
<div className="flex flex-col gap-2 px-0">
{data.categories.map((category) => {
const IconComponent = category.categoryIcon
? getIconComponent(category.categoryIcon)
: null;
const initials = buildInitials(category.categoryName);
const hasIncrease =
category.percentageChange !== null && category.percentageChange > 0;
const hasDecrease =
category.percentageChange !== null && category.percentageChange < 0;
const hasBudget = category.budgetAmount !== null;
const budgetExceeded =
hasBudget &&
category.budgetUsedPercentage !== null &&
category.budgetUsedPercentage > 100;
return (
<div className="flex flex-col gap-2 px-0">
{data.categories.map((category) => {
const IconComponent = category.categoryIcon
? getIconComponent(category.categoryIcon)
: null;
const initials = buildInitials(category.categoryName);
const hasIncrease =
category.percentageChange !== null && category.percentageChange > 0;
const hasDecrease =
category.percentageChange !== null && category.percentageChange < 0;
const hasBudget = category.budgetAmount !== null;
const budgetExceeded =
hasBudget &&
category.budgetUsedPercentage !== null &&
category.budgetUsedPercentage > 100;
const formatCurrency = (value: number) =>
new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
const formatCurrency = (value: number) =>
new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
const exceededAmount =
budgetExceeded && category.budgetAmount
? category.currentAmount - category.budgetAmount
: 0;
const exceededAmount =
budgetExceeded && category.budgetAmount
? category.currentAmount - category.budgetAmount
: 0;
return (
<div
key={category.categoryId}
className="flex flex-col gap-1.5 py-2 border-b border-dashed last:border-0"
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted">
{IconComponent ? (
<IconComponent className="size-4 text-foreground" />
) : (
<span className="text-xs font-semibold uppercase text-muted-foreground">
{initials}
</span>
)}
</div>
return (
<div
key={category.categoryId}
className="flex flex-col gap-1.5 py-2 border-b border-dashed last:border-0"
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted">
{IconComponent ? (
<IconComponent className="size-4 text-foreground" />
) : (
<span className="text-xs font-semibold uppercase text-muted-foreground">
{initials}
</span>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Link
href={`/categorias/${category.categoryId}?periodo=${periodParam}`}
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
>
<span className="truncate">{category.categoryName}</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
{formatPercentage(category.percentageOfTotal)} da receita
total
</span>
</div>
</div>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Link
href={`/categorias/${category.categoryId}?periodo=${periodParam}`}
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
>
<span className="truncate">{category.categoryName}</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
{formatPercentage(category.percentageOfTotal)} da receita
total
</span>
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
<MoneyValues
className="text-foreground"
amount={category.currentAmount}
/>
{category.percentageChange !== null && (
<span
className={`flex items-center gap-0.5 text-xs ${
hasIncrease
? "text-green-600 dark:text-green-500"
: hasDecrease
? "text-red-600 dark:text-red-500"
: "text-muted-foreground"
}`}
>
{hasIncrease && <RiArrowUpLine className="size-3" />}
{hasDecrease && <RiArrowDownLine className="size-3" />}
{formatPercentage(category.percentageChange)}
</span>
)}
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
<MoneyValues
className="text-foreground"
amount={category.currentAmount}
/>
{category.percentageChange !== null && (
<span
className={`flex items-center gap-0.5 text-xs ${
hasIncrease
? "text-green-600 dark:text-green-500"
: hasDecrease
? "text-red-600 dark:text-red-500"
: "text-muted-foreground"
}`}
>
{hasIncrease && <RiArrowUpLine className="size-3" />}
{hasDecrease && <RiArrowDownLine className="size-3" />}
{formatPercentage(category.percentageChange)}
</span>
)}
</div>
</div>
{hasBudget &&
category.budgetUsedPercentage !== null &&
category.budgetAmount !== null && (
<div className="ml-11 flex items-center gap-1.5 text-xs">
<RiWallet3Line
className={`size-3 ${
budgetExceeded
? "text-red-600"
: "text-blue-600 dark:text-blue-400"
}`}
/>
<span
className={
budgetExceeded
? "text-red-600"
: "text-blue-600 dark:text-blue-400"
}
>
{budgetExceeded ? (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite {formatCurrency(category.budgetAmount)} - excedeu
em {formatCurrency(exceededAmount)}
</>
) : (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite {formatCurrency(category.budgetAmount)}
</>
)}
</span>
</div>
)}
</div>
);
})}
</div>
);
{hasBudget &&
category.budgetUsedPercentage !== null &&
category.budgetAmount !== null && (
<div className="ml-11 flex items-center gap-1.5 text-xs">
<RiWallet3Line
className={`size-3 ${
budgetExceeded
? "text-red-600"
: "text-blue-600 dark:text-blue-400"
}`}
/>
<span
className={
budgetExceeded
? "text-red-600"
: "text-blue-600 dark:text-blue-400"
}
>
{budgetExceeded ? (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite {formatCurrency(category.budgetAmount)} - excedeu
em {formatCurrency(exceededAmount)}
</>
) : (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite {formatCurrency(category.budgetAmount)}
</>
)}
</span>
</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -1,175 +1,175 @@
"use client";
import { RiLineChartLine } from "@remixicon/react";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import { CardContent } from "@/components/ui/card";
import {
ChartContainer,
ChartTooltip,
type ChartConfig,
type ChartConfig,
ChartContainer,
ChartTooltip,
} from "@/components/ui/chart";
import { WidgetEmptyState } from "@/components/widget-empty-state";
import type { IncomeExpenseBalanceData } from "@/lib/dashboard/income-expense-balance";
import { RiLineChartLine } from "@remixicon/react";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
type IncomeExpenseBalanceWidgetProps = {
data: IncomeExpenseBalanceData;
data: IncomeExpenseBalanceData;
};
const chartConfig = {
receita: {
label: "Receita",
color: "var(--chart-1)",
},
despesa: {
label: "Despesa",
color: "var(--chart-2)",
},
balanco: {
label: "Balanço",
color: "var(--chart-3)",
},
receita: {
label: "Receita",
color: "var(--chart-1)",
},
despesa: {
label: "Despesa",
color: "var(--chart-2)",
},
balanco: {
label: "Balanço",
color: "var(--chart-3)",
},
} satisfies ChartConfig;
export function IncomeExpenseBalanceWidget({
data,
data,
}: IncomeExpenseBalanceWidgetProps) {
const chartData = data.months.map((month) => ({
month: month.monthLabel,
receita: month.income,
despesa: month.expense,
balanco: month.balance,
}));
const chartData = data.months.map((month) => ({
month: month.monthLabel,
receita: month.income,
despesa: month.expense,
balanco: month.balance,
}));
// Verifica se todos os valores são zero
const isEmpty = chartData.every(
(item) => item.receita === 0 && item.despesa === 0 && item.balanco === 0
);
// Verifica se todos os valores são zero
const isEmpty = chartData.every(
(item) => item.receita === 0 && item.despesa === 0 && item.balanco === 0,
);
if (isEmpty) {
return (
<CardContent className="px-0">
<WidgetEmptyState
icon={<RiLineChartLine className="size-6 text-muted-foreground" />}
title="Nenhuma movimentação financeira no período"
description="Registre receitas e despesas para visualizar o balanço mensal."
/>
</CardContent>
);
}
if (isEmpty) {
return (
<CardContent className="px-0">
<WidgetEmptyState
icon={<RiLineChartLine className="size-6 text-muted-foreground" />}
title="Nenhuma movimentação financeira no período"
description="Registre receitas e despesas para visualizar o balanço mensal."
/>
</CardContent>
);
}
return (
<CardContent className="space-y-4 px-0">
<ChartContainer
config={chartConfig}
className="h-[270px] w-full aspect-auto"
>
<BarChart
data={chartData}
margin={{ top: 20, right: 10, left: 10, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<ChartTooltip
content={({ active, payload }) => {
if (!active || !payload || payload.length === 0) {
return null;
}
return (
<CardContent className="space-y-4 px-0">
<ChartContainer
config={chartConfig}
className="h-[270px] w-full aspect-auto"
>
<BarChart
data={chartData}
margin={{ top: 20, right: 10, left: 10, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<ChartTooltip
content={({ active, payload }) => {
if (!active || !payload || payload.length === 0) {
return null;
}
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
};
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
};
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid gap-2">
{payload.map((entry) => {
const config =
chartConfig[entry.dataKey as keyof typeof chartConfig];
const value = entry.value as number;
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid gap-2">
{payload.map((entry) => {
const config =
chartConfig[entry.dataKey as keyof typeof chartConfig];
const value = entry.value as number;
return (
<div
key={entry.dataKey}
className="flex items-center gap-2"
>
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: config?.color }}
/>
<span className="text-xs text-muted-foreground">
{config?.label}:
</span>
<span className="text-xs font-medium">
{formatCurrency(value)}
</span>
</div>
);
})}
</div>
</div>
);
}}
cursor={{ fill: "hsl(var(--muted))", opacity: 0.3 }}
/>
<Bar
dataKey="receita"
fill={chartConfig.receita.color}
radius={[4, 4, 0, 0]}
maxBarSize={60}
/>
<Bar
dataKey="despesa"
fill={chartConfig.despesa.color}
radius={[4, 4, 0, 0]}
maxBarSize={60}
/>
<Bar
dataKey="balanco"
fill={chartConfig.balanco.color}
radius={[4, 4, 0, 0]}
maxBarSize={60}
/>
</BarChart>
</ChartContainer>
<div className="flex items-center justify-center gap-6">
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: chartConfig.receita.color }}
/>
<span className="text-sm text-muted-foreground">
{chartConfig.receita.label}
</span>
</div>
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: chartConfig.despesa.color }}
/>
<span className="text-sm text-muted-foreground">
{chartConfig.despesa.label}
</span>
</div>
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: chartConfig.balanco.color }}
/>
<span className="text-sm text-muted-foreground">
{chartConfig.balanco.label}
</span>
</div>
</div>
</CardContent>
);
return (
<div
key={entry.dataKey}
className="flex items-center gap-2"
>
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: config?.color }}
/>
<span className="text-xs text-muted-foreground">
{config?.label}:
</span>
<span className="text-xs font-medium">
{formatCurrency(value)}
</span>
</div>
);
})}
</div>
</div>
);
}}
cursor={{ fill: "hsl(var(--muted))", opacity: 0.3 }}
/>
<Bar
dataKey="receita"
fill={chartConfig.receita.color}
radius={[4, 4, 0, 0]}
maxBarSize={60}
/>
<Bar
dataKey="despesa"
fill={chartConfig.despesa.color}
radius={[4, 4, 0, 0]}
maxBarSize={60}
/>
<Bar
dataKey="balanco"
fill={chartConfig.balanco.color}
radius={[4, 4, 0, 0]}
maxBarSize={60}
/>
</BarChart>
</ChartContainer>
<div className="flex items-center justify-center gap-6">
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: chartConfig.receita.color }}
/>
<span className="text-sm text-muted-foreground">
{chartConfig.receita.label}
</span>
</div>
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: chartConfig.despesa.color }}
/>
<span className="text-sm text-muted-foreground">
{chartConfig.despesa.label}
</span>
</div>
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: chartConfig.balanco.color }}
/>
<span className="text-sm text-muted-foreground">
{chartConfig.balanco.label}
</span>
</div>
</div>
</CardContent>
);
}

View File

@@ -1,52 +1,52 @@
"use client";
import { RiPieChartLine } from "@remixicon/react";
import MoneyValues from "@/components/money-values";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { RiPieChartLine } from "@remixicon/react";
type AnalysisSummaryPanelProps = {
totalInstallments: number;
grandTotal: number;
selectedCount: number;
totalInstallments: number;
grandTotal: number;
selectedCount: number;
};
export function AnalysisSummaryPanel({
totalInstallments,
grandTotal,
selectedCount,
totalInstallments,
grandTotal,
selectedCount,
}: AnalysisSummaryPanelProps) {
return (
<Card className="border-primary/20">
<CardHeader className="border-b">
<div className="flex items-center gap-2">
<RiPieChartLine className="size-4 text-primary" />
<CardTitle className="text-base">Resumo</CardTitle>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3 pt-4">
{/* Total geral */}
<div className="flex flex-col items-center gap-2 rounded-lg bg-primary/10 p-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total Selecionado
</p>
<MoneyValues
amount={grandTotal}
className="text-2xl font-bold text-primary"
/>
<p className="text-xs text-muted-foreground">
{selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}
</p>
</div>
return (
<Card className="border-primary/20">
<CardHeader className="border-b">
<div className="flex items-center gap-2">
<RiPieChartLine className="size-4 text-primary" />
<CardTitle className="text-base">Resumo</CardTitle>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3 pt-4">
{/* Total geral */}
<div className="flex flex-col items-center gap-2 rounded-lg bg-primary/10 p-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total Selecionado
</p>
<MoneyValues
amount={grandTotal}
className="text-2xl font-bold text-primary"
/>
<p className="text-xs text-muted-foreground">
{selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}
</p>
</div>
{/* Mensagem quando nada está selecionado */}
{selectedCount === 0 && (
<div className="rounded-lg bg-muted/50 p-3 text-center">
<p className="text-xs text-muted-foreground">
Selecione parcelas para ver o resumo
</p>
</div>
)}
</CardContent>
</Card>
);
{/* Mensagem quando nada está selecionado */}
{selectedCount === 0 && (
<div className="rounded-lg bg-muted/50 p-3 text-center">
<p className="text-xs text-muted-foreground">
Selecione parcelas para ver o resumo
</p>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,210 +1,210 @@
"use client";
import {
RiCalculatorLine,
RiCheckboxBlankLine,
RiCheckboxLine,
} from "@remixicon/react";
import { useMemo, useState } from "react";
import MoneyValues from "@/components/money-values";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
RiCalculatorLine,
RiCheckboxBlankLine,
RiCheckboxLine,
} from "@remixicon/react";
import { useMemo, useState } from "react";
import { InstallmentGroupCard } from "./installment-group-card";
import type { InstallmentAnalysisData } from "./types";
type InstallmentAnalysisPageProps = {
data: InstallmentAnalysisData;
data: InstallmentAnalysisData;
};
export function InstallmentAnalysisPage({
data,
data,
}: InstallmentAnalysisPageProps) {
// Estado para parcelas selecionadas: Map<seriesId, Set<installmentId>>
const [selectedInstallments, setSelectedInstallments] = useState<
Map<string, Set<string>>
>(new Map());
// Estado para parcelas selecionadas: Map<seriesId, Set<installmentId>>
const [selectedInstallments, setSelectedInstallments] = useState<
Map<string, Set<string>>
>(new Map());
// Calcular se está tudo selecionado (apenas parcelas não pagas)
const isAllSelected = useMemo(() => {
const allInstallmentsSelected = data.installmentGroups.every((group) => {
const groupSelection = selectedInstallments.get(group.seriesId);
const unpaidInstallments = group.pendingInstallments.filter(
(i) => !i.isSettled
);
if (!groupSelection || unpaidInstallments.length === 0) return false;
return groupSelection.size === unpaidInstallments.length;
});
// Calcular se está tudo selecionado (apenas parcelas não pagas)
const isAllSelected = useMemo(() => {
const allInstallmentsSelected = data.installmentGroups.every((group) => {
const groupSelection = selectedInstallments.get(group.seriesId);
const unpaidInstallments = group.pendingInstallments.filter(
(i) => !i.isSettled,
);
if (!groupSelection || unpaidInstallments.length === 0) return false;
return groupSelection.size === unpaidInstallments.length;
});
return allInstallmentsSelected && data.installmentGroups.length > 0;
}, [selectedInstallments, data]);
return allInstallmentsSelected && data.installmentGroups.length > 0;
}, [selectedInstallments, data]);
// Função para selecionar/desselecionar tudo
const toggleSelectAll = () => {
if (isAllSelected) {
// Desmarcar tudo
setSelectedInstallments(new Map());
} else {
// Marcar tudo (exceto parcelas já pagas)
const newInstallments = new Map<string, Set<string>>();
data.installmentGroups.forEach((group) => {
const unpaidIds = group.pendingInstallments
.filter((i) => !i.isSettled)
.map((i) => i.id);
if (unpaidIds.length > 0) {
newInstallments.set(group.seriesId, new Set(unpaidIds));
}
});
// Função para selecionar/desselecionar tudo
const toggleSelectAll = () => {
if (isAllSelected) {
// Desmarcar tudo
setSelectedInstallments(new Map());
} else {
// Marcar tudo (exceto parcelas já pagas)
const newInstallments = new Map<string, Set<string>>();
data.installmentGroups.forEach((group) => {
const unpaidIds = group.pendingInstallments
.filter((i) => !i.isSettled)
.map((i) => i.id);
if (unpaidIds.length > 0) {
newInstallments.set(group.seriesId, new Set(unpaidIds));
}
});
setSelectedInstallments(newInstallments);
}
};
setSelectedInstallments(newInstallments);
}
};
// Função para selecionar/desselecionar um grupo de parcelas
const toggleGroupSelection = (seriesId: string, installmentIds: string[]) => {
const newMap = new Map(selectedInstallments);
const current = newMap.get(seriesId) || new Set<string>();
// Função para selecionar/desselecionar um grupo de parcelas
const toggleGroupSelection = (seriesId: string, installmentIds: string[]) => {
const newMap = new Map(selectedInstallments);
const current = newMap.get(seriesId) || new Set<string>();
if (current.size === installmentIds.length) {
// Já está tudo selecionado, desmarcar
newMap.delete(seriesId);
} else {
// Marcar tudo
newMap.set(seriesId, new Set(installmentIds));
}
if (current.size === installmentIds.length) {
// Já está tudo selecionado, desmarcar
newMap.delete(seriesId);
} else {
// Marcar tudo
newMap.set(seriesId, new Set(installmentIds));
}
setSelectedInstallments(newMap);
};
setSelectedInstallments(newMap);
};
// Função para selecionar/desselecionar parcela individual
const toggleInstallmentSelection = (
seriesId: string,
installmentId: string
) => {
const newMap = new Map(selectedInstallments);
// Criar uma NOVA instância do Set para React detectar a mudança
const current = new Set(newMap.get(seriesId) || []);
// Função para selecionar/desselecionar parcela individual
const toggleInstallmentSelection = (
seriesId: string,
installmentId: string,
) => {
const newMap = new Map(selectedInstallments);
// Criar uma NOVA instância do Set para React detectar a mudança
const current = new Set(newMap.get(seriesId) || []);
if (current.has(installmentId)) {
current.delete(installmentId);
if (current.size === 0) {
newMap.delete(seriesId);
} else {
newMap.set(seriesId, current);
}
} else {
current.add(installmentId);
newMap.set(seriesId, current);
}
if (current.has(installmentId)) {
current.delete(installmentId);
if (current.size === 0) {
newMap.delete(seriesId);
} else {
newMap.set(seriesId, current);
}
} else {
current.add(installmentId);
newMap.set(seriesId, current);
}
setSelectedInstallments(newMap);
};
setSelectedInstallments(newMap);
};
// Calcular totais
const { grandTotal, selectedCount } = useMemo(() => {
let installmentsSum = 0;
let installmentsCount = 0;
// Calcular totais
const { grandTotal, selectedCount } = useMemo(() => {
let installmentsSum = 0;
let installmentsCount = 0;
selectedInstallments.forEach((installmentIds, seriesId) => {
const group = data.installmentGroups.find((g) => g.seriesId === seriesId);
if (group) {
installmentIds.forEach((id) => {
const installment = group.pendingInstallments.find(
(i) => i.id === id
);
if (installment && !installment.isSettled) {
installmentsSum += installment.amount;
installmentsCount++;
}
});
}
});
selectedInstallments.forEach((installmentIds, seriesId) => {
const group = data.installmentGroups.find((g) => g.seriesId === seriesId);
if (group) {
installmentIds.forEach((id) => {
const installment = group.pendingInstallments.find(
(i) => i.id === id,
);
if (installment && !installment.isSettled) {
installmentsSum += installment.amount;
installmentsCount++;
}
});
}
});
return {
grandTotal: installmentsSum,
selectedCount: installmentsCount,
};
}, [selectedInstallments, data]);
return {
grandTotal: installmentsSum,
selectedCount: installmentsCount,
};
}, [selectedInstallments, data]);
const hasNoData = data.installmentGroups.length === 0;
const hasNoData = data.installmentGroups.length === 0;
return (
<div className="flex flex-col gap-4">
{/* Card de resumo principal */}
<Card className="border-none bg-primary/15">
<CardContent className="flex flex-col items-start justify-center gap-2 py-2">
<p className="text-sm font-medium text-muted-foreground">
Se você pagar tudo que está selecionado:
</p>
<MoneyValues
amount={grandTotal}
className="text-3xl font-bold text-primary"
/>
<p className="text-sm text-muted-foreground">
{selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}{" "}
selecionadas
</p>
</CardContent>
</Card>
return (
<div className="flex flex-col gap-4">
{/* Card de resumo principal */}
<Card className="border-none bg-primary/15">
<CardContent className="flex flex-col items-start justify-center gap-2 py-2">
<p className="text-sm font-medium text-muted-foreground">
Se você pagar tudo que está selecionado:
</p>
<MoneyValues
amount={grandTotal}
className="text-3xl font-bold text-primary"
/>
<p className="text-sm text-muted-foreground">
{selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}{" "}
selecionadas
</p>
</CardContent>
</Card>
{/* Botões de ação */}
{!hasNoData && (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleSelectAll}
className="gap-2"
>
{isAllSelected ? (
<RiCheckboxLine className="size-4" />
) : (
<RiCheckboxBlankLine className="size-4" />
)}
{isAllSelected ? "Desmarcar Tudo" : "Selecionar Tudo"}
</Button>
</div>
)}
{/* Botões de ação */}
{!hasNoData && (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleSelectAll}
className="gap-2"
>
{isAllSelected ? (
<RiCheckboxLine className="size-4" />
) : (
<RiCheckboxBlankLine className="size-4" />
)}
{isAllSelected ? "Desmarcar Tudo" : "Selecionar Tudo"}
</Button>
</div>
)}
{/* Seção de Lançamentos Parcelados */}
{data.installmentGroups.length > 0 && (
<div className="flex flex-col gap-3">
{data.installmentGroups.map((group) => (
<InstallmentGroupCard
key={group.seriesId}
group={group}
selectedInstallments={
selectedInstallments.get(group.seriesId) || new Set()
}
onToggleGroup={() =>
toggleGroupSelection(
group.seriesId,
group.pendingInstallments
.filter((i) => !i.isSettled)
.map((i) => i.id)
)
}
onToggleInstallment={(installmentId) =>
toggleInstallmentSelection(group.seriesId, installmentId)
}
/>
))}
</div>
)}
{/* Seção de Lançamentos Parcelados */}
{data.installmentGroups.length > 0 && (
<div className="flex flex-col gap-3">
{data.installmentGroups.map((group) => (
<InstallmentGroupCard
key={group.seriesId}
group={group}
selectedInstallments={
selectedInstallments.get(group.seriesId) || new Set()
}
onToggleGroup={() =>
toggleGroupSelection(
group.seriesId,
group.pendingInstallments
.filter((i) => !i.isSettled)
.map((i) => i.id),
)
}
onToggleInstallment={(installmentId) =>
toggleInstallmentSelection(group.seriesId, installmentId)
}
/>
))}
</div>
)}
{/* Estado vazio */}
{hasNoData && (
<Card>
<CardContent className="flex flex-col items-center justify-center gap-3 py-12">
<RiCalculatorLine className="size-12 text-muted-foreground/50" />
<div className="text-center">
<p className="font-medium">Nenhuma parcela pendente</p>
<p className="text-sm text-muted-foreground">
Você está em dia com seus pagamentos!
</p>
</div>
</CardContent>
</Card>
)}
</div>
);
{/* Estado vazio */}
{hasNoData && (
<Card>
<CardContent className="flex flex-col items-center justify-center gap-3 py-12">
<RiCalculatorLine className="size-12 text-muted-foreground/50" />
<div className="text-center">
<p className="font-medium">Nenhuma parcela pendente</p>
<p className="text-sm text-muted-foreground">
Você está em dia com seus pagamentos!
</p>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -1,235 +1,239 @@
"use client";
import {
RiArrowDownSLine,
RiArrowRightSLine,
RiCheckboxCircleFill,
} from "@remixicon/react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useState } from "react";
import MoneyValues from "@/components/money-values";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils/ui";
import {
RiArrowDownSLine,
RiArrowRightSLine,
RiCheckboxCircleFill,
} from "@remixicon/react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useState } from "react";
import type { InstallmentGroup } from "./types";
type InstallmentGroupCardProps = {
group: InstallmentGroup;
selectedInstallments: Set<string>;
onToggleGroup: () => void;
onToggleInstallment: (installmentId: string) => void;
group: InstallmentGroup;
selectedInstallments: Set<string>;
onToggleGroup: () => void;
onToggleInstallment: (installmentId: string) => void;
};
export function InstallmentGroupCard({
group,
selectedInstallments,
onToggleGroup,
onToggleInstallment,
group,
selectedInstallments,
onToggleGroup,
onToggleInstallment,
}: InstallmentGroupCardProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const unpaidInstallments = group.pendingInstallments.filter(
(i) => !i.isSettled
);
const unpaidInstallments = group.pendingInstallments.filter(
(i) => !i.isSettled,
);
const unpaidCount = unpaidInstallments.length;
const unpaidCount = unpaidInstallments.length;
const isFullySelected =
selectedInstallments.size === unpaidInstallments.length &&
unpaidInstallments.length > 0;
const isFullySelected =
selectedInstallments.size === unpaidInstallments.length &&
unpaidInstallments.length > 0;
const progress =
group.totalInstallments > 0
? (group.paidInstallments / group.totalInstallments) * 100
: 0;
const progress =
group.totalInstallments > 0
? (group.paidInstallments / group.totalInstallments) * 100
: 0;
const selectedAmount = group.pendingInstallments
.filter((i) => selectedInstallments.has(i.id) && !i.isSettled)
.reduce((sum, i) => sum + Number(i.amount), 0);
const selectedAmount = group.pendingInstallments
.filter((i) => selectedInstallments.has(i.id) && !i.isSettled)
.reduce((sum, i) => sum + Number(i.amount), 0);
// Calcular valor total de todas as parcelas (pagas + pendentes)
const totalAmount = group.pendingInstallments.reduce(
(sum, i) => sum + i.amount,
0
);
// Calcular valor total de todas as parcelas (pagas + pendentes)
const totalAmount = group.pendingInstallments.reduce(
(sum, i) => sum + i.amount,
0,
);
// Calcular valor pendente (apenas não pagas)
const pendingAmount = unpaidInstallments.reduce(
(sum, i) => sum + i.amount,
0
);
// Calcular valor pendente (apenas não pagas)
const pendingAmount = unpaidInstallments.reduce(
(sum, i) => sum + i.amount,
0,
);
return (
<Card className={cn(isFullySelected && "border-primary/50")}>
<CardContent className="flex flex-col gap-2">
{/* Header do card */}
<div className="flex items-start gap-3">
<Checkbox
checked={isFullySelected}
onCheckedChange={onToggleGroup}
className="mt-1"
aria-label={`Selecionar todas as parcelas de ${group.name}`}
/>
return (
<Card className={cn(isFullySelected && "border-primary/50")}>
<CardContent className="flex flex-col gap-2">
{/* Header do card */}
<div className="flex items-start gap-3">
<Checkbox
checked={isFullySelected}
onCheckedChange={onToggleGroup}
className="mt-1"
aria-label={`Selecionar todas as parcelas de ${group.name}`}
/>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="flex gap-1 items-center">
{group.cartaoLogo && (
<img
src={`/logos/${group.cartaoLogo}`}
alt={group.cartaoName}
className="h-6 w-auto object-contain rounded"
/>
)}
<span className="font-medium">{group.name}</span>|
<span className="text-xs text-muted-foreground">
{group.cartaoName}
</span>
</div>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="flex gap-1 items-center">
{group.cartaoLogo && (
<img
src={`/logos/${group.cartaoLogo}`}
alt={group.cartaoName}
className="h-6 w-auto object-contain rounded"
/>
)}
<span className="font-medium">{group.name}</span>|
<span className="text-xs text-muted-foreground">
{group.cartaoName}
</span>
</div>
</div>
<div className="shrink-0 flex items-center gap-3">
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">Total:</span>
<MoneyValues
amount={totalAmount}
className="text-base font-bold"
/>
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">
Pendente:
</span>
<MoneyValues
amount={pendingAmount}
className="text-sm font-medium text-primary"
/>
</div>
</div>
</div>
<div className="shrink-0 flex items-center gap-3">
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">Total:</span>
<MoneyValues
amount={totalAmount}
className="text-base font-bold"
/>
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">
Pendente:
</span>
<MoneyValues
amount={pendingAmount}
className="text-sm font-medium text-primary"
/>
</div>
</div>
</div>
{/* Progress bar */}
<div className="mt-3">
<div className="mb-2 flex items-center px-1 justify-between text-xs text-muted-foreground">
<span>
{group.paidInstallments} de {group.totalInstallments} pagas
</span>
<div className="flex items-center gap-2">
<span>
{unpaidCount} {unpaidCount === 1 ? "pendente" : "pendentes"}
</span>
{selectedInstallments.size > 0 && (
<span className="text-primary font-medium">
Selecionado: <MoneyValues amount={selectedAmount} className="text-xs font-medium text-primary inline" />
</span>
)}
</div>
</div>
<Progress value={progress} className="h-2" />
</div>
{/* Progress bar */}
<div className="mt-3">
<div className="mb-2 flex items-center px-1 justify-between text-xs text-muted-foreground">
<span>
{group.paidInstallments} de {group.totalInstallments} pagas
</span>
<div className="flex items-center gap-2">
<span>
{unpaidCount} {unpaidCount === 1 ? "pendente" : "pendentes"}
</span>
{selectedInstallments.size > 0 && (
<span className="text-primary font-medium">
Selecionado:{" "}
<MoneyValues
amount={selectedAmount}
className="text-xs font-medium text-primary inline"
/>
</span>
)}
</div>
</div>
<Progress value={progress} className="h-2" />
</div>
{/* Botão de expandir */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="mt-2 flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{isExpanded ? (
<>
<RiArrowDownSLine className="size-4" />
Ocultar parcelas ({group.pendingInstallments.length})
</>
) : (
<>
<RiArrowRightSLine className="size-4" />
Ver parcelas ({group.pendingInstallments.length})
</>
)}
</button>
</div>
</div>
{/* Botão de expandir */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="mt-2 flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{isExpanded ? (
<>
<RiArrowDownSLine className="size-4" />
Ocultar parcelas ({group.pendingInstallments.length})
</>
) : (
<>
<RiArrowRightSLine className="size-4" />
Ver parcelas ({group.pendingInstallments.length})
</>
)}
</button>
</div>
</div>
{/* Lista de parcelas expandida */}
{isExpanded && (
<div className="px-8 mt-2 flex flex-col gap-2">
{group.pendingInstallments.map((installment) => {
const isSelected = selectedInstallments.has(installment.id);
const isPaid = installment.isSettled;
const dueDate = installment.dueDate
? format(installment.dueDate, "dd/MM/yyyy", { locale: ptBR })
: format(installment.purchaseDate, "dd/MM/yyyy", {
locale: ptBR,
});
{/* Lista de parcelas expandida */}
{isExpanded && (
<div className="px-8 mt-2 flex flex-col gap-2">
{group.pendingInstallments.map((installment) => {
const isSelected = selectedInstallments.has(installment.id);
const isPaid = installment.isSettled;
const dueDate = installment.dueDate
? format(installment.dueDate, "dd/MM/yyyy", { locale: ptBR })
: format(installment.purchaseDate, "dd/MM/yyyy", {
locale: ptBR,
});
return (
<div
key={installment.id}
className={cn(
"flex items-center gap-3 rounded-md border p-2 transition-colors",
isSelected && !isPaid && "border-primary/50 bg-primary/5",
isPaid &&
"border-green-400 bg-green-50 dark:border-green-900 dark:bg-green-950/30"
)}
>
<Checkbox
checked={isPaid ? false : isSelected}
disabled={isPaid}
onCheckedChange={() =>
!isPaid && onToggleInstallment(installment.id)
}
aria-label={`Selecionar parcela ${installment.currentInstallment} de ${group.totalInstallments}`}
/>
return (
<div
key={installment.id}
className={cn(
"flex items-center gap-3 rounded-md border p-2 transition-colors",
isSelected && !isPaid && "border-primary/50 bg-primary/5",
isPaid &&
"border-green-400 bg-green-50 dark:border-green-900 dark:bg-green-950/30",
)}
>
<Checkbox
checked={isPaid ? false : isSelected}
disabled={isPaid}
onCheckedChange={() =>
!isPaid && onToggleInstallment(installment.id)
}
aria-label={`Selecionar parcela ${installment.currentInstallment} de ${group.totalInstallments}`}
/>
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
<div className="min-w-0">
<p
className={cn(
"text-xs font-medium",
isPaid &&
"text-green-700 dark:text-green-400 line-through decoration-green-600/50"
)}
>
Parcela {installment.currentInstallment}/
{group.totalInstallments}
{isPaid && (
<Badge
variant="outline"
className="ml-1 text-xs border-none border-green-700 text-green-700 dark:text-green-400"
>
<RiCheckboxCircleFill /> Pago
</Badge>
)}
</p>
<p
className={cn(
"text-xs mt-1",
isPaid
? "text-green-700 dark:text-green-500"
: "text-muted-foreground"
)}
>
Vencimento: {dueDate}
</p>
</div>
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
<div className="min-w-0">
<p
className={cn(
"text-xs font-medium",
isPaid &&
"text-green-700 dark:text-green-400 line-through decoration-green-600/50",
)}
>
Parcela {installment.currentInstallment}/
{group.totalInstallments}
{isPaid && (
<Badge
variant="outline"
className="ml-1 text-xs border-none border-green-700 text-green-700 dark:text-green-400"
>
<RiCheckboxCircleFill /> Pago
</Badge>
)}
</p>
<p
className={cn(
"text-xs mt-1",
isPaid
? "text-green-700 dark:text-green-500"
: "text-muted-foreground",
)}
>
Vencimento: {dueDate}
</p>
</div>
<MoneyValues
amount={installment.amount}
className={cn(
"shrink-0 text-sm",
isPaid && "text-green-700 dark:text-green-400"
)}
/>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
<MoneyValues
amount={installment.amount}
className={cn(
"shrink-0 text-sm",
isPaid && "text-green-700 dark:text-green-400",
)}
/>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,177 +1,177 @@
"use client";
import {
RiArrowDownSLine,
RiArrowRightSLine,
RiBillLine,
} from "@remixicon/react";
import { format, parse } from "date-fns";
import { ptBR } from "date-fns/locale";
import Image from "next/image";
import { useState } from "react";
import MoneyValues from "@/components/money-values";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils/ui";
import {
RiArrowDownSLine,
RiArrowRightSLine,
RiBillLine,
} from "@remixicon/react";
import { format, parse } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useState } from "react";
import type { PendingInvoice } from "./types";
import Image from "next/image";
type PendingInvoiceCardProps = {
invoice: PendingInvoice;
isSelected: boolean;
onToggle: () => void;
invoice: PendingInvoice;
isSelected: boolean;
onToggle: () => void;
};
export function PendingInvoiceCard({
invoice,
isSelected,
onToggle,
invoice,
isSelected,
onToggle,
}: PendingInvoiceCardProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
// Formatar período (YYYY-MM) para texto legível
const periodDate = parse(invoice.period, "yyyy-MM", new Date());
const periodText = format(periodDate, "MMMM 'de' yyyy", { locale: ptBR });
// Formatar período (YYYY-MM) para texto legível
const periodDate = parse(invoice.period, "yyyy-MM", new Date());
const periodText = format(periodDate, "MMMM 'de' yyyy", { locale: ptBR });
// Calcular data de vencimento aproximada
const dueDay = parseInt(invoice.dueDay, 10);
const dueDate = new Date(periodDate);
dueDate.setDate(dueDay);
const dueDateText = format(dueDate, "dd/MM/yyyy", { locale: ptBR });
// Calcular data de vencimento aproximada
const dueDay = parseInt(invoice.dueDay, 10);
const dueDate = new Date(periodDate);
dueDate.setDate(dueDay);
const dueDateText = format(dueDate, "dd/MM/yyyy", { locale: ptBR });
return (
<Card className={cn(isSelected && "border-primary/50 bg-primary/5")}>
<CardContent className="flex flex-col gap-3 py-4">
{/* Header do card */}
<div className="flex items-start gap-3">
<Checkbox
checked={isSelected}
onCheckedChange={onToggle}
className="mt-1"
aria-label={`Selecionar fatura ${invoice.cartaoName} - ${periodText}`}
/>
return (
<Card className={cn(isSelected && "border-primary/50 bg-primary/5")}>
<CardContent className="flex flex-col gap-3 py-4">
{/* Header do card */}
<div className="flex items-start gap-3">
<Checkbox
checked={isSelected}
onCheckedChange={onToggle}
className="mt-1"
aria-label={`Selecionar fatura ${invoice.cartaoName} - ${periodText}`}
/>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{invoice.cartaoLogo ? (
<Image
src={invoice.cartaoLogo}
alt={invoice.cartaoName}
width={24}
height={24}
className="size-6 rounded"
/>
) : (
<div className="flex size-6 items-center justify-center rounded bg-muted">
<RiBillLine className="size-4 text-muted-foreground" />
</div>
)}
<p className="font-medium">{invoice.cartaoName}</p>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="capitalize">{periodText}</span>
<span>-</span>
<span>Vencimento: {dueDateText}</span>
</div>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{invoice.cartaoLogo ? (
<Image
src={invoice.cartaoLogo}
alt={invoice.cartaoName}
width={24}
height={24}
className="size-6 rounded"
/>
) : (
<div className="flex size-6 items-center justify-center rounded bg-muted">
<RiBillLine className="size-4 text-muted-foreground" />
</div>
)}
<p className="font-medium">{invoice.cartaoName}</p>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="capitalize">{periodText}</span>
<span>-</span>
<span>Vencimento: {dueDateText}</span>
</div>
</div>
<MoneyValues
amount={invoice.totalAmount}
className="shrink-0 text-sm font-semibold"
/>
</div>
<MoneyValues
amount={invoice.totalAmount}
className="shrink-0 text-sm font-semibold"
/>
</div>
{/* Badge de status */}
<div className="mt-2 flex flex-wrap gap-2">
<Badge variant="destructive" className="text-xs">
Pendente
</Badge>
<Badge variant="secondary" className="text-xs">
{invoice.lancamentos.length}{" "}
{invoice.lancamentos.length === 1
? "lançamento"
: "lançamentos"}
</Badge>
</div>
{/* Badge de status */}
<div className="mt-2 flex flex-wrap gap-2">
<Badge variant="destructive" className="text-xs">
Pendente
</Badge>
<Badge variant="secondary" className="text-xs">
{invoice.lancamentos.length}{" "}
{invoice.lancamentos.length === 1
? "lançamento"
: "lançamentos"}
</Badge>
</div>
{/* Botão de expandir */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="mt-3 flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{isExpanded ? (
<>
<RiArrowDownSLine className="size-4" />
Ocultar lançamentos ({invoice.lancamentos.length})
</>
) : (
<>
<RiArrowRightSLine className="size-4" />
Ver lançamentos ({invoice.lancamentos.length})
</>
)}
</button>
</div>
</div>
{/* Botão de expandir */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="mt-3 flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{isExpanded ? (
<>
<RiArrowDownSLine className="size-4" />
Ocultar lançamentos ({invoice.lancamentos.length})
</>
) : (
<>
<RiArrowRightSLine className="size-4" />
Ver lançamentos ({invoice.lancamentos.length})
</>
)}
</button>
</div>
</div>
{/* Lista de lançamentos expandida */}
{isExpanded && (
<div className="ml-9 mt-2 flex flex-col gap-2 border-l-2 border-muted pl-4">
{invoice.lancamentos.map((lancamento) => {
const purchaseDate = format(
lancamento.purchaseDate,
"dd/MM/yyyy",
{ locale: ptBR }
);
{/* Lista de lançamentos expandida */}
{isExpanded && (
<div className="ml-9 mt-2 flex flex-col gap-2 border-l-2 border-muted pl-4">
{invoice.lancamentos.map((lancamento) => {
const purchaseDate = format(
lancamento.purchaseDate,
"dd/MM/yyyy",
{ locale: ptBR },
);
const installmentLabel =
lancamento.condition === "Parcelado" &&
lancamento.currentInstallment &&
lancamento.installmentCount
? `${lancamento.currentInstallment}/${lancamento.installmentCount}`
: null;
const installmentLabel =
lancamento.condition === "Parcelado" &&
lancamento.currentInstallment &&
lancamento.installmentCount
? `${lancamento.currentInstallment}/${lancamento.installmentCount}`
: null;
return (
<div
key={lancamento.id}
className="flex items-center gap-3 rounded-md border p-2"
>
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{lancamento.name}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{purchaseDate}</span>
{installmentLabel && (
<>
<span>-</span>
<span>Parcela {installmentLabel}</span>
</>
)}
{lancamento.condition !== "Parcelado" && (
<>
<span>-</span>
<span>{lancamento.condition}</span>
</>
)}
</div>
</div>
return (
<div
key={lancamento.id}
className="flex items-center gap-3 rounded-md border p-2"
>
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{lancamento.name}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{purchaseDate}</span>
{installmentLabel && (
<>
<span>-</span>
<span>Parcela {installmentLabel}</span>
</>
)}
{lancamento.condition !== "Parcelado" && (
<>
<span>-</span>
<span>{lancamento.condition}</span>
</>
)}
</div>
</div>
<MoneyValues
amount={lancamento.amount}
className="shrink-0 text-sm"
/>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
<MoneyValues
amount={lancamento.amount}
className="shrink-0 text-sm"
/>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,7 +1,7 @@
import type {
InstallmentAnalysisData,
InstallmentGroup,
PendingInvoice,
InstallmentAnalysisData,
InstallmentGroup,
PendingInvoice,
} from "@/lib/dashboard/expenses/installment-analysis";
export type { InstallmentAnalysisData, InstallmentGroup, PendingInvoice };

View File

@@ -1,191 +1,191 @@
import { RiNumbersLine } from "@remixicon/react";
import Image from "next/image";
import MoneyValues from "@/components/money-values";
import { CardContent } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { InstallmentExpensesData } from "@/lib/dashboard/expenses/installment-expenses";
import {
calculateLastInstallmentDate,
formatLastInstallmentDate,
calculateLastInstallmentDate,
formatLastInstallmentDate,
} from "@/lib/installments/utils";
import { RiNumbersLine } from "@remixicon/react";
import Image from "next/image";
import { Progress } from "../ui/progress";
import { WidgetEmptyState } from "../widget-empty-state";
type InstallmentExpensesWidgetProps = {
data: InstallmentExpensesData;
data: InstallmentExpensesData;
};
const buildCompactInstallmentLabel = (
currentInstallment: number | null,
installmentCount: number | null
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (currentInstallment && installmentCount) {
return `${currentInstallment} de ${installmentCount}`;
}
return null;
if (currentInstallment && installmentCount) {
return `${currentInstallment} de ${installmentCount}`;
}
return null;
};
const isLastInstallment = (
currentInstallment: number | null,
installmentCount: number | null
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (!currentInstallment || !installmentCount) return false;
return currentInstallment === installmentCount && installmentCount > 1;
if (!currentInstallment || !installmentCount) return false;
return currentInstallment === installmentCount && installmentCount > 1;
};
const calculateRemainingInstallments = (
currentInstallment: number | null,
installmentCount: number | null
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (!currentInstallment || !installmentCount) return 0;
return Math.max(0, installmentCount - currentInstallment);
if (!currentInstallment || !installmentCount) return 0;
return Math.max(0, installmentCount - currentInstallment);
};
const calculateRemainingAmount = (
amount: number,
currentInstallment: number | null,
installmentCount: number | null
amount: number,
currentInstallment: number | null,
installmentCount: number | null,
) => {
const remaining = calculateRemainingInstallments(
currentInstallment,
installmentCount
);
return amount * remaining;
const remaining = calculateRemainingInstallments(
currentInstallment,
installmentCount,
);
return amount * remaining;
};
const formatEndDate = (
period: string,
currentInstallment: number | null,
installmentCount: number | null
period: string,
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (!currentInstallment || !installmentCount) return null;
if (!currentInstallment || !installmentCount) return null;
const lastDate = calculateLastInstallmentDate(
period,
currentInstallment,
installmentCount
);
const lastDate = calculateLastInstallmentDate(
period,
currentInstallment,
installmentCount,
);
return formatLastInstallmentDate(lastDate);
return formatLastInstallmentDate(lastDate);
};
const buildProgress = (
currentInstallment: number | null,
installmentCount: number | null
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (!currentInstallment || !installmentCount || installmentCount <= 0) {
return 0;
}
if (!currentInstallment || !installmentCount || installmentCount <= 0) {
return 0;
}
return Math.min(
100,
Math.max(0, (currentInstallment / installmentCount) * 100)
);
return Math.min(
100,
Math.max(0, (currentInstallment / installmentCount) * 100),
);
};
export function InstallmentExpensesWidget({
data,
data,
}: InstallmentExpensesWidgetProps) {
if (data.expenses.length === 0) {
return (
<WidgetEmptyState
icon={<RiNumbersLine className="size-6 text-muted-foreground" />}
title="Nenhuma despesa parcelada"
description="Lançamentos parcelados aparecerão aqui conforme forem registrados."
/>
);
}
if (data.expenses.length === 0) {
return (
<WidgetEmptyState
icon={<RiNumbersLine className="size-6 text-muted-foreground" />}
title="Nenhuma despesa parcelada"
description="Lançamentos parcelados aparecerão aqui conforme forem registrados."
/>
);
}
return (
<CardContent className="flex flex-col gap-4 px-0">
<ul className="flex flex-col gap-2">
{data.expenses.map((expense) => {
const compactLabel = buildCompactInstallmentLabel(
expense.currentInstallment,
expense.installmentCount
);
const isLast = isLastInstallment(
expense.currentInstallment,
expense.installmentCount
);
const remainingInstallments = calculateRemainingInstallments(
expense.currentInstallment,
expense.installmentCount
);
const remainingAmount = calculateRemainingAmount(
expense.amount,
expense.currentInstallment,
expense.installmentCount
);
const endDate = formatEndDate(
expense.period,
expense.currentInstallment,
expense.installmentCount
);
const progress = buildProgress(
expense.currentInstallment,
expense.installmentCount
);
return (
<CardContent className="flex flex-col gap-4 px-0">
<ul className="flex flex-col gap-2">
{data.expenses.map((expense) => {
const compactLabel = buildCompactInstallmentLabel(
expense.currentInstallment,
expense.installmentCount,
);
const isLast = isLastInstallment(
expense.currentInstallment,
expense.installmentCount,
);
const remainingInstallments = calculateRemainingInstallments(
expense.currentInstallment,
expense.installmentCount,
);
const remainingAmount = calculateRemainingAmount(
expense.amount,
expense.currentInstallment,
expense.installmentCount,
);
const endDate = formatEndDate(
expense.period,
expense.currentInstallment,
expense.installmentCount,
);
const progress = buildProgress(
expense.currentInstallment,
expense.installmentCount,
);
return (
<li
key={expense.id}
className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0"
>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<p className="truncate text-sm font-medium text-foreground">
{expense.name}
</p>
{compactLabel && (
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground">
{compactLabel}
{isLast && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Image
src="/icones/party.svg"
alt="Última parcela"
width={14}
height={14}
className="h-3.5 w-3.5"
/>
<span className="sr-only">Última parcela</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Última parcela!
</TooltipContent>
</Tooltip>
)}
</span>
)}
</div>
<MoneyValues amount={expense.amount} className="shrink-0" />
</div>
return (
<li
key={expense.id}
className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0"
>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<p className="truncate text-sm font-medium text-foreground">
{expense.name}
</p>
{compactLabel && (
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground">
{compactLabel}
{isLast && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Image
src="/icones/party.svg"
alt="Última parcela"
width={14}
height={14}
className="h-3.5 w-3.5"
/>
<span className="sr-only">Última parcela</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Última parcela!
</TooltipContent>
</Tooltip>
)}
</span>
)}
</div>
<MoneyValues amount={expense.amount} className="shrink-0" />
</div>
<p className="text-xs text-muted-foreground ">
{endDate && `Termina em ${endDate}`}
{" | Restante "}
<MoneyValues
amount={remainingAmount}
className="inline-block font-medium"
/>{" "}
({remainingInstallments})
</p>
<p className="text-xs text-muted-foreground ">
{endDate && `Termina em ${endDate}`}
{" | Restante "}
<MoneyValues
amount={remainingAmount}
className="inline-block font-medium"
/>{" "}
({remainingInstallments})
</p>
<Progress value={progress} className="h-2 mt-1" />
</div>
</li>
);
})}
</ul>
</CardContent>
);
<Progress value={progress} className="h-2 mt-1" />
</div>
</li>
);
})}
</ul>
</CardContent>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,130 +1,130 @@
import {
CardContent,
CardDescription,
CardFooter,
CardHeader,
} from "@/components/ui/card";
import type { DashboardAccount } from "@/lib/dashboard/accounts";
import { formatPeriodForUrl } from "@/lib/utils/period";
import { RiBarChartBoxLine } from "@remixicon/react";
import Image from "next/image";
import Link from "next/link";
import {
CardContent,
CardDescription,
CardFooter,
CardHeader,
} from "@/components/ui/card";
import type { DashboardAccount } from "@/lib/dashboard/accounts";
import { formatPeriodForUrl } from "@/lib/utils/period";
import MoneyValues from "../money-values";
import { WidgetEmptyState } from "../widget-empty-state";
type MyAccountsWidgetProps = {
accounts: DashboardAccount[];
totalBalance: number;
maxVisible?: number;
period: string;
accounts: DashboardAccount[];
totalBalance: number;
maxVisible?: number;
period: string;
};
const resolveLogoSrc = (logo: string | null) => {
if (!logo) {
return null;
}
if (!logo) {
return null;
}
const fileName = logo.split("/").filter(Boolean).pop() ?? logo;
return `/logos/${fileName}`;
const fileName = logo.split("/").filter(Boolean).pop() ?? logo;
return `/logos/${fileName}`;
};
const buildInitials = (name: string) => {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "CC";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0] ?? ""}${parts[1][0] ?? ""}`.toUpperCase();
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "CC";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0] ?? ""}${parts[1][0] ?? ""}`.toUpperCase();
};
export function MyAccountsWidget({
accounts,
totalBalance,
maxVisible = 5,
period,
accounts,
totalBalance,
maxVisible = 5,
period,
}: MyAccountsWidgetProps) {
const visibleAccounts = accounts.filter(
(account) => !account.excludeFromBalance
);
const displayedAccounts = visibleAccounts.slice(0, maxVisible);
const remainingCount = visibleAccounts.length - displayedAccounts.length;
const visibleAccounts = accounts.filter(
(account) => !account.excludeFromBalance,
);
const displayedAccounts = visibleAccounts.slice(0, maxVisible);
const remainingCount = visibleAccounts.length - displayedAccounts.length;
return (
<>
<CardHeader className="pb-4 px-0">
<CardDescription>Saldo Total</CardDescription>
<div className="text-2xl text-foreground">
<MoneyValues amount={totalBalance} />
</div>
</CardHeader>
return (
<>
<CardHeader className="pb-4 px-0">
<CardDescription>Saldo Total</CardDescription>
<div className="text-2xl text-foreground">
<MoneyValues amount={totalBalance} />
</div>
</CardHeader>
<CardContent className="py-2 px-0">
{displayedAccounts.length === 0 ? (
<div className="-mt-10">
<WidgetEmptyState
icon={
<RiBarChartBoxLine className="size-6 text-muted-foreground" />
}
title="Você ainda não adicionou nenhuma conta"
description="Cadastre suas contas bancárias para acompanhar os saldos e movimentações."
/>
</div>
) : (
<ul className="flex flex-col">
{displayedAccounts.map((account) => {
const logoSrc = resolveLogoSrc(account.logo);
const initials = buildInitials(account.name);
<CardContent className="py-2 px-0">
{displayedAccounts.length === 0 ? (
<div className="-mt-10">
<WidgetEmptyState
icon={
<RiBarChartBoxLine className="size-6 text-muted-foreground" />
}
title="Você ainda não adicionou nenhuma conta"
description="Cadastre suas contas bancárias para acompanhar os saldos e movimentações."
/>
</div>
) : (
<ul className="flex flex-col">
{displayedAccounts.map((account) => {
const logoSrc = resolveLogoSrc(account.logo);
const initials = buildInitials(account.name);
return (
<li
key={account.id}
className="flex items-center justify-between gap-2 border-b border-dashed py-2 last:border-0"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
{logoSrc ? (
<div className="relative size-10 overflow-hidden">
<Image
src={logoSrc}
alt={`Logo da conta ${account.name}`}
fill
className="object-contain rounded-lg"
/>
</div>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-secondary text-sm font-semibold uppercase text-secondary-foreground">
{initials}
</div>
)}
return (
<li
key={account.id}
className="flex items-center justify-between gap-2 border-b border-dashed py-2 last:border-0"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
{logoSrc ? (
<div className="relative size-10 overflow-hidden">
<Image
src={logoSrc}
alt={`Logo da conta ${account.name}`}
fill
className="object-contain rounded-lg"
/>
</div>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-secondary text-sm font-semibold uppercase text-secondary-foreground">
{initials}
</div>
)}
<div className="min-w-0">
<Link
prefetch
href={`/contas/${
account.id
}/extrato?periodo=${formatPeriodForUrl(period)}`}
className="truncate font-medium text-foreground hover:text-primary hover:underline"
>
<span className="truncate text-sm">{account.name}</span>
</Link>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="truncate">{account.accountType}</span>
</div>
</div>
</div>
<div className="min-w-0">
<Link
prefetch
href={`/contas/${
account.id
}/extrato?periodo=${formatPeriodForUrl(period)}`}
className="truncate font-medium text-foreground hover:text-primary hover:underline"
>
<span className="truncate text-sm">{account.name}</span>
</Link>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="truncate">{account.accountType}</span>
</div>
</div>
</div>
<div className="flex flex-col items-end gap-0.5 text-right">
<MoneyValues amount={account.balance} />
</div>
</li>
);
})}
</ul>
)}
</CardContent>
<div className="flex flex-col items-end gap-0.5 text-right">
<MoneyValues amount={account.balance} />
</div>
</li>
);
})}
</ul>
)}
</CardContent>
{visibleAccounts.length > displayedAccounts.length ? (
<CardFooter className="border-border/60 border-t pt-4 text-sm text-muted-foreground">
+{remainingCount} contas não exibidas
</CardFooter>
) : null}
</>
);
{visibleAccounts.length > displayedAccounts.length ? (
<CardFooter className="border-border/60 border-t pt-4 text-sm text-muted-foreground">
+{remainingCount} contas não exibidas
</CardFooter>
) : null}
</>
);
}

View File

@@ -1,88 +1,88 @@
import MoneyValues from "@/components/money-values";
import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions";
import {
RiCheckLine,
RiLoader2Fill,
RiRefreshLine,
RiSlideshowLine,
RiCheckLine,
RiLoader2Fill,
RiRefreshLine,
RiSlideshowLine,
} from "@remixicon/react";
import type { ReactNode } from "react";
import MoneyValues from "@/components/money-values";
import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions";
import { Progress } from "../ui/progress";
import { WidgetEmptyState } from "../widget-empty-state";
type PaymentConditionsWidgetProps = {
data: PaymentConditionsData;
data: PaymentConditionsData;
};
const CONDITION_ICON_CLASSES =
"flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground";
"flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground";
const CONDITION_ICONS: Record<string, ReactNode> = {
"À vista": <RiCheckLine className="size-5" aria-hidden />,
Parcelado: <RiLoader2Fill className="size-5" aria-hidden />,
Recorrente: <RiRefreshLine className="size-5" aria-hidden />,
"À vista": <RiCheckLine className="size-5" aria-hidden />,
Parcelado: <RiLoader2Fill className="size-5" aria-hidden />,
Recorrente: <RiRefreshLine className="size-5" aria-hidden />,
};
const formatPercentage = (value: number) =>
new Intl.NumberFormat("pt-BR", {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(value);
new Intl.NumberFormat("pt-BR", {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(value);
export function PaymentConditionsWidget({
data,
data,
}: PaymentConditionsWidgetProps) {
if (data.conditions.length === 0) {
return (
<WidgetEmptyState
icon={<RiSlideshowLine className="size-6 text-muted-foreground" />}
title="Nenhuma despesa encontrada"
description="As distribuições por condição aparecerão conforme novos lançamentos."
/>
);
}
if (data.conditions.length === 0) {
return (
<WidgetEmptyState
icon={<RiSlideshowLine className="size-6 text-muted-foreground" />}
title="Nenhuma despesa encontrada"
description="As distribuições por condição aparecerão conforme novos lançamentos."
/>
);
}
return (
<div className="flex flex-col gap-4 px-0">
<ul className="flex flex-col gap-2">
{data.conditions.map((condition) => {
const Icon =
CONDITION_ICONS[condition.condition] ?? CONDITION_ICONS["À vista"];
const percentageLabel = formatPercentage(condition.percentage);
return (
<div className="flex flex-col gap-4 px-0">
<ul className="flex flex-col gap-2">
{data.conditions.map((condition) => {
const Icon =
CONDITION_ICONS[condition.condition] ?? CONDITION_ICONS["À vista"];
const percentageLabel = formatPercentage(condition.percentage);
return (
<li
key={condition.condition}
className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0"
>
<div className={CONDITION_ICON_CLASSES}>{Icon}</div>
return (
<li
key={condition.condition}
className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0"
>
<div className={CONDITION_ICON_CLASSES}>{Icon}</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between">
<p className="font-medium text-foreground text-sm">
{condition.condition}
</p>
<MoneyValues amount={condition.amount} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between">
<p className="font-medium text-foreground text-sm">
{condition.condition}
</p>
<MoneyValues amount={condition.amount} />
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{condition.transactions}{" "}
{condition.transactions === 1
? "lançamento"
: "lançamentos"}
</span>
<span>{percentageLabel}%</span>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{condition.transactions}{" "}
{condition.transactions === 1
? "lançamento"
: "lançamentos"}
</span>
<span>{percentageLabel}%</span>
</div>
<div className="mt-1">
<Progress value={condition.percentage} />
</div>
</div>
</li>
);
})}
</ul>
</div>
);
<div className="mt-1">
<Progress value={condition.percentage} />
</div>
</div>
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -1,87 +1,87 @@
import { RiBankCard2Line, RiMoneyDollarCircleLine } from "@remixicon/react";
import MoneyValues from "@/components/money-values";
import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods";
import { getPaymentMethodIcon } from "@/lib/utils/icons";
import { RiBankCard2Line, RiMoneyDollarCircleLine } from "@remixicon/react";
import { Progress } from "../ui/progress";
import { WidgetEmptyState } from "../widget-empty-state";
type PaymentMethodsWidgetProps = {
data: PaymentMethodsData;
data: PaymentMethodsData;
};
const ICON_WRAPPER_CLASS =
"flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground";
"flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground";
const formatPercentage = (value: number) =>
new Intl.NumberFormat("pt-BR", {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(value);
new Intl.NumberFormat("pt-BR", {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(value);
const resolveIcon = (paymentMethod: string | null | undefined) => {
if (!paymentMethod) {
return <RiMoneyDollarCircleLine className="size-5" aria-hidden />;
}
if (!paymentMethod) {
return <RiMoneyDollarCircleLine className="size-5" aria-hidden />;
}
const icon = getPaymentMethodIcon(paymentMethod);
if (icon) {
return icon;
}
const icon = getPaymentMethodIcon(paymentMethod);
if (icon) {
return icon;
}
return <RiBankCard2Line className="size-5" aria-hidden />;
return <RiBankCard2Line className="size-5" aria-hidden />;
};
export function PaymentMethodsWidget({ data }: PaymentMethodsWidgetProps) {
if (data.methods.length === 0) {
return (
<WidgetEmptyState
icon={
<RiMoneyDollarCircleLine className="size-6 text-muted-foreground" />
}
title="Nenhuma despesa encontrada"
description="Cadastre despesas para visualizar a distribuição por forma de pagamento."
/>
);
}
if (data.methods.length === 0) {
return (
<WidgetEmptyState
icon={
<RiMoneyDollarCircleLine className="size-6 text-muted-foreground" />
}
title="Nenhuma despesa encontrada"
description="Cadastre despesas para visualizar a distribuição por forma de pagamento."
/>
);
}
return (
<div className="flex flex-col gap-4 px-0">
<ul className="flex flex-col gap-2">
{data.methods.map((method) => {
const icon = resolveIcon(method.paymentMethod);
const percentageLabel = formatPercentage(method.percentage);
return (
<div className="flex flex-col gap-4 px-0">
<ul className="flex flex-col gap-2">
{data.methods.map((method) => {
const icon = resolveIcon(method.paymentMethod);
const percentageLabel = formatPercentage(method.percentage);
return (
<li
key={method.paymentMethod}
className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0"
>
<div className={ICON_WRAPPER_CLASS}>{icon}</div>
return (
<li
key={method.paymentMethod}
className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0"
>
<div className={ICON_WRAPPER_CLASS}>{icon}</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between">
<p className="font-medium text-foreground text-sm">
{method.paymentMethod}
</p>
<MoneyValues amount={method.amount} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between">
<p className="font-medium text-foreground text-sm">
{method.paymentMethod}
</p>
<MoneyValues amount={method.amount} />
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{method.transactions}{" "}
{method.transactions === 1 ? "lançamento" : "lançamentos"}
</span>
<span>{percentageLabel}%</span>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{method.transactions}{" "}
{method.transactions === 1 ? "lançamento" : "lançamentos"}
</span>
<span>{percentageLabel}%</span>
</div>
<div className="mt-1">
<Progress value={method.percentage} />
</div>
</div>
</li>
);
})}
</ul>
</div>
);
<div className="mt-1">
<Progress value={method.percentage} />
</div>
</div>
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -1,100 +1,100 @@
"use client";
import {
RiCheckboxCircleLine,
RiHourglass2Line,
RiWallet3Line,
} from "@remixicon/react";
import MoneyValues from "@/components/money-values";
import { CardContent } from "@/components/ui/card";
import { WidgetEmptyState } from "@/components/widget-empty-state";
import type { PaymentStatusData } from "@/lib/dashboard/payments/payment-status";
import {
RiCheckboxCircleLine,
RiHourglass2Line,
RiWallet3Line,
} from "@remixicon/react";
import { Progress } from "../ui/progress";
type PaymentStatusWidgetProps = {
data: PaymentStatusData;
data: PaymentStatusData;
};
type CategorySectionProps = {
title: string;
total: number;
confirmed: number;
pending: number;
title: string;
total: number;
confirmed: number;
pending: number;
};
function CategorySection({
title,
total,
confirmed,
pending,
title,
total,
confirmed,
pending,
}: CategorySectionProps) {
// Usa valores absolutos para calcular percentual corretamente
const absTotal = Math.abs(total);
const absConfirmed = Math.abs(confirmed);
const confirmedPercentage =
absTotal > 0 ? (absConfirmed / absTotal) * 100 : 0;
// Usa valores absolutos para calcular percentual corretamente
const absTotal = Math.abs(total);
const absConfirmed = Math.abs(confirmed);
const confirmedPercentage =
absTotal > 0 ? (absConfirmed / absTotal) * 100 : 0;
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">{title}</span>
<MoneyValues amount={total} />
</div>
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">{title}</span>
<MoneyValues amount={total} />
</div>
{/* Barra de progresso */}
<Progress value={confirmedPercentage} className="h-2" />
{/* Barra de progresso */}
<Progress value={confirmedPercentage} className="h-2" />
{/* Status de confirmados e pendentes */}
<div className="flex items-center justify-between gap-4 text-sm">
<div className="flex items-center gap-1.5 ">
<RiCheckboxCircleLine className="size-3 text-emerald-600" />
<MoneyValues amount={confirmed} />
<span className="text-xs text-muted-foreground">confirmados</span>
</div>
{/* Status de confirmados e pendentes */}
<div className="flex items-center justify-between gap-4 text-sm">
<div className="flex items-center gap-1.5 ">
<RiCheckboxCircleLine className="size-3 text-emerald-600" />
<MoneyValues amount={confirmed} />
<span className="text-xs text-muted-foreground">confirmados</span>
</div>
<div className="flex items-center gap-1.5 ">
<RiHourglass2Line className="size-3 text-orange-500" />
<MoneyValues amount={pending} />
<span className="text-xs text-muted-foreground">pendentes</span>
</div>
</div>
</div>
);
<div className="flex items-center gap-1.5 ">
<RiHourglass2Line className="size-3 text-orange-500" />
<MoneyValues amount={pending} />
<span className="text-xs text-muted-foreground">pendentes</span>
</div>
</div>
</div>
);
}
export function PaymentStatusWidget({ data }: PaymentStatusWidgetProps) {
const isEmpty = data.income.total === 0 && data.expenses.total === 0;
const isEmpty = data.income.total === 0 && data.expenses.total === 0;
if (isEmpty) {
return (
<CardContent className="px-0">
<WidgetEmptyState
icon={<RiWallet3Line className="size-6 text-muted-foreground" />}
title="Nenhum valor a receber ou pagar no período"
description="Registre lançamentos para visualizar os valores confirmados e pendentes."
/>
</CardContent>
);
}
if (isEmpty) {
return (
<CardContent className="px-0">
<WidgetEmptyState
icon={<RiWallet3Line className="size-6 text-muted-foreground" />}
title="Nenhum valor a receber ou pagar no período"
description="Registre lançamentos para visualizar os valores confirmados e pendentes."
/>
</CardContent>
);
}
return (
<CardContent className="space-y-6 px-0">
<CategorySection
title="A Receber"
total={data.income.total}
confirmed={data.income.confirmed}
pending={data.income.pending}
/>
return (
<CardContent className="space-y-6 px-0">
<CategorySection
title="A Receber"
total={data.income.total}
confirmed={data.income.confirmed}
pending={data.income.pending}
/>
{/* Linha divisória pontilhada */}
<div className="border-t border-dashed" />
{/* Linha divisória pontilhada */}
<div className="border-t border-dashed" />
<CategorySection
title="A Pagar"
total={data.expenses.total}
confirmed={data.expenses.confirmed}
pending={data.expenses.pending}
/>
</CardContent>
);
<CategorySection
title="A Pagar"
total={data.expenses.total}
confirmed={data.expenses.confirmed}
pending={data.expenses.pending}
/>
</CardContent>
);
}

View File

@@ -1,187 +1,187 @@
"use client";
import { RiArrowDownLine, RiStore3Line } from "@remixicon/react";
import { useEffect, useMemo, useState } from "react";
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
import MoneyValues from "@/components/money-values";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { CATEGORY_TYPE_LABEL } from "@/lib/categorias/constants";
import type { PurchasesByCategoryData } from "@/lib/dashboard/purchases-by-category";
import { RiArrowDownLine, RiStore3Line } from "@remixicon/react";
import { useEffect, useMemo, useState } from "react";
import { WidgetEmptyState } from "../widget-empty-state";
type PurchasesByCategoryWidgetProps = {
data: PurchasesByCategoryData;
data: PurchasesByCategoryData;
};
const formatTransactionDate = (date: Date) => {
const formatter = new Intl.DateTimeFormat("pt-BR", {
weekday: "short",
day: "2-digit",
month: "short",
timeZone: "UTC",
});
const formatter = new Intl.DateTimeFormat("pt-BR", {
weekday: "short",
day: "2-digit",
month: "short",
timeZone: "UTC",
});
const formatted = formatter.format(date);
// Capitaliza a primeira letra do dia da semana
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
const formatted = formatter.format(date);
// Capitaliza a primeira letra do dia da semana
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
};
const STORAGE_KEY = "purchases-by-category-selected";
export function PurchasesByCategoryWidget({
data,
data,
}: PurchasesByCategoryWidgetProps) {
// Inicializa com a categoria salva ou a primeira disponível
const [selectedCategoryId, setSelectedCategoryId] = useState<string>(() => {
if (typeof window === "undefined") {
const firstCategory = data.categories[0];
return firstCategory ? firstCategory.id : "";
}
// Inicializa com a categoria salva ou a primeira disponível
const [selectedCategoryId, setSelectedCategoryId] = useState<string>(() => {
if (typeof window === "undefined") {
const firstCategory = data.categories[0];
return firstCategory ? firstCategory.id : "";
}
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved && data.categories.some((cat) => cat.id === saved)) {
return saved;
}
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved && data.categories.some((cat) => cat.id === saved)) {
return saved;
}
const firstCategory = data.categories[0];
return firstCategory ? firstCategory.id : "";
});
const firstCategory = data.categories[0];
return firstCategory ? firstCategory.id : "";
});
// Agrupa categorias por tipo
const categoriesByType = useMemo(() => {
const grouped: Record<string, typeof data.categories> = {};
// Agrupa categorias por tipo
const categoriesByType = useMemo(() => {
const grouped: Record<string, typeof data.categories> = {};
for (const category of data.categories) {
if (!grouped[category.type]) {
grouped[category.type] = [];
}
const typeGroup = grouped[category.type];
if (typeGroup) {
typeGroup.push(category);
}
}
for (const category of data.categories) {
if (!grouped[category.type]) {
grouped[category.type] = [];
}
const typeGroup = grouped[category.type];
if (typeGroup) {
typeGroup.push(category);
}
}
return grouped;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.categories]);
return grouped;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.categories]);
// Salva a categoria selecionada quando mudar
useEffect(() => {
if (selectedCategoryId) {
sessionStorage.setItem(STORAGE_KEY, selectedCategoryId);
}
}, [selectedCategoryId]);
// Salva a categoria selecionada quando mudar
useEffect(() => {
if (selectedCategoryId) {
sessionStorage.setItem(STORAGE_KEY, selectedCategoryId);
}
}, [selectedCategoryId]);
// Atualiza a categoria selecionada se ela não existir mais na lista
useEffect(() => {
if (
selectedCategoryId &&
!data.categories.some((cat) => cat.id === selectedCategoryId)
) {
const firstCategory = data.categories[0];
if (firstCategory) {
setSelectedCategoryId(firstCategory.id);
} else {
setSelectedCategoryId("");
}
}
}, [data.categories, selectedCategoryId]);
// Atualiza a categoria selecionada se ela não existir mais na lista
useEffect(() => {
if (
selectedCategoryId &&
!data.categories.some((cat) => cat.id === selectedCategoryId)
) {
const firstCategory = data.categories[0];
if (firstCategory) {
setSelectedCategoryId(firstCategory.id);
} else {
setSelectedCategoryId("");
}
}
}, [data.categories, selectedCategoryId]);
const currentTransactions = useMemo(() => {
if (!selectedCategoryId) {
return [];
}
return data.transactionsByCategory[selectedCategoryId] ?? [];
}, [selectedCategoryId, data.transactionsByCategory]);
const currentTransactions = useMemo(() => {
if (!selectedCategoryId) {
return [];
}
return data.transactionsByCategory[selectedCategoryId] ?? [];
}, [selectedCategoryId, data.transactionsByCategory]);
const selectedCategory = useMemo(() => {
return data.categories.find((cat) => cat.id === selectedCategoryId);
}, [data.categories, selectedCategoryId]);
const selectedCategory = useMemo(() => {
return data.categories.find((cat) => cat.id === selectedCategoryId);
}, [data.categories, selectedCategoryId]);
if (data.categories.length === 0) {
return (
<WidgetEmptyState
icon={<RiStore3Line className="size-6 text-muted-foreground" />}
title="Nenhuma categoria encontrada"
description="Crie categorias de despesas ou receitas para visualizar as compras."
/>
);
}
if (data.categories.length === 0) {
return (
<WidgetEmptyState
icon={<RiStore3Line className="size-6 text-muted-foreground" />}
title="Nenhuma categoria encontrada"
description="Crie categorias de despesas ou receitas para visualizar as compras."
/>
);
}
return (
<div className="flex flex-col gap-4 px-0">
<div className="flex items-center gap-3">
<Select
value={selectedCategoryId}
onValueChange={setSelectedCategoryId}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Selecione uma categoria" />
</SelectTrigger>
<SelectContent>
{Object.entries(categoriesByType).map(([type, categories]) => (
<div key={type}>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
{CATEGORY_TYPE_LABEL[
type as keyof typeof CATEGORY_TYPE_LABEL
] ?? type}
</div>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
</div>
return (
<div className="flex flex-col gap-4 px-0">
<div className="flex items-center gap-3">
<Select
value={selectedCategoryId}
onValueChange={setSelectedCategoryId}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Selecione uma categoria" />
</SelectTrigger>
<SelectContent>
{Object.entries(categoriesByType).map(([type, categories]) => (
<div key={type}>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
{CATEGORY_TYPE_LABEL[
type as keyof typeof CATEGORY_TYPE_LABEL
] ?? type}
</div>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
</div>
{currentTransactions.length === 0 ? (
<WidgetEmptyState
icon={<RiArrowDownLine className="size-6 text-muted-foreground" />}
title="Nenhuma compra encontrada"
description={
selectedCategory
? `Não há lançamentos na categoria "${selectedCategory.name}".`
: "Selecione uma categoria para visualizar as compras."
}
/>
) : (
<ul className="flex flex-col">
{currentTransactions.map((transaction) => {
return (
<li
key={transaction.id}
className="flex items-center justify-between gap-3 border-b border-dashed py-2 last:border-b-0 last:pb-0"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<EstabelecimentoLogo name={transaction.name} size={38} />
{currentTransactions.length === 0 ? (
<WidgetEmptyState
icon={<RiArrowDownLine className="size-6 text-muted-foreground" />}
title="Nenhuma compra encontrada"
description={
selectedCategory
? `Não há lançamentos na categoria "${selectedCategory.name}".`
: "Selecione uma categoria para visualizar as compras."
}
/>
) : (
<ul className="flex flex-col">
{currentTransactions.map((transaction) => {
return (
<li
key={transaction.id}
className="flex items-center justify-between gap-3 border-b border-dashed py-2 last:border-b-0 last:pb-0"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<EstabelecimentoLogo name={transaction.name} size={38} />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{transaction.name}
</p>
<p className="text-xs text-muted-foreground">
{formatTransactionDate(transaction.purchaseDate)}
</p>
</div>
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{transaction.name}
</p>
<p className="text-xs text-muted-foreground">
{formatTransactionDate(transaction.purchaseDate)}
</p>
</div>
</div>
<div className="shrink-0 text-foreground">
<MoneyValues amount={transaction.amount} />
</div>
</li>
);
})}
</ul>
)}
</div>
);
<div className="shrink-0 text-foreground">
<MoneyValues amount={transaction.amount} />
</div>
</li>
);
})}
</ul>
)}
</div>
);
}

View File

@@ -1,66 +1,66 @@
import { RiExchangeLine } from "@remixicon/react";
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
import MoneyValues from "@/components/money-values";
import type { RecentTransactionsData } from "@/lib/dashboard/recent-transactions";
import { RiExchangeLine } from "@remixicon/react";
import { WidgetEmptyState } from "../widget-empty-state";
type RecentTransactionsWidgetProps = {
data: RecentTransactionsData;
data: RecentTransactionsData;
};
const formatTransactionDate = (date: Date) => {
const formatter = new Intl.DateTimeFormat("pt-BR", {
weekday: "short",
day: "2-digit",
month: "short",
timeZone: "UTC",
});
const formatter = new Intl.DateTimeFormat("pt-BR", {
weekday: "short",
day: "2-digit",
month: "short",
timeZone: "UTC",
});
const formatted = formatter.format(date);
// Capitaliza a primeira letra do dia da semana
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
const formatted = formatter.format(date);
// Capitaliza a primeira letra do dia da semana
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
};
export function RecentTransactionsWidget({
data,
data,
}: RecentTransactionsWidgetProps) {
return (
<div className="flex flex-col px-0">
{data.transactions.length === 0 ? (
<WidgetEmptyState
icon={<RiExchangeLine className="size-6 text-muted-foreground" />}
title="Nenhum lançamento encontrado"
description="Quando houver despesas registradas, elas aparecerão aqui."
/>
) : (
<ul className="flex flex-col">
{data.transactions.map((transaction) => {
return (
<li
key={transaction.id}
className="flex items-center justify-between gap-3 border-b border-dashed py-2 last:border-b-0 last:pb-0"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<EstabelecimentoLogo name={transaction.name} size={38} />
return (
<div className="flex flex-col px-0">
{data.transactions.length === 0 ? (
<WidgetEmptyState
icon={<RiExchangeLine className="size-6 text-muted-foreground" />}
title="Nenhum lançamento encontrado"
description="Quando houver despesas registradas, elas aparecerão aqui."
/>
) : (
<ul className="flex flex-col">
{data.transactions.map((transaction) => {
return (
<li
key={transaction.id}
className="flex items-center justify-between gap-3 border-b border-dashed py-2 last:border-b-0 last:pb-0"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<EstabelecimentoLogo name={transaction.name} size={38} />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{transaction.name}
</p>
<p className="text-xs text-muted-foreground">
{formatTransactionDate(transaction.purchaseDate)}
</p>
</div>
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{transaction.name}
</p>
<p className="text-xs text-muted-foreground">
{formatTransactionDate(transaction.purchaseDate)}
</p>
</div>
</div>
<div className="shrink-0 text-foreground">
<MoneyValues amount={transaction.amount} />
</div>
</li>
);
})}
</ul>
)}
</div>
);
<div className="shrink-0 text-foreground">
<MoneyValues amount={transaction.amount} />
</div>
</li>
);
})}
</ul>
)}
</div>
);
}

View File

@@ -1,66 +1,66 @@
import { RiRefreshLine } from "@remixicon/react";
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
import MoneyValues from "@/components/money-values";
import { CardContent } from "@/components/ui/card";
import type { RecurringExpensesData } from "@/lib/dashboard/expenses/recurring-expenses";
import { RiRefreshLine } from "@remixicon/react";
import { WidgetEmptyState } from "../widget-empty-state";
type RecurringExpensesWidgetProps = {
data: RecurringExpensesData;
data: RecurringExpensesData;
};
const formatOccurrences = (value: number | null) => {
if (!value) {
return "Recorrência contínua";
}
if (!value) {
return "Recorrência contínua";
}
return `${value} recorrências`;
return `${value} recorrências`;
};
export function RecurringExpensesWidget({
data,
data,
}: RecurringExpensesWidgetProps) {
if (data.expenses.length === 0) {
return (
<WidgetEmptyState
icon={<RiRefreshLine className="size-6 text-muted-foreground" />}
title="Nenhuma despesa recorrente"
description="Lançamentos recorrentes aparecerão aqui conforme forem registrados."
/>
);
}
if (data.expenses.length === 0) {
return (
<WidgetEmptyState
icon={<RiRefreshLine className="size-6 text-muted-foreground" />}
title="Nenhuma despesa recorrente"
description="Lançamentos recorrentes aparecerão aqui conforme forem registrados."
/>
);
}
return (
<CardContent className="flex flex-col gap-4 px-0">
<ul className="flex flex-col gap-2">
{data.expenses.map((expense) => {
return (
<li
key={expense.id}
className="flex items-center gap-3 border-b border-dashed pb-2 last:border-b-0 last:pb-0"
>
<EstabelecimentoLogo name={expense.name} size={38} />
return (
<CardContent className="flex flex-col gap-4 px-0">
<ul className="flex flex-col gap-2">
{data.expenses.map((expense) => {
return (
<li
key={expense.id}
className="flex items-center gap-3 border-b border-dashed pb-2 last:border-b-0 last:pb-0"
>
<EstabelecimentoLogo name={expense.name} size={38} />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between">
<p className="truncate text-foreground text-sm font-medium">
{expense.name}
</p>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between">
<p className="truncate text-foreground text-sm font-medium">
{expense.name}
</p>
<MoneyValues amount={expense.amount} />
</div>
<MoneyValues amount={expense.amount} />
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
{expense.paymentMethod}
</span>
<span>{formatOccurrences(expense.recurrenceCount)}</span>
</div>
</div>
</li>
);
})}
</ul>
</CardContent>
);
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
{expense.paymentMethod}
</span>
<span>{formatOccurrences(expense.recurrenceCount)}</span>
</div>
</div>
</li>
);
})}
</ul>
</CardContent>
);
}

View File

@@ -1,24 +1,24 @@
import {
RiArrowDownLine,
RiArrowUpLine,
RiCurrencyLine,
RiIncreaseDecreaseLine,
RiSubtractLine,
} from "@remixicon/react";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardAction,
CardFooter,
CardHeader,
CardTitle,
Card,
CardAction,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { DashboardCardMetrics } from "@/lib/dashboard/metrics";
import { title_font } from "@/public/fonts/font_index";
import {
RiArrowDownLine,
RiArrowUpLine,
RiCurrencyLine,
RiIncreaseDecreaseLine,
RiSubtractLine,
} from "@remixicon/react";
import MoneyValues from "../money-values";
type SectionCardsProps = {
metrics: DashboardCardMetrics;
metrics: DashboardCardMetrics;
};
type Trend = "up" | "down" | "flat";
@@ -26,75 +26,75 @@ type Trend = "up" | "down" | "flat";
const TREND_THRESHOLD = 0.005;
const CARDS = [
{ label: "Receitas", key: "receitas", icon: RiArrowUpLine },
{ label: "Despesas", key: "despesas", icon: RiArrowDownLine },
{ label: "Balanço", key: "balanco", icon: RiIncreaseDecreaseLine },
{ label: "Previsto", key: "previsto", icon: RiCurrencyLine },
{ label: "Receitas", key: "receitas", icon: RiArrowUpLine },
{ label: "Despesas", key: "despesas", icon: RiArrowDownLine },
{ label: "Balanço", key: "balanco", icon: RiIncreaseDecreaseLine },
{ label: "Previsto", key: "previsto", icon: RiCurrencyLine },
] as const;
const TREND_ICONS = {
up: RiArrowUpLine,
down: RiArrowDownLine,
flat: RiSubtractLine,
up: RiArrowUpLine,
down: RiArrowDownLine,
flat: RiSubtractLine,
} as const;
const getTrend = (current: number, previous: number): Trend => {
const diff = current - previous;
if (diff > TREND_THRESHOLD) return "up";
if (diff < -TREND_THRESHOLD) return "down";
return "flat";
const diff = current - previous;
if (diff > TREND_THRESHOLD) return "up";
if (diff < -TREND_THRESHOLD) return "down";
return "flat";
};
const getPercentChange = (current: number, previous: number): string => {
const EPSILON = 0.01; // Considera valores menores que 1 centavo como zero
const EPSILON = 0.01; // Considera valores menores que 1 centavo como zero
if (Math.abs(previous) < EPSILON) {
if (Math.abs(current) < EPSILON) return "0%";
return "—";
}
if (Math.abs(previous) < EPSILON) {
if (Math.abs(current) < EPSILON) return "0%";
return "—";
}
const change = ((current - previous) / Math.abs(previous)) * 100;
return Number.isFinite(change) && Math.abs(change) < 1000000
? `${change > 0 ? "+" : ""}${change.toFixed(1)}%`
: "—";
const change = ((current - previous) / Math.abs(previous)) * 100;
return Number.isFinite(change) && Math.abs(change) < 1000000
? `${change > 0 ? "+" : ""}${change.toFixed(1)}%`
: "—";
};
export function SectionCards({ metrics }: SectionCardsProps) {
return (
<div
className={`${title_font.className} *:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4`}
>
{CARDS.map(({ label, key, icon: Icon }) => {
const metric = metrics[key];
const trend = getTrend(metric.current, metric.previous);
const TrendIcon = TREND_ICONS[trend];
return (
<div
className={`${title_font.className} *:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4`}
>
{CARDS.map(({ label, key, icon: Icon }) => {
const metric = metrics[key];
const trend = getTrend(metric.current, metric.previous);
const TrendIcon = TREND_ICONS[trend];
return (
<Card key={label} className="@container/card gap-2">
<CardHeader>
<CardTitle className="flex items-center gap-1">
<Icon className="size-4 text-primary" />
{label}
</CardTitle>
<MoneyValues className="text-2xl" amount={metric.current} />
<CardAction>
<Badge variant="outline">
<TrendIcon />
{getPercentChange(metric.current, metric.previous)}
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-2 text-sm">
<div className="line-clamp-1 flex gap-2 text-xs">
Mês anterior
</div>
<div className="text-muted-foreground">
<MoneyValues amount={metric.previous} />
</div>
</CardFooter>
</Card>
);
})}
</div>
);
return (
<Card key={label} className="@container/card gap-2">
<CardHeader>
<CardTitle className="flex items-center gap-1">
<Icon className="size-4 text-primary" />
{label}
</CardTitle>
<MoneyValues className="text-2xl" amount={metric.current} />
<CardAction>
<Badge variant="outline">
<TrendIcon />
{getPercentChange(metric.current, metric.previous)}
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-2 text-sm">
<div className="line-clamp-1 flex gap-2 text-xs">
Mês anterior
</div>
<div className="text-muted-foreground">
<MoneyValues amount={metric.previous} />
</div>
</CardFooter>
</Card>
);
})}
</div>
);
}

View File

@@ -1,47 +1,47 @@
"use client";
import { cn } from "@/lib/utils";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
type SortableWidgetProps = {
id: string;
children: ReactNode;
isEditing: boolean;
id: string;
children: ReactNode;
isEditing: boolean;
};
export function SortableWidget({
id,
children,
isEditing,
id,
children,
isEditing,
}: SortableWidgetProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id, disabled: !isEditing });
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id, disabled: !isEditing });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"relative",
isDragging && "z-50 opacity-90",
isEditing && "cursor-grab active:cursor-grabbing",
)}
{...(isEditing ? { ...attributes, ...listeners } : {})}
>
{children}
</div>
);
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"relative",
isDragging && "z-50 opacity-90",
isEditing && "cursor-grab active:cursor-grabbing",
)}
{...(isEditing ? { ...attributes, ...listeners } : {})}
>
{children}
</div>
);
}

View File

@@ -1,60 +1,60 @@
import { RiStore2Line } from "@remixicon/react";
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
import MoneyValues from "@/components/money-values";
import type { TopEstablishmentsData } from "@/lib/dashboard/top-establishments";
import { RiStore2Line } from "@remixicon/react";
import { WidgetEmptyState } from "../widget-empty-state";
type TopEstablishmentsWidgetProps = {
data: TopEstablishmentsData;
data: TopEstablishmentsData;
};
const formatOccurrencesLabel = (occurrences: number) => {
if (occurrences === 1) {
return "1 lançamento";
}
return `${occurrences} lançamentos`;
if (occurrences === 1) {
return "1 lançamento";
}
return `${occurrences} lançamentos`;
};
export function TopEstablishmentsWidget({
data,
data,
}: TopEstablishmentsWidgetProps) {
return (
<div className="flex flex-col px-0">
{data.establishments.length === 0 ? (
<WidgetEmptyState
icon={<RiStore2Line className="size-6 text-muted-foreground" />}
title="Nenhum estabelecimento encontrado"
description="Quando houver despesas registradas, elas aparecerão aqui."
/>
) : (
<ul className="flex flex-col">
{data.establishments.map((establishment) => {
return (
<li
key={establishment.id}
className="flex items-center justify-between gap-3 border-b border-dashed py-2 last:border-b-0 last:pb-0"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<EstabelecimentoLogo name={establishment.name} size={38} />
return (
<div className="flex flex-col px-0">
{data.establishments.length === 0 ? (
<WidgetEmptyState
icon={<RiStore2Line className="size-6 text-muted-foreground" />}
title="Nenhum estabelecimento encontrado"
description="Quando houver despesas registradas, elas aparecerão aqui."
/>
) : (
<ul className="flex flex-col">
{data.establishments.map((establishment) => {
return (
<li
key={establishment.id}
className="flex items-center justify-between gap-3 border-b border-dashed py-2 last:border-b-0 last:pb-0"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<EstabelecimentoLogo name={establishment.name} size={38} />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{establishment.name}
</p>
<p className="text-xs text-muted-foreground">
{formatOccurrencesLabel(establishment.occurrences)}
</p>
</div>
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{establishment.name}
</p>
<p className="text-xs text-muted-foreground">
{formatOccurrencesLabel(establishment.occurrences)}
</p>
</div>
</div>
<div className="shrink-0 text-foreground">
<MoneyValues amount={establishment.amount} />
</div>
</li>
);
})}
</ul>
)}
</div>
);
<div className="shrink-0 text-foreground">
<MoneyValues amount={establishment.amount} />
</div>
</li>
);
})}
</ul>
)}
</div>
);
}

View File

@@ -1,139 +1,139 @@
"use client";
import { RiArrowUpDoubleLine } from "@remixicon/react";
import { useMemo, useState } from "react";
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
import MoneyValues from "@/components/money-values";
import { Switch } from "@/components/ui/switch";
import type {
TopExpense,
TopExpensesData,
TopExpense,
TopExpensesData,
} from "@/lib/dashboard/expenses/top-expenses";
import { RiArrowUpDoubleLine } from "@remixicon/react";
import { useMemo, useState } from "react";
import { WidgetEmptyState } from "../widget-empty-state";
type TopExpensesWidgetProps = {
allExpenses: TopExpensesData;
cardOnlyExpenses: TopExpensesData;
allExpenses: TopExpensesData;
cardOnlyExpenses: TopExpensesData;
};
const formatTransactionDate = (date: Date) => {
const formatter = new Intl.DateTimeFormat("pt-BR", {
weekday: "short",
day: "2-digit",
month: "short",
timeZone: "UTC",
});
const formatter = new Intl.DateTimeFormat("pt-BR", {
weekday: "short",
day: "2-digit",
month: "short",
timeZone: "UTC",
});
const formatted = formatter.format(date);
// Capitaliza a primeira letra do dia da semana
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
const formatted = formatter.format(date);
// Capitaliza a primeira letra do dia da semana
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
};
const shouldIncludeExpense = (expense: TopExpense) => {
const normalizedName = expense.name.trim().toLowerCase();
const normalizedName = expense.name.trim().toLowerCase();
if (normalizedName === "saldo inicial") {
return false;
}
if (normalizedName === "saldo inicial") {
return false;
}
if (normalizedName.includes("fatura")) {
return false;
}
if (normalizedName.includes("fatura")) {
return false;
}
return true;
return true;
};
const isCardExpense = (expense: TopExpense) =>
expense.paymentMethod?.toLowerCase().includes("cartão") ?? false;
expense.paymentMethod?.toLowerCase().includes("cartão") ?? false;
export function TopExpensesWidget({
allExpenses,
cardOnlyExpenses,
allExpenses,
cardOnlyExpenses,
}: TopExpensesWidgetProps) {
const [cardOnly, setCardOnly] = useState(false);
const normalizedAllExpenses = useMemo(() => {
return allExpenses.expenses.filter(shouldIncludeExpense);
}, [allExpenses]);
const [cardOnly, setCardOnly] = useState(false);
const normalizedAllExpenses = useMemo(() => {
return allExpenses.expenses.filter(shouldIncludeExpense);
}, [allExpenses]);
const normalizedCardOnlyExpenses = useMemo(() => {
const merged = [...cardOnlyExpenses.expenses, ...normalizedAllExpenses];
const seen = new Set<string>();
const normalizedCardOnlyExpenses = useMemo(() => {
const merged = [...cardOnlyExpenses.expenses, ...normalizedAllExpenses];
const seen = new Set<string>();
return merged.filter((expense) => {
if (seen.has(expense.id)) {
return false;
}
return merged.filter((expense) => {
if (seen.has(expense.id)) {
return false;
}
if (!isCardExpense(expense) || !shouldIncludeExpense(expense)) {
return false;
}
if (!isCardExpense(expense) || !shouldIncludeExpense(expense)) {
return false;
}
seen.add(expense.id);
return true;
});
}, [cardOnlyExpenses, normalizedAllExpenses]);
seen.add(expense.id);
return true;
});
}, [cardOnlyExpenses, normalizedAllExpenses]);
const data = cardOnly
? { expenses: normalizedCardOnlyExpenses }
: { expenses: normalizedAllExpenses };
const data = cardOnly
? { expenses: normalizedCardOnlyExpenses }
: { expenses: normalizedAllExpenses };
return (
<div className="flex flex-col gap-4 px-0">
<div className="flex items-center justify-between gap-3">
<label
htmlFor="card-only-toggle"
className="text-sm text-muted-foreground"
>
{cardOnly
? "Somente cartões de crédito ou débito."
: "Todas as despesas"}
</label>
<Switch
id="card-only-toggle"
checked={cardOnly}
onCheckedChange={setCardOnly}
/>
</div>
return (
<div className="flex flex-col gap-4 px-0">
<div className="flex items-center justify-between gap-3">
<label
htmlFor="card-only-toggle"
className="text-sm text-muted-foreground"
>
{cardOnly
? "Somente cartões de crédito ou débito."
: "Todas as despesas"}
</label>
<Switch
id="card-only-toggle"
checked={cardOnly}
onCheckedChange={setCardOnly}
/>
</div>
{data.expenses.length === 0 ? (
<div className="-mt-10">
<WidgetEmptyState
icon={
<RiArrowUpDoubleLine className="size-6 text-muted-foreground" />
}
title="Nenhuma despesa encontrada"
description="Quando houver despesas registradas, elas aparecerão aqui."
/>
</div>
) : (
<ul className="flex flex-col">
{data.expenses.map((expense) => {
return (
<li
key={expense.id}
className="flex items-center justify-between gap-3 border-b border-dashed py-2 last:border-b-0 last:pb-0"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<EstabelecimentoLogo name={expense.name} size={38} />
{data.expenses.length === 0 ? (
<div className="-mt-10">
<WidgetEmptyState
icon={
<RiArrowUpDoubleLine className="size-6 text-muted-foreground" />
}
title="Nenhuma despesa encontrada"
description="Quando houver despesas registradas, elas aparecerão aqui."
/>
</div>
) : (
<ul className="flex flex-col">
{data.expenses.map((expense) => {
return (
<li
key={expense.id}
className="flex items-center justify-between gap-3 border-b border-dashed py-2 last:border-b-0 last:pb-0"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<EstabelecimentoLogo name={expense.name} size={38} />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{expense.name}
</p>
<p className="text-xs text-muted-foreground">
{formatTransactionDate(expense.purchaseDate)}
</p>
</div>
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{expense.name}
</p>
<p className="text-xs text-muted-foreground">
{formatTransactionDate(expense.purchaseDate)}
</p>
</div>
</div>
<div className="shrink-0 text-foreground">
<MoneyValues amount={expense.amount} />
</div>
</li>
);
})}
</ul>
)}
</div>
);
<div className="shrink-0 text-foreground">
<MoneyValues amount={expense.amount} />
</div>
</li>
);
})}
</ul>
)}
</div>
);
}

View File

@@ -1,92 +1,92 @@
"use client";
import { RiRefreshLine, RiSettings4Line } from "@remixicon/react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { widgetsConfig } from "@/lib/dashboard/widgets/widgets-config";
import { RiRefreshLine, RiSettings4Line } from "@remixicon/react";
import { useState } from "react";
type WidgetSettingsDialogProps = {
hiddenWidgets: string[];
onToggleWidget: (widgetId: string) => void;
onReset: () => void;
hiddenWidgets: string[];
onToggleWidget: (widgetId: string) => void;
onReset: () => void;
};
export function WidgetSettingsDialog({
hiddenWidgets,
onToggleWidget,
onReset,
hiddenWidgets,
onToggleWidget,
onReset,
}: WidgetSettingsDialogProps) {
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<RiSettings4Line className="size-4" />
Widgets
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Configurar Widgets</DialogTitle>
<DialogDescription>
Escolha quais widgets deseja exibir no seu dashboard.
</DialogDescription>
</DialogHeader>
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<RiSettings4Line className="size-4" />
Widgets
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Configurar Widgets</DialogTitle>
<DialogDescription>
Escolha quais widgets deseja exibir no seu dashboard.
</DialogDescription>
</DialogHeader>
<div className="max-h-[400px] overflow-y-auto py-4">
<div className="space-y-3">
{widgetsConfig.map((widget) => {
const isVisible = !hiddenWidgets.includes(widget.id);
<div className="max-h-[400px] overflow-y-auto py-4">
<div className="space-y-3">
{widgetsConfig.map((widget) => {
const isVisible = !hiddenWidgets.includes(widget.id);
return (
<div
key={widget.id}
className="flex items-center justify-between gap-4 rounded-lg border p-3"
>
<div className="flex items-center gap-3 min-w-0">
<span className="text-primary shrink-0">{widget.icon}</span>
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{widget.title}
</p>
<p className="text-xs text-muted-foreground truncate">
{widget.subtitle}
</p>
</div>
</div>
<Switch
checked={isVisible}
onCheckedChange={() => onToggleWidget(widget.id)}
/>
</div>
);
})}
</div>
</div>
return (
<div
key={widget.id}
className="flex items-center justify-between gap-4 rounded-lg border p-3"
>
<div className="flex items-center gap-3 min-w-0">
<span className="text-primary shrink-0">{widget.icon}</span>
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{widget.title}
</p>
<p className="text-xs text-muted-foreground truncate">
{widget.subtitle}
</p>
</div>
</div>
<Switch
checked={isVisible}
onCheckedChange={() => onToggleWidget(widget.id)}
/>
</div>
);
})}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={onReset}
className="gap-2"
>
<RiRefreshLine className="size-4" />
Restaurar Padrão
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={onReset}
className="gap-2"
>
<RiRefreshLine className="size-4" />
Restaurar Padrão
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,11 +1,11 @@
type DotIconProps = {
bg_dot: string;
bg_dot: string;
};
export default function DotIcon({ bg_dot }: DotIconProps) {
return (
<span>
<span className={`${bg_dot} flex size-2 rounded-full`}></span>
</span>
);
return (
<span>
<span className={`${bg_dot} flex size-2 rounded-full`}></span>
</span>
);
}

View File

@@ -1,53 +1,53 @@
import type { ReactNode } from "react";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { cn } from "@/lib/utils/ui";
import type { ReactNode } from "react";
interface EmptyStateProps {
title: ReactNode;
description?: ReactNode;
action?: ReactNode;
media?: ReactNode;
mediaVariant?: "default" | "icon";
className?: string;
contentClassName?: string;
children?: ReactNode;
title: ReactNode;
description?: ReactNode;
action?: ReactNode;
media?: ReactNode;
mediaVariant?: "default" | "icon";
className?: string;
contentClassName?: string;
children?: ReactNode;
}
export function EmptyState({
title,
description,
media,
mediaVariant = "default",
className,
contentClassName,
children,
title,
description,
media,
mediaVariant = "default",
className,
contentClassName,
children,
}: EmptyStateProps) {
const hasContent = Boolean(children);
const hasContent = Boolean(children);
return (
<Empty className={cn("w-full max-w-xl min-h-[320px]", className)}>
<EmptyHeader>
{media ? (
<EmptyMedia variant={mediaVariant} className="mb-0">
{media}
</EmptyMedia>
) : null}
<EmptyTitle>{title}</EmptyTitle>
{description ? (
<EmptyDescription>{description}</EmptyDescription>
) : null}
</EmptyHeader>
return (
<Empty className={cn("w-full max-w-xl min-h-[320px]", className)}>
<EmptyHeader>
{media ? (
<EmptyMedia variant={mediaVariant} className="mb-0">
{media}
</EmptyMedia>
) : null}
<EmptyTitle>{title}</EmptyTitle>
{description ? (
<EmptyDescription>{description}</EmptyDescription>
) : null}
</EmptyHeader>
{hasContent ? (
<EmptyContent className={cn(contentClassName)}>{children}</EmptyContent>
) : null}
</Empty>
);
{hasContent ? (
<EmptyContent className={cn(contentClassName)}>{children}</EmptyContent>
) : null}
</Empty>
);
}

View File

@@ -1,75 +1,75 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { DatePicker } from "@/components/ui/date-picker";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { useState } from "react";
type EditPaymentDateDialogProps = {
trigger: React.ReactNode;
currentDate: Date;
onDateChange: (date: Date) => void;
trigger: React.ReactNode;
currentDate: Date;
onDateChange: (date: Date) => void;
};
export function EditPaymentDateDialog({
trigger,
currentDate,
onDateChange,
trigger,
currentDate,
onDateChange,
}: EditPaymentDateDialogProps) {
const [open, setOpen] = useState(false);
const [selectedDate, setSelectedDate] = useState<Date>(currentDate);
const [open, setOpen] = useState(false);
const [selectedDate, setSelectedDate] = useState<Date>(currentDate);
const handleSave = () => {
onDateChange(selectedDate);
setOpen(false);
};
const handleSave = () => {
onDateChange(selectedDate);
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Editar data de pagamento</DialogTitle>
<DialogDescription>
Selecione a data em que o pagamento foi realizado.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="payment-date">Data de pagamento</Label>
<DatePicker
id="payment-date"
value={selectedDate.toISOString().split("T")[0] ?? ""}
onChange={(value) => {
if (value) {
setSelectedDate(new Date(value));
}
}}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
Cancelar
</Button>
<Button type="button" onClick={handleSave}>
Salvar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Editar data de pagamento</DialogTitle>
<DialogDescription>
Selecione a data em que o pagamento foi realizado.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="payment-date">Data de pagamento</Label>
<DatePicker
id="payment-date"
value={selectedDate.toISOString().split("T")[0] ?? ""}
onChange={(value) => {
if (value) {
setSelectedDate(new Date(value));
}
}}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
Cancelar
</Button>
<Button type="button" onClick={handleSave}>
Salvar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,8 +1,13 @@
"use client";
import { RiEditLine } from "@remixicon/react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import {
updateInvoicePaymentStatusAction,
updatePaymentDateAction,
updateInvoicePaymentStatusAction,
updatePaymentDateAction,
} from "@/app/(dashboard)/cartoes/[cartaoId]/fatura/actions";
import DotIcon from "@/components/dot-icon";
import MoneyValues from "@/components/money-values";
@@ -10,352 +15,347 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
INVOICE_PAYMENT_STATUS,
INVOICE_STATUS_BADGE_VARIANT,
INVOICE_STATUS_DESCRIPTION,
INVOICE_STATUS_LABEL,
type InvoicePaymentStatus,
INVOICE_PAYMENT_STATUS,
INVOICE_STATUS_BADGE_VARIANT,
INVOICE_STATUS_DESCRIPTION,
INVOICE_STATUS_LABEL,
type InvoicePaymentStatus,
} from "@/lib/faturas";
import { cn } from "@/lib/utils/ui";
import { RiEditLine } from "@remixicon/react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { EditPaymentDateDialog } from "./edit-payment-date-dialog";
type InvoiceSummaryCardProps = {
cartaoId: string;
period: string;
cardName: string;
cardBrand: string | null;
cardStatus: string | null;
closingDay: string;
dueDay: string;
periodLabel: string;
totalAmount: number;
limitAmount: number | null;
invoiceStatus: InvoicePaymentStatus;
paymentDate: Date | null;
logo?: string | null;
actions?: React.ReactNode;
cartaoId: string;
period: string;
cardName: string;
cardBrand: string | null;
cardStatus: string | null;
closingDay: string;
dueDay: string;
periodLabel: string;
totalAmount: number;
limitAmount: number | null;
invoiceStatus: InvoicePaymentStatus;
paymentDate: Date | null;
logo?: string | null;
actions?: React.ReactNode;
};
const BRAND_ASSETS: Record<string, string> = {
visa: "/bandeiras/visa.svg",
mastercard: "/bandeiras/mastercard.svg",
amex: "/bandeiras/amex.svg",
american: "/bandeiras/amex.svg",
elo: "/bandeiras/elo.svg",
hipercard: "/bandeiras/hipercard.svg",
hiper: "/bandeiras/hipercard.svg",
visa: "/bandeiras/visa.svg",
mastercard: "/bandeiras/mastercard.svg",
amex: "/bandeiras/amex.svg",
american: "/bandeiras/amex.svg",
elo: "/bandeiras/elo.svg",
hipercard: "/bandeiras/hipercard.svg",
hiper: "/bandeiras/hipercard.svg",
};
const resolveBrandAsset = (brand: string) => {
const normalized = brand.trim().toLowerCase();
const normalized = brand.trim().toLowerCase();
const match = (
Object.keys(BRAND_ASSETS) as Array<keyof typeof BRAND_ASSETS>
).find((entry) => normalized.includes(entry));
const match = (
Object.keys(BRAND_ASSETS) as Array<keyof typeof BRAND_ASSETS>
).find((entry) => normalized.includes(entry));
return match ? BRAND_ASSETS[match] : null;
return match ? BRAND_ASSETS[match] : null;
};
const actionLabelByStatus: Record<InvoicePaymentStatus, string> = {
[INVOICE_PAYMENT_STATUS.PENDING]: "Marcar como paga",
[INVOICE_PAYMENT_STATUS.PAID]: "Desfazer pagamento",
[INVOICE_PAYMENT_STATUS.PENDING]: "Marcar como paga",
[INVOICE_PAYMENT_STATUS.PAID]: "Desfazer pagamento",
};
const actionVariantByStatus: Record<
InvoicePaymentStatus,
"default" | "outline"
InvoicePaymentStatus,
"default" | "outline"
> = {
[INVOICE_PAYMENT_STATUS.PENDING]: "default",
[INVOICE_PAYMENT_STATUS.PAID]: "outline",
[INVOICE_PAYMENT_STATUS.PENDING]: "default",
[INVOICE_PAYMENT_STATUS.PAID]: "outline",
};
const formatDay = (value: string) => value.padStart(2, "0");
const resolveLogoPath = (logo?: string | null) => {
if (!logo) return null;
if (
logo.startsWith("http://") ||
logo.startsWith("https://") ||
logo.startsWith("data:")
) {
return logo;
}
if (!logo) return null;
if (
logo.startsWith("http://") ||
logo.startsWith("https://") ||
logo.startsWith("data:")
) {
return logo;
}
return logo.startsWith("/") ? logo : `/logos/${logo}`;
return logo.startsWith("/") ? logo : `/logos/${logo}`;
};
const getCardStatusDotColor = (status: string | null) => {
if (!status) return "bg-gray-400";
const normalizedStatus = status.toLowerCase();
if (normalizedStatus === "ativo" || normalizedStatus === "active") {
return "bg-green-500";
}
return "bg-gray-400";
if (!status) return "bg-gray-400";
const normalizedStatus = status.toLowerCase();
if (normalizedStatus === "ativo" || normalizedStatus === "active") {
return "bg-green-500";
}
return "bg-gray-400";
};
export function InvoiceSummaryCard({
cartaoId,
period,
cardName,
cardBrand,
cardStatus,
closingDay,
dueDay,
periodLabel,
totalAmount,
limitAmount,
invoiceStatus,
paymentDate: initialPaymentDate,
logo,
actions,
cartaoId,
period,
cardName,
cardBrand,
cardStatus,
closingDay,
dueDay,
periodLabel,
totalAmount,
limitAmount,
invoiceStatus,
paymentDate: initialPaymentDate,
logo,
actions,
}: InvoiceSummaryCardProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [paymentDate, setPaymentDate] = useState<Date>(
initialPaymentDate ?? new Date()
);
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [paymentDate, setPaymentDate] = useState<Date>(
initialPaymentDate ?? new Date(),
);
// Atualizar estado quando initialPaymentDate mudar
useEffect(() => {
if (initialPaymentDate) {
setPaymentDate(initialPaymentDate);
}
}, [initialPaymentDate]);
// Atualizar estado quando initialPaymentDate mudar
useEffect(() => {
if (initialPaymentDate) {
setPaymentDate(initialPaymentDate);
}
}, [initialPaymentDate]);
const logoPath = useMemo(() => resolveLogoPath(logo), [logo]);
const logoPath = useMemo(() => resolveLogoPath(logo), [logo]);
const brandAsset = useMemo(
() => (cardBrand ? resolveBrandAsset(cardBrand) : null),
[cardBrand]
);
const brandAsset = useMemo(
() => (cardBrand ? resolveBrandAsset(cardBrand) : null),
[cardBrand],
);
const limitLabel = useMemo(() => {
if (typeof limitAmount !== "number") return "—";
return limitAmount.toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
maximumFractionDigits: 2,
});
}, [limitAmount]);
const limitLabel = useMemo(() => {
if (typeof limitAmount !== "number") return "—";
return limitAmount.toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
maximumFractionDigits: 2,
});
}, [limitAmount]);
const targetStatus =
invoiceStatus === INVOICE_PAYMENT_STATUS.PAID
? INVOICE_PAYMENT_STATUS.PENDING
: INVOICE_PAYMENT_STATUS.PAID;
const targetStatus =
invoiceStatus === INVOICE_PAYMENT_STATUS.PAID
? INVOICE_PAYMENT_STATUS.PENDING
: INVOICE_PAYMENT_STATUS.PAID;
const handleAction = () => {
startTransition(async () => {
const result = await updateInvoicePaymentStatusAction({
cartaoId,
period,
status: targetStatus,
paymentDate:
targetStatus === INVOICE_PAYMENT_STATUS.PAID
? paymentDate.toISOString().split("T")[0]
: undefined,
});
const handleAction = () => {
startTransition(async () => {
const result = await updateInvoicePaymentStatusAction({
cartaoId,
period,
status: targetStatus,
paymentDate:
targetStatus === INVOICE_PAYMENT_STATUS.PAID
? paymentDate.toISOString().split("T")[0]
: undefined,
});
if (result.success) {
toast.success(result.message);
router.refresh();
return;
}
if (result.success) {
toast.success(result.message);
router.refresh();
return;
}
toast.error(result.error);
});
};
toast.error(result.error);
});
};
const handleDateChange = (newDate: Date) => {
setPaymentDate(newDate);
startTransition(async () => {
const result = await updatePaymentDateAction({
cartaoId,
period,
paymentDate: newDate.toISOString().split("T")[0] ?? "",
});
const handleDateChange = (newDate: Date) => {
setPaymentDate(newDate);
startTransition(async () => {
const result = await updatePaymentDateAction({
cartaoId,
period,
paymentDate: newDate.toISOString().split("T")[0] ?? "",
});
if (result.success) {
toast.success(result.message);
router.refresh();
return;
}
if (result.success) {
toast.success(result.message);
router.refresh();
return;
}
toast.error(result.error);
});
};
toast.error(result.error);
});
};
return (
<Card className="border">
<CardHeader className="flex flex-col gap-3">
<div className="flex items-start gap-3">
{logoPath ? (
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border/60 bg-background">
<Image
src={logoPath}
alt={`Logo do cartão ${cardName}`}
width={48}
height={48}
className="h-full w-full object-contain"
/>
</div>
) : cardBrand ? (
<span className="flex size-12 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-background text-sm font-semibold text-muted-foreground">
{cardBrand}
</span>
) : null}
return (
<Card className="border">
<CardHeader className="flex flex-col gap-3">
<div className="flex items-start gap-3">
{logoPath ? (
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border/60 bg-background">
<Image
src={logoPath}
alt={`Logo do cartão ${cardName}`}
width={48}
height={48}
className="h-full w-full object-contain"
/>
</div>
) : cardBrand ? (
<span className="flex size-12 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-background text-sm font-semibold text-muted-foreground">
{cardBrand}
</span>
) : null}
<div className="flex w-full items-start justify-between gap-3">
<div className="space-y-1">
<CardTitle className="text-xl font-semibold text-foreground">
{cardName}
</CardTitle>
<p className="text-sm text-muted-foreground">
Fatura de {periodLabel}
</p>
</div>
{actions ? <div className="shrink-0">{actions}</div> : null}
</div>
</div>
</CardHeader>
<div className="flex w-full items-start justify-between gap-3">
<div className="space-y-1">
<CardTitle className="text-xl font-semibold text-foreground">
{cardName}
</CardTitle>
<p className="text-sm text-muted-foreground">
Fatura de {periodLabel}
</p>
</div>
{actions ? <div className="shrink-0">{actions}</div> : null}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4 border-t border-border/60 border-dashed pt-4">
{/* Destaque Principal */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<DetailItem
label="Valor total"
value={
<MoneyValues
amount={totalAmount}
className="text-2xl text-foreground"
/>
}
/>
<DetailItem
label="Status da fatura"
value={
<Badge
variant={INVOICE_STATUS_BADGE_VARIANT[invoiceStatus]}
className="text-xs"
>
{INVOICE_STATUS_LABEL[invoiceStatus]}
</Badge>
}
/>
</div>
<CardContent className="space-y-4 border-t border-border/60 border-dashed pt-4">
{/* Destaque Principal */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<DetailItem
label="Valor total"
value={
<MoneyValues
amount={totalAmount}
className="text-2xl text-foreground"
/>
}
/>
<DetailItem
label="Status da fatura"
value={
<Badge
variant={INVOICE_STATUS_BADGE_VARIANT[invoiceStatus]}
className="text-xs"
>
{INVOICE_STATUS_LABEL[invoiceStatus]}
</Badge>
}
/>
</div>
{/* Informações Gerais */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<DetailItem
label="Fechamento"
value={
<span className="font-medium">Dia {formatDay(closingDay)}</span>
}
/>
<DetailItem
label="Vencimento"
value={<span className="font-medium">Dia {formatDay(dueDay)}</span>}
/>
<DetailItem
label="Bandeira"
value={
brandAsset ? (
<div className="flex items-center gap-2">
<Image
src={brandAsset}
alt={`Bandeira ${cardBrand}`}
width={32}
height={32}
className="h-5 w-auto rounded"
/>
<span className="truncate">{cardBrand}</span>
</div>
) : cardBrand ? (
<span className="truncate">{cardBrand}</span>
) : (
<span className="text-muted-foreground"></span>
)
}
/>
<DetailItem
label="Status cartão"
value={
cardStatus ? (
<div className="flex items-center gap-1.5">
<DotIcon bg_dot={getCardStatusDotColor(cardStatus)} />
<span className="truncate">{cardStatus}</span>
</div>
) : (
<span className="text-muted-foreground"></span>
)
}
/>
</div>
{/* Informações Gerais */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<DetailItem
label="Fechamento"
value={
<span className="font-medium">Dia {formatDay(closingDay)}</span>
}
/>
<DetailItem
label="Vencimento"
value={<span className="font-medium">Dia {formatDay(dueDay)}</span>}
/>
<DetailItem
label="Bandeira"
value={
brandAsset ? (
<div className="flex items-center gap-2">
<Image
src={brandAsset}
alt={`Bandeira ${cardBrand}`}
width={32}
height={32}
className="h-5 w-auto rounded"
/>
<span className="truncate">{cardBrand}</span>
</div>
) : cardBrand ? (
<span className="truncate">{cardBrand}</span>
) : (
<span className="text-muted-foreground"></span>
)
}
/>
<DetailItem
label="Status cartão"
value={
cardStatus ? (
<div className="flex items-center gap-1.5">
<DotIcon bg_dot={getCardStatusDotColor(cardStatus)} />
<span className="truncate">{cardStatus}</span>
</div>
) : (
<span className="text-muted-foreground"></span>
)
}
/>
</div>
<DetailItem
label="Limite do cartão"
value={limitLabel}
className="sm:w-1/2"
/>
<DetailItem
label="Limite do cartão"
value={limitLabel}
className="sm:w-1/2"
/>
{/* Ações */}
<div className="flex flex-col gap-2 pt-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
{INVOICE_STATUS_DESCRIPTION[invoiceStatus]}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant={actionVariantByStatus[invoiceStatus]}
disabled={isPending}
onClick={handleAction}
className="w-full shrink-0 sm:w-auto"
>
{isPending ? "Salvando..." : actionLabelByStatus[invoiceStatus]}
</Button>
{invoiceStatus === INVOICE_PAYMENT_STATUS.PAID && (
<EditPaymentDateDialog
trigger={
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
aria-label="Editar data de pagamento"
>
<RiEditLine className="size-4" />
</Button>
}
currentDate={paymentDate}
onDateChange={handleDateChange}
/>
)}
</div>
</div>
</CardContent>
</Card>
);
{/* Ações */}
<div className="flex flex-col gap-2 pt-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
{INVOICE_STATUS_DESCRIPTION[invoiceStatus]}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant={actionVariantByStatus[invoiceStatus]}
disabled={isPending}
onClick={handleAction}
className="w-full shrink-0 sm:w-auto"
>
{isPending ? "Salvando..." : actionLabelByStatus[invoiceStatus]}
</Button>
{invoiceStatus === INVOICE_PAYMENT_STATUS.PAID && (
<EditPaymentDateDialog
trigger={
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
aria-label="Editar data de pagamento"
>
<RiEditLine className="size-4" />
</Button>
}
currentDate={paymentDate}
onDateChange={handleDateChange}
/>
)}
</div>
</div>
</CardContent>
</Card>
);
}
type DetailItemProps = {
label?: string;
value: React.ReactNode;
className?: string;
label?: string;
value: React.ReactNode;
className?: string;
};
function DetailItem({ label, value, className }: DetailItemProps) {
return (
<div className={cn("space-y-1", className)}>
{label && (
<span className="block text-xs font-medium uppercase text-muted-foreground/80">
{label}
</span>
)}
<div className="text-base text-foreground">{value}</div>
</div>
);
return (
<div className={cn("space-y-1", className)}>
{label && (
<span className="block text-xs font-medium uppercase text-muted-foreground/80">
{label}
</span>
)}
<div className="text-base text-foreground">{value}</div>
</div>
);
}

View File

@@ -1,154 +1,154 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
RiBugLine,
RiExternalLinkLine,
RiLightbulbLine,
RiMessageLine,
RiQuestionLine,
RiStarLine,
} from "@remixicon/react";
import { useState } from "react";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import {
RiMessageLine,
RiBugLine,
RiLightbulbLine,
RiQuestionLine,
RiStarLine,
RiExternalLinkLine,
} from "@remixicon/react";
const GITHUB_REPO_BASE = "https://github.com/felipegcoutinho/opensheets-app";
const GITHUB_DISCUSSIONS_BASE = `${GITHUB_REPO_BASE}/discussions/new`;
const GITHUB_ISSUES_URL = `${GITHUB_REPO_BASE}/issues/new`;
const feedbackCategories = [
{
id: "bug",
title: "Reportar Bug",
icon: RiBugLine,
description: "Encontrou algo que não está funcionando?",
color: "text-red-500 dark:text-red-400",
url: GITHUB_ISSUES_URL,
},
{
id: "idea",
title: "Sugerir Feature",
icon: RiLightbulbLine,
description: "Tem uma ideia para melhorar o app?",
color: "text-yellow-500 dark:text-yellow-400",
url: `${GITHUB_DISCUSSIONS_BASE}?category=ideias`,
},
{
id: "question",
title: "Dúvidas/Suporte",
icon: RiQuestionLine,
description: "Precisa de ajuda com alguma coisa?",
color: "text-blue-500 dark:text-blue-400",
url: `${GITHUB_DISCUSSIONS_BASE}?category=q-a`,
},
{
id: "experience",
title: "Compartilhar Experiência",
icon: RiStarLine,
description: "Como o OpenSheets tem ajudado você?",
color: "text-purple-500 dark:text-purple-400",
url: `${GITHUB_DISCUSSIONS_BASE}?category=sua-experiencia`,
},
{
id: "bug",
title: "Reportar Bug",
icon: RiBugLine,
description: "Encontrou algo que não está funcionando?",
color: "text-red-500 dark:text-red-400",
url: GITHUB_ISSUES_URL,
},
{
id: "idea",
title: "Sugerir Feature",
icon: RiLightbulbLine,
description: "Tem uma ideia para melhorar o app?",
color: "text-yellow-500 dark:text-yellow-400",
url: `${GITHUB_DISCUSSIONS_BASE}?category=ideias`,
},
{
id: "question",
title: "Dúvidas/Suporte",
icon: RiQuestionLine,
description: "Precisa de ajuda com alguma coisa?",
color: "text-blue-500 dark:text-blue-400",
url: `${GITHUB_DISCUSSIONS_BASE}?category=q-a`,
},
{
id: "experience",
title: "Compartilhar Experiência",
icon: RiStarLine,
description: "Como o OpenSheets tem ajudado você?",
color: "text-purple-500 dark:text-purple-400",
url: `${GITHUB_DISCUSSIONS_BASE}?category=sua-experiencia`,
},
];
export function FeedbackDialog() {
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(false);
const handleCategoryClick = (url: string) => {
window.open(url, "_blank", "noopener,noreferrer");
setOpen(false);
};
const handleCategoryClick = (url: string) => {
window.open(url, "_blank", "noopener,noreferrer");
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"group relative text-muted-foreground transition-all duration-200",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground border"
)}
>
<RiMessageLine className="h-5 w-5" />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>Enviar Feedback</TooltipContent>
</Tooltip>
return (
<Dialog open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"group relative text-muted-foreground transition-all duration-200",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground border",
)}
>
<RiMessageLine className="h-5 w-5" />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>Enviar Feedback</TooltipContent>
</Tooltip>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RiMessageLine className="h-5 w-5" />
Enviar Feedback
</DialogTitle>
<DialogDescription>
Sua opinião é muito importante! Escolha o tipo de feedback que
deseja compartilhar.
</DialogDescription>
</DialogHeader>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RiMessageLine className="h-5 w-5" />
Enviar Feedback
</DialogTitle>
<DialogDescription>
Sua opinião é muito importante! Escolha o tipo de feedback que
deseja compartilhar.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-4">
{feedbackCategories.map((item) => {
const Icon = item.icon;
return (
<button
key={item.id}
onClick={() => handleCategoryClick(item.url)}
className={cn(
"flex items-start gap-3 p-4 rounded-lg border transition-all",
"hover:border-primary hover:bg-accent/50",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
)}
>
<div
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full",
"bg-muted"
)}
>
<Icon className={cn("h-5 w-5", item.color)} />
</div>
<div className="flex-1 text-left space-y-1">
<h3 className="font-semibold text-sm flex items-center gap-2">
{item.title}
<RiExternalLinkLine className="h-3.5 w-3.5 text-muted-foreground" />
</h3>
<p className="text-sm text-muted-foreground">
{item.description}
</p>
</div>
</button>
);
})}
</div>
<div className="grid gap-3 py-4">
{feedbackCategories.map((item) => {
const Icon = item.icon;
return (
<button
key={item.id}
onClick={() => handleCategoryClick(item.url)}
className={cn(
"flex items-start gap-3 p-4 rounded-lg border transition-all",
"hover:border-primary hover:bg-accent/50",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
)}
>
<div
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full",
"bg-muted",
)}
>
<Icon className={cn("h-5 w-5", item.color)} />
</div>
<div className="flex-1 text-left space-y-1">
<h3 className="font-semibold text-sm flex items-center gap-2">
{item.title}
<RiExternalLinkLine className="h-3.5 w-3.5 text-muted-foreground" />
</h3>
<p className="text-sm text-muted-foreground">
{item.description}
</p>
</div>
</button>
);
})}
</div>
<div className="flex items-start gap-2 p-3 bg-muted/50 rounded-lg text-xs text-muted-foreground">
<RiExternalLinkLine className="h-4 w-4 shrink-0 mt-0.5" />
<p>
Você será redirecionado para o GitHub Discussions onde poderá
escrever seu feedback. É necessário ter uma conta no GitHub.
</p>
</div>
</DialogContent>
</Dialog>
);
<div className="flex items-start gap-2 p-3 bg-muted/50 rounded-lg text-xs text-muted-foreground">
<RiExternalLinkLine className="h-4 w-4 shrink-0 mt-0.5" />
<p>
Você será redirecionado para o GitHub Discussions onde poderá
escrever seu feedback. É necessário ter uma conta no GitHub.
</p>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -9,29 +9,29 @@ import { CalculatorDialogButton } from "./calculadora/calculator-dialog";
import { PrivacyModeToggle } from "./privacy-mode-toggle";
type SiteHeaderProps = {
notificationsSnapshot: DashboardNotificationsSnapshot;
notificationsSnapshot: DashboardNotificationsSnapshot;
};
export async function SiteHeader({ notificationsSnapshot }: SiteHeaderProps) {
const user = await getUser();
const _user = await getUser();
return (
<header className="border-b flex h-12 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<SidebarTrigger className="-ml-1" />
<div className="ml-auto flex items-center gap-2">
<NotificationBell
notifications={notificationsSnapshot.notifications}
totalCount={notificationsSnapshot.totalCount}
/>
<CalculatorDialogButton withTooltip />
<PrivacyModeToggle />
<AnimatedThemeToggler />
<span className="text-muted-foreground">|</span>
<FeedbackDialog />
<LogoutButton />
</div>
</div>
</header>
);
return (
<header className="border-b flex h-12 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<SidebarTrigger className="-ml-1" />
<div className="ml-auto flex items-center gap-2">
<NotificationBell
notifications={notificationsSnapshot.notifications}
totalCount={notificationsSnapshot.totalCount}
/>
<CalculatorDialogButton withTooltip />
<PrivacyModeToggle />
<AnimatedThemeToggler />
<span className="text-muted-foreground">|</span>
<FeedbackDialog />
<LogoutButton />
</div>
</div>
</header>
);
}

View File

@@ -1,110 +1,110 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type {
InsightCategoryId,
InsightsResponse,
} from "@/lib/schemas/insights";
import { INSIGHT_CATEGORIES } from "@/lib/schemas/insights";
import { cn } from "@/lib/utils/ui";
import {
RiChatAi3Line,
RiEyeLine,
RiFlashlightLine,
RiLightbulbLine,
RiRocketLine,
type RemixiconComponentType,
type RemixiconComponentType,
RiChatAi3Line,
RiEyeLine,
RiFlashlightLine,
RiLightbulbLine,
RiRocketLine,
} from "@remixicon/react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type {
InsightCategoryId,
InsightsResponse,
} from "@/lib/schemas/insights";
import { INSIGHT_CATEGORIES } from "@/lib/schemas/insights";
import { cn } from "@/lib/utils/ui";
interface InsightsGridProps {
insights: InsightsResponse;
insights: InsightsResponse;
}
const CATEGORY_ICONS: Record<InsightCategoryId, RemixiconComponentType> = {
behaviors: RiEyeLine,
triggers: RiFlashlightLine,
recommendations: RiLightbulbLine,
improvements: RiRocketLine,
behaviors: RiEyeLine,
triggers: RiFlashlightLine,
recommendations: RiLightbulbLine,
improvements: RiRocketLine,
};
const CATEGORY_COLORS: Record<
InsightCategoryId,
{ titleText: string; chatAiIcon: string }
InsightCategoryId,
{ titleText: string; chatAiIcon: string }
> = {
behaviors: {
titleText: "text-orange-700 dark:text-orange-400",
chatAiIcon: "text-orange-600 dark:text-orange-400",
},
triggers: {
titleText: "text-amber-700 dark:text-amber-400 ",
chatAiIcon: "text-amber-600 dark:text-amber-400",
},
recommendations: {
titleText: "text-sky-700 dark:text-sky-400",
chatAiIcon: "text-sky-600 dark:text-sky-400",
},
improvements: {
titleText: "text-emerald-700 dark:text-emerald-400",
chatAiIcon: "text-emerald-600 dark:text-emerald-400",
},
behaviors: {
titleText: "text-orange-700 dark:text-orange-400",
chatAiIcon: "text-orange-600 dark:text-orange-400",
},
triggers: {
titleText: "text-amber-700 dark:text-amber-400 ",
chatAiIcon: "text-amber-600 dark:text-amber-400",
},
recommendations: {
titleText: "text-sky-700 dark:text-sky-400",
chatAiIcon: "text-sky-600 dark:text-sky-400",
},
improvements: {
titleText: "text-emerald-700 dark:text-emerald-400",
chatAiIcon: "text-emerald-600 dark:text-emerald-400",
},
};
export function InsightsGrid({ insights }: InsightsGridProps) {
// Formatar o período para exibição
const [year, month] = insights.month.split("-");
const periodDate = new Date(parseInt(year), parseInt(month) - 1);
const formattedPeriod = format(periodDate, "MMMM 'de' yyyy", {
locale: ptBR,
});
// Formatar o período para exibição
const [year, month] = insights.month.split("-");
const periodDate = new Date(parseInt(year, 10), parseInt(month, 10) - 1);
const formattedPeriod = format(periodDate, "MMMM 'de' yyyy", {
locale: ptBR,
});
return (
<div className="space-y-6">
<div className="space-y-2 px-1 text-muted-foreground">
<p>
No período selecionado ({formattedPeriod}), identificamos os
principais comportamentos e gatilhos que impactaram seu padrão de
consumo.
</p>
<p>Segue um panorama prático com recomendações acionáveis.</p>
</div>
return (
<div className="space-y-6">
<div className="space-y-2 px-1 text-muted-foreground">
<p>
No período selecionado ({formattedPeriod}), identificamos os
principais comportamentos e gatilhos que impactaram seu padrão de
consumo.
</p>
<p>Segue um panorama prático com recomendações acionáveis.</p>
</div>
{/* Grid de Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{insights.categories.map((categoryData) => {
const categoryConfig = INSIGHT_CATEGORIES[categoryData.category];
const colors = CATEGORY_COLORS[categoryData.category];
const Icon = CATEGORY_ICONS[categoryData.category];
{/* Grid de Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{insights.categories.map((categoryData) => {
const categoryConfig = INSIGHT_CATEGORIES[categoryData.category];
const colors = CATEGORY_COLORS[categoryData.category];
const Icon = CATEGORY_ICONS[categoryData.category];
return (
<Card
key={categoryData.category}
className="relative overflow-hidden"
>
<CardHeader>
<div className="flex items-center gap-2">
<Icon className={cn("size-5", colors.chatAiIcon)} />
<CardTitle className={cn("font-semibold", colors.titleText)}>
{categoryConfig.title}
</CardTitle>
</div>
</CardHeader>
<CardContent>
{categoryData.items.map((item, index) => (
<div
key={index}
className="flex flex-1 border-b border-dashed py-2.5 gap-2 items-start last:border-0"
>
<RiChatAi3Line
className={cn("size-4 shrink-0", colors.chatAiIcon)}
/>
<span className="text-sm">{item.text}</span>
</div>
))}
</CardContent>
</Card>
);
})}
</div>
</div>
);
return (
<Card
key={categoryData.category}
className="relative overflow-hidden"
>
<CardHeader>
<div className="flex items-center gap-2">
<Icon className={cn("size-5", colors.chatAiIcon)} />
<CardTitle className={cn("font-semibold", colors.titleText)}>
{categoryConfig.title}
</CardTitle>
</div>
</CardHeader>
<CardContent>
{categoryData.items.map((item, index) => (
<div
key={index}
className="flex flex-1 border-b border-dashed py-2.5 gap-2 items-start last:border-0"
>
<RiChatAi3Line
className={cn("size-4 shrink-0", colors.chatAiIcon)}
/>
<span className="text-sm">{item.text}</span>
</div>
))}
</CardContent>
</Card>
);
})}
</div>
</div>
);
}

View File

@@ -1,10 +1,20 @@
"use client";
import {
deleteSavedInsightsAction,
generateInsightsAction,
loadSavedInsightsAction,
saveInsightsAction,
RiAlertLine,
RiDeleteBinLine,
RiSaveLine,
RiSparklingLine,
} from "@remixicon/react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner";
import {
deleteSavedInsightsAction,
generateInsightsAction,
loadSavedInsightsAction,
saveInsightsAction,
} from "@/app/(dashboard)/insights/actions";
import { DEFAULT_MODEL } from "@/app/(dashboard)/insights/data";
import { Alert, AlertDescription } from "@/components/ui/alert";
@@ -12,269 +22,259 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import type { InsightsResponse } from "@/lib/schemas/insights";
import {
RiAlertLine,
RiDeleteBinLine,
RiSaveLine,
RiSparklingLine,
} from "@remixicon/react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner";
import { EmptyState } from "../empty-state";
import { InsightsGrid } from "./insights-grid";
import { ModelSelector } from "./model-selector";
interface InsightsPageProps {
period: string;
onAnalyze?: () => void;
period: string;
onAnalyze?: () => void;
}
export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL);
const [insights, setInsights] = useState<InsightsResponse | null>(null);
const [isPending, startTransition] = useTransition();
const [isSaving, startSaveTransition] = useTransition();
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 [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL);
const [insights, setInsights] = useState<InsightsResponse | null>(null);
const [isPending, startTransition] = useTransition();
const [isSaving, startSaveTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [isSaved, setIsSaved] = useState(false);
const [savedDate, setSavedDate] = useState<Date | null>(null);
const [isLoading, setIsLoading] = useState(true);
// 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);
}
};
// 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();
}, [period]);
loadSaved();
}, [period]);
const handleAnalyze = () => {
setError(null);
setIsSaved(false);
setSavedDate(null);
onAnalyze?.();
startTransition(async () => {
try {
const result = await generateInsightsAction(period, selectedModel);
const handleAnalyze = () => {
setError(null);
setIsSaved(false);
setSavedDate(null);
onAnalyze?.();
startTransition(async () => {
try {
const result = await generateInsightsAction(period, selectedModel);
if (result.success) {
setInsights(result.data);
toast.success("Insights gerados com sucesso!");
} else {
setError(result.error);
toast.error(result.error);
}
} catch (err) {
const errorMessage = "Erro inesperado ao gerar insights.";
setError(errorMessage);
toast.error(errorMessage);
console.error("Error generating insights:", err);
}
});
};
if (result.success) {
setInsights(result.data);
toast.success("Insights gerados com sucesso!");
} else {
setError(result.error);
toast.error(result.error);
}
} catch (err) {
const errorMessage = "Erro inesperado ao gerar insights.";
setError(errorMessage);
toast.error(errorMessage);
console.error("Error generating insights:", err);
}
});
};
const handleSave = () => {
if (!insights) return;
const handleSave = () => {
if (!insights) return;
startSaveTransition(async () => {
try {
const result = await saveInsightsAction(
period,
selectedModel,
insights
);
startSaveTransition(async () => {
try {
const result = await saveInsightsAction(
period,
selectedModel,
insights,
);
if (result.success) {
setIsSaved(true);
setSavedDate(result.data.createdAt);
toast.success("Análise salva com sucesso!");
} else {
toast.error(result.error);
}
} catch (err) {
toast.error("Erro ao salvar análise.");
console.error("Error saving insights:", err);
}
});
};
if (result.success) {
setIsSaved(true);
setSavedDate(result.data.createdAt);
toast.success("Análise salva com sucesso!");
} else {
toast.error(result.error);
}
} catch (err) {
toast.error("Erro ao salvar análise.");
console.error("Error saving insights:", err);
}
});
};
const handleDelete = () => {
startSaveTransition(async () => {
try {
const result = await deleteSavedInsightsAction(period);
const handleDelete = () => {
startSaveTransition(async () => {
try {
const result = await deleteSavedInsightsAction(period);
if (result.success) {
setIsSaved(false);
setSavedDate(null);
toast.success("Análise removida com sucesso!");
} else {
toast.error(result.error);
}
} catch (err) {
toast.error("Erro ao remover análise.");
console.error("Error deleting insights:", err);
}
});
};
if (result.success) {
setIsSaved(false);
setSavedDate(null);
toast.success("Análise removida com sucesso!");
} else {
toast.error(result.error);
}
} catch (err) {
toast.error("Erro ao remover análise.");
console.error("Error deleting insights:", err);
}
});
};
return (
<div className="flex flex-col gap-6">
{/* Privacy Warning */}
<Alert className="border-none">
<RiAlertLine className="size-4" color="red" />
<AlertDescription className="text-sm text-card-foreground">
<strong>Aviso de privacidade:</strong> Ao gerar insights, seus dados
financeiros serão enviados para o provedor de IA selecionado
(Anthropic, OpenAI, Google ou OpenRouter) para processamento.
Certifique-se de que você confia no provedor escolhido antes de
prosseguir.
</AlertDescription>
</Alert>
return (
<div className="flex flex-col gap-6">
{/* Privacy Warning */}
<Alert className="border-none">
<RiAlertLine className="size-4" color="red" />
<AlertDescription className="text-sm text-card-foreground">
<strong>Aviso de privacidade:</strong> Ao gerar insights, seus dados
financeiros serão enviados para o provedor de IA selecionado
(Anthropic, OpenAI, Google ou OpenRouter) para processamento.
Certifique-se de que você confia no provedor escolhido antes de
prosseguir.
</AlertDescription>
</Alert>
{/* Model Selector */}
<ModelSelector
value={selectedModel}
onValueChange={setSelectedModel}
disabled={isPending}
/>
{/* Model Selector */}
<ModelSelector
value={selectedModel}
onValueChange={setSelectedModel}
disabled={isPending}
/>
{/* Analyze Button */}
<div className="flex items-center gap-3 flex-wrap">
<Button
onClick={handleAnalyze}
disabled={isPending || isLoading}
className="bg-linear-to-r from-primary to-violet-500 dark:from-primary-dark dark:to-emerald-600"
>
<RiSparklingLine className="mr-2 size-5" aria-hidden="true" />
{isPending ? "Analisando..." : "Gerar análise inteligente"}
</Button>
{/* Analyze Button */}
<div className="flex items-center gap-3 flex-wrap">
<Button
onClick={handleAnalyze}
disabled={isPending || isLoading}
className="bg-linear-to-r from-primary to-violet-500 dark:from-primary-dark dark:to-emerald-600"
>
<RiSparklingLine className="mr-2 size-5" aria-hidden="true" />
{isPending ? "Analisando..." : "Gerar análise inteligente"}
</Button>
{insights && !error && (
<Button
onClick={isSaved ? handleDelete : handleSave}
disabled={isSaving || isPending || isLoading}
variant={isSaved ? "destructive" : "outline"}
>
{isSaved ? (
<>
<RiDeleteBinLine className="mr-2 size-4" />
{isSaving ? "Removendo..." : "Remover análise"}
</>
) : (
<>
<RiSaveLine className="mr-2 size-4" />
{isSaving ? "Salvando..." : "Salvar análise"}
</>
)}
</Button>
)}
{insights && !error && (
<Button
onClick={isSaved ? handleDelete : handleSave}
disabled={isSaving || isPending || isLoading}
variant={isSaved ? "destructive" : "outline"}
>
{isSaved ? (
<>
<RiDeleteBinLine className="mr-2 size-4" />
{isSaving ? "Removendo..." : "Remover análise"}
</>
) : (
<>
<RiSaveLine className="mr-2 size-4" />
{isSaving ? "Salvando..." : "Salvar análise"}
</>
)}
</Button>
)}
{isSaved && savedDate && (
<span className="text-sm text-muted-foreground">
Salva em{" "}
{format(new Date(savedDate), "dd/MM/yyyy 'às' HH:mm", {
locale: ptBR,
})}
</span>
)}
</div>
{isSaved && savedDate && (
<span className="text-sm text-muted-foreground">
Salva em{" "}
{format(new Date(savedDate), "dd/MM/yyyy 'às' HH:mm", {
locale: ptBR,
})}
</span>
)}
</div>
{/* 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
{/* 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
dados financeiros do mês selecionado."
/>
</Card>
)}
{!isPending && !isLoading && error && (
<ErrorState error={error} onRetry={handleAnalyze} />
)}
{!isPending && !isLoading && insights && !error && (
<InsightsGrid insights={insights} />
)}
</div>
</div>
);
/>
</Card>
)}
{!isPending && !isLoading && error && (
<ErrorState error={error} onRetry={handleAnalyze} />
)}
{!isPending && !isLoading && insights && !error && (
<InsightsGrid insights={insights} />
)}
</div>
</div>
);
}
function LoadingState() {
return (
<div className="space-y-6">
{/* Intro text skeleton */}
<div className="space-y-2 px-1">
<Skeleton className="h-5 w-full max-w-2xl" />
<Skeleton className="h-5 w-full max-w-md" />
</div>
return (
<div className="space-y-6">
{/* Intro text skeleton */}
<div className="space-y-2 px-1">
<Skeleton className="h-5 w-full max-w-2xl" />
<Skeleton className="h-5 w-full max-w-md" />
</div>
{/* Grid de Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i} className="relative overflow-hidden">
<CardHeader>
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded" />
<Skeleton className="h-5 w-32" />
</div>
</CardHeader>
<CardContent>
{Array.from({ length: 4 }).map((_, j) => (
<div
key={j}
className="flex flex-1 border-b border-dashed py-2.5 gap-2 items-start last:border-0"
>
<Skeleton className="size-4 shrink-0 rounded" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
))}
</CardContent>
</Card>
))}
</div>
</div>
);
{/* Grid de Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i} className="relative overflow-hidden">
<CardHeader>
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded" />
<Skeleton className="h-5 w-32" />
</div>
</CardHeader>
<CardContent>
{Array.from({ length: 4 }).map((_, j) => (
<div
key={j}
className="flex flex-1 border-b border-dashed py-2.5 gap-2 items-start last:border-0"
>
<Skeleton className="size-4 shrink-0 rounded" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
))}
</CardContent>
</Card>
))}
</div>
</div>
);
}
function ErrorState({
error,
onRetry,
error,
onRetry,
}: {
error: string;
onRetry: () => void;
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-semibold text-destructive">
Erro ao gerar insights
</h3>
<p className="text-sm text-muted-foreground max-w-md">{error}</p>
</div>
<Button onClick={onRetry} variant="outline">
Tentar novamente
</Button>
</div>
);
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-semibold text-destructive">
Erro ao gerar insights
</h3>
<p className="text-sm text-muted-foreground max-w-md">{error}</p>
</div>
<Button onClick={onRetry} variant="outline">
Tentar novamente
</Button>
</div>
);
}

View File

@@ -1,236 +1,236 @@
"use client";
import {
AVAILABLE_MODELS,
DEFAULT_PROVIDER,
PROVIDERS,
type AIProvider,
} from "@/app/(dashboard)/insights/data";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { RiExternalLinkLine } from "@remixicon/react";
import Image from "next/image";
import { useEffect, useMemo, useState } from "react";
import {
type AIProvider,
AVAILABLE_MODELS,
DEFAULT_PROVIDER,
PROVIDERS,
} from "@/app/(dashboard)/insights/data";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card } from "../ui/card";
interface ModelSelectorProps {
value: string;
onValueChange: (value: string) => void;
disabled?: boolean;
value: string;
onValueChange: (value: string) => void;
disabled?: boolean;
}
const PROVIDER_ICON_PATHS: Record<
AIProvider,
{ light: string; dark?: string }
AIProvider,
{ light: string; dark?: string }
> = {
openai: {
light: "/providers/chatgpt.svg",
dark: "/providers/chatgpt_dark_mode.svg",
},
anthropic: {
light: "/providers/claude.svg",
},
google: {
light: "/providers/gemini.svg",
},
openrouter: {
light: "/providers/openrouter_light.svg",
dark: "/providers/openrouter_dark.svg",
},
openai: {
light: "/providers/chatgpt.svg",
dark: "/providers/chatgpt_dark_mode.svg",
},
anthropic: {
light: "/providers/claude.svg",
},
google: {
light: "/providers/gemini.svg",
},
openrouter: {
light: "/providers/openrouter_light.svg",
dark: "/providers/openrouter_dark.svg",
},
};
export function ModelSelector({
value,
onValueChange,
disabled,
value,
onValueChange,
disabled,
}: ModelSelectorProps) {
// Estado para armazenar o provider selecionado manualmente
const [selectedProvider, setSelectedProvider] = useState<AIProvider | null>(
null
);
const [customModel, setCustomModel] = useState(value);
// Estado para armazenar o provider selecionado manualmente
const [selectedProvider, setSelectedProvider] = useState<AIProvider | null>(
null,
);
const [customModel, setCustomModel] = useState(value);
// Sincronizar customModel quando value mudar (importante para pré-carregamento)
useEffect(() => {
// Se o value tem "/" é um modelo OpenRouter customizado
if (value.includes("/")) {
setCustomModel(value);
setSelectedProvider("openrouter");
} else {
setCustomModel(value);
// Limpar selectedProvider para deixar o useMemo detectar automaticamente
setSelectedProvider(null);
}
}, [value]);
// Sincronizar customModel quando value mudar (importante para pré-carregamento)
useEffect(() => {
// Se o value tem "/" é um modelo OpenRouter customizado
if (value.includes("/")) {
setCustomModel(value);
setSelectedProvider("openrouter");
} else {
setCustomModel(value);
// Limpar selectedProvider para deixar o useMemo detectar automaticamente
setSelectedProvider(null);
}
}, [value]);
// Determinar provider atual baseado no modelo selecionado ou provider manual
const currentProvider = useMemo(() => {
// Se há um provider selecionado manualmente, use-o
if (selectedProvider) {
return selectedProvider;
}
// Determinar provider atual baseado no modelo selecionado ou provider manual
const currentProvider = useMemo(() => {
// Se há um provider selecionado manualmente, use-o
if (selectedProvider) {
return selectedProvider;
}
// Se o modelo tem "/" é OpenRouter
if (value.includes("/")) {
return "openrouter";
}
// Se o modelo tem "/" é OpenRouter
if (value.includes("/")) {
return "openrouter";
}
// Caso contrário, tente detectar baseado no modelo
const model = AVAILABLE_MODELS.find((m) => m.id === value);
return model?.provider ?? DEFAULT_PROVIDER;
}, [value, selectedProvider]);
// Caso contrário, tente detectar baseado no modelo
const model = AVAILABLE_MODELS.find((m) => m.id === value);
return model?.provider ?? DEFAULT_PROVIDER;
}, [value, selectedProvider]);
// Agrupar modelos por provider
const modelsByProvider = useMemo(() => {
const grouped: Record<
AIProvider,
Array<(typeof AVAILABLE_MODELS)[number]>
> = {
openai: [],
anthropic: [],
google: [],
openrouter: [],
};
// Agrupar modelos por provider
const modelsByProvider = useMemo(() => {
const grouped: Record<
AIProvider,
Array<(typeof AVAILABLE_MODELS)[number]>
> = {
openai: [],
anthropic: [],
google: [],
openrouter: [],
};
AVAILABLE_MODELS.forEach((model) => {
grouped[model.provider].push(model);
});
AVAILABLE_MODELS.forEach((model) => {
grouped[model.provider].push(model);
});
return grouped;
}, []);
return grouped;
}, []);
// Atualizar provider (seleciona primeiro modelo daquele provider)
const handleProviderChange = (newProvider: AIProvider) => {
setSelectedProvider(newProvider);
// Atualizar provider (seleciona primeiro modelo daquele provider)
const handleProviderChange = (newProvider: AIProvider) => {
setSelectedProvider(newProvider);
if (newProvider === "openrouter") {
// Para OpenRouter, usa o modelo customizado ou limpa o valor
onValueChange(customModel || "");
return;
}
if (newProvider === "openrouter") {
// Para OpenRouter, usa o modelo customizado ou limpa o valor
onValueChange(customModel || "");
return;
}
const firstModel = modelsByProvider[newProvider][0];
if (firstModel) {
onValueChange(firstModel.id);
}
};
const firstModel = modelsByProvider[newProvider][0];
if (firstModel) {
onValueChange(firstModel.id);
}
};
// Atualizar modelo customizado do OpenRouter
const handleCustomModelChange = (modelName: string) => {
setCustomModel(modelName);
onValueChange(modelName);
};
// Atualizar modelo customizado do OpenRouter
const handleCustomModelChange = (modelName: string) => {
setCustomModel(modelName);
onValueChange(modelName);
};
return (
<Card className="grid grid-cols-1 lg:grid-cols-[1fr,auto] gap-6 items-start p-6">
{/* Descrição */}
<div className="space-y-2">
<h3 className="text-lg font-semibold">Definir modelo de análise</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Escolha o provedor de IA e o modelo específico que será utilizado para
gerar insights sobre seus dados financeiros. <br />
Diferentes modelos podem oferecer perspectivas variadas na análise.
</p>
</div>
return (
<Card className="grid grid-cols-1 lg:grid-cols-[1fr,auto] gap-6 items-start p-6">
{/* Descrição */}
<div className="space-y-2">
<h3 className="text-lg font-semibold">Definir modelo de análise</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Escolha o provedor de IA e o modelo específico que será utilizado para
gerar insights sobre seus dados financeiros. <br />
Diferentes modelos podem oferecer perspectivas variadas na análise.
</p>
</div>
{/* Seletor */}
<div className="flex flex-col gap-4 min-w-xs">
<RadioGroup
value={currentProvider}
onValueChange={(v) => handleProviderChange(v as AIProvider)}
disabled={disabled}
className="gap-3"
>
{(Object.keys(PROVIDERS) as AIProvider[]).map((providerId) => {
const provider = PROVIDERS[providerId];
const iconPaths = PROVIDER_ICON_PATHS[providerId];
{/* Seletor */}
<div className="flex flex-col gap-4 min-w-xs">
<RadioGroup
value={currentProvider}
onValueChange={(v) => handleProviderChange(v as AIProvider)}
disabled={disabled}
className="gap-3"
>
{(Object.keys(PROVIDERS) as AIProvider[]).map((providerId) => {
const provider = PROVIDERS[providerId];
const iconPaths = PROVIDER_ICON_PATHS[providerId];
return (
<div key={providerId} className="flex items-center gap-3">
<RadioGroupItem
value={providerId}
id={`provider-${providerId}`}
disabled={disabled}
/>
<div className="size-6 relative">
<Image
src={iconPaths.light}
alt={provider.name}
width={22}
height={22}
className={iconPaths.dark ? "dark:hidden" : ""}
/>
{iconPaths.dark && (
<Image
src={iconPaths.dark}
alt={provider.name}
width={22}
height={22}
className="hidden dark:block"
/>
)}
</div>
<Label
htmlFor={`provider-${providerId}`}
className="text-sm font-medium cursor-pointer flex-1"
>
{provider.name}
</Label>
</div>
);
})}
</RadioGroup>
return (
<div key={providerId} className="flex items-center gap-3">
<RadioGroupItem
value={providerId}
id={`provider-${providerId}`}
disabled={disabled}
/>
<div className="size-6 relative">
<Image
src={iconPaths.light}
alt={provider.name}
width={22}
height={22}
className={iconPaths.dark ? "dark:hidden" : ""}
/>
{iconPaths.dark && (
<Image
src={iconPaths.dark}
alt={provider.name}
width={22}
height={22}
className="hidden dark:block"
/>
)}
</div>
<Label
htmlFor={`provider-${providerId}`}
className="text-sm font-medium cursor-pointer flex-1"
>
{provider.name}
</Label>
</div>
);
})}
</RadioGroup>
{/* Seletor de Modelo */}
{currentProvider === "openrouter" ? (
<div className="space-y-2">
<Input
value={customModel}
onChange={(e) => handleCustomModelChange(e.target.value)}
placeholder="Ex: anthropic/claude-3.5-sonnet"
disabled={disabled}
className="border-none bg-neutral-200 dark:bg-neutral-800"
/>
<a
href="https://openrouter.ai/models"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RiExternalLinkLine className="h-3 w-3" />
Ver modelos disponíveis no OpenRouter
</a>
</div>
) : (
<Select
value={value}
onValueChange={onValueChange}
disabled={disabled}
>
<SelectTrigger
disabled={disabled}
className="border-none bg-neutral-200 dark:bg-neutral-800"
>
<SelectValue placeholder="Selecione um modelo" />
</SelectTrigger>
<SelectContent>
{modelsByProvider[currentProvider].map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</Card>
);
{/* Seletor de Modelo */}
{currentProvider === "openrouter" ? (
<div className="space-y-2">
<Input
value={customModel}
onChange={(e) => handleCustomModelChange(e.target.value)}
placeholder="Ex: anthropic/claude-3.5-sonnet"
disabled={disabled}
className="border-none bg-neutral-200 dark:bg-neutral-800"
/>
<a
href="https://openrouter.ai/models"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RiExternalLinkLine className="h-3 w-3" />
Ver modelos disponíveis no OpenRouter
</a>
</div>
) : (
<Select
value={value}
onValueChange={onValueChange}
disabled={disabled}
>
<SelectTrigger
disabled={disabled}
className="border-none bg-neutral-200 dark:bg-neutral-800"
>
<SelectValue placeholder="Selecione um modelo" />
</SelectTrigger>
<SelectContent>
{modelsByProvider[currentProvider].map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</Card>
);
}

View File

@@ -1,424 +1,424 @@
"use client";
import { RiLoader4Line } from "@remixicon/react";
import {
createInstallmentAnticipationAction,
getEligibleInstallmentsAction,
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { toast } from "sonner";
import {
createInstallmentAnticipationAction,
getEligibleInstallmentsAction,
} from "@/app/(dashboard)/lancamentos/anticipation-actions";
import { CategoryIcon } from "@/components/categorias/category-icon";
import { InstallmentSelectionTable } from "./installment-selection-table";
import MoneyValues from "@/components/money-values";
import { PeriodPicker } from "@/components/period-picker";
import { Button } from "@/components/ui/button";
import { CurrencyInput } from "@/components/ui/currency-input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Field,
FieldContent,
FieldGroup,
FieldLabel,
FieldLegend,
Field,
FieldContent,
FieldGroup,
FieldLabel,
FieldLegend,
} from "@/components/ui/field";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { PeriodPicker } from "@/components/period-picker";
import { useControlledState } from "@/hooks/use-controlled-state";
import { useFormState } from "@/hooks/use-form-state";
import type { EligibleInstallment } from "@/lib/installments/anticipation-types";
import { RiLoader4Line } from "@remixicon/react";
import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { toast } from "sonner";
import MoneyValues from "@/components/money-values";
import { InstallmentSelectionTable } from "./installment-selection-table";
interface AnticipateInstallmentsDialogProps {
trigger?: React.ReactNode;
seriesId: string;
lancamentoName: string;
categorias: Array<{ id: string; name: string; icon: string | null }>;
pagadores: Array<{ id: string; name: string }>;
defaultPeriod: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
trigger?: React.ReactNode;
seriesId: string;
lancamentoName: string;
categorias: Array<{ id: string; name: string; icon: string | null }>;
pagadores: Array<{ id: string; name: string }>;
defaultPeriod: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
type AnticipationFormValues = {
anticipationPeriod: string;
discount: number;
pagadorId: string;
categoriaId: string;
note: string;
anticipationPeriod: string;
discount: number;
pagadorId: string;
categoriaId: string;
note: string;
};
export function AnticipateInstallmentsDialog({
trigger,
seriesId,
lancamentoName,
categorias,
pagadores,
defaultPeriod,
open,
onOpenChange,
trigger,
seriesId,
lancamentoName,
categorias,
pagadores,
defaultPeriod,
open,
onOpenChange,
}: AnticipateInstallmentsDialogProps) {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [isLoadingInstallments, setIsLoadingInstallments] = useState(false);
const [eligibleInstallments, setEligibleInstallments] = useState<
EligibleInstallment[]
>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [isLoadingInstallments, setIsLoadingInstallments] = useState(false);
const [eligibleInstallments, setEligibleInstallments] = useState<
EligibleInstallment[]
>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Use controlled state hook for dialog open state
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange
);
// Use controlled state hook for dialog open state
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange,
);
// Use form state hook for form management
const { formState, updateField, setFormState } =
useFormState<AnticipationFormValues>({
anticipationPeriod: defaultPeriod,
discount: 0,
pagadorId: "",
categoriaId: "",
note: "",
});
// Use form state hook for form management
const { formState, updateField, setFormState } =
useFormState<AnticipationFormValues>({
anticipationPeriod: defaultPeriod,
discount: 0,
pagadorId: "",
categoriaId: "",
note: "",
});
// Buscar parcelas elegíveis ao abrir o dialog
useEffect(() => {
if (dialogOpen) {
setIsLoadingInstallments(true);
setSelectedIds([]);
setErrorMessage(null);
// Buscar parcelas elegíveis ao abrir o dialog
useEffect(() => {
if (dialogOpen) {
setIsLoadingInstallments(true);
setSelectedIds([]);
setErrorMessage(null);
getEligibleInstallmentsAction(seriesId)
.then((result) => {
if (result.success && result.data) {
setEligibleInstallments(result.data);
getEligibleInstallmentsAction(seriesId)
.then((result) => {
if (result.success && result.data) {
setEligibleInstallments(result.data);
// Pré-preencher pagador e categoria da primeira parcela
if (result.data.length > 0) {
const first = result.data[0];
setFormState({
anticipationPeriod: defaultPeriod,
discount: 0,
pagadorId: first.pagadorId ?? "",
categoriaId: first.categoriaId ?? "",
note: "",
});
}
} else {
toast.error(result.error || "Erro ao carregar parcelas");
setEligibleInstallments([]);
}
})
.catch((error) => {
console.error("Erro ao buscar parcelas:", error);
toast.error("Erro ao carregar parcelas elegíveis");
setEligibleInstallments([]);
})
.finally(() => {
setIsLoadingInstallments(false);
});
}
}, [dialogOpen, seriesId, defaultPeriod, setFormState]);
// Pré-preencher pagador e categoria da primeira parcela
if (result.data.length > 0) {
const first = result.data[0];
setFormState({
anticipationPeriod: defaultPeriod,
discount: 0,
pagadorId: first.pagadorId ?? "",
categoriaId: first.categoriaId ?? "",
note: "",
});
}
} else {
toast.error(result.error || "Erro ao carregar parcelas");
setEligibleInstallments([]);
}
})
.catch((error) => {
console.error("Erro ao buscar parcelas:", error);
toast.error("Erro ao carregar parcelas elegíveis");
setEligibleInstallments([]);
})
.finally(() => {
setIsLoadingInstallments(false);
});
}
}, [dialogOpen, seriesId, defaultPeriod, setFormState]);
const totalAmount = useMemo(() => {
return eligibleInstallments
.filter((inst) => selectedIds.includes(inst.id))
.reduce((sum, inst) => sum + Number(inst.amount), 0);
}, [eligibleInstallments, selectedIds]);
const totalAmount = useMemo(() => {
return eligibleInstallments
.filter((inst) => selectedIds.includes(inst.id))
.reduce((sum, inst) => sum + Number(inst.amount), 0);
}, [eligibleInstallments, selectedIds]);
const finalAmount = useMemo(() => {
// Se for despesa (negativo), soma o desconto para reduzir
// Se for receita (positivo), subtrai o desconto
const discount = Number(formState.discount) || 0;
return totalAmount < 0 ? totalAmount + discount : totalAmount - discount;
}, [totalAmount, formState.discount]);
const finalAmount = useMemo(() => {
// Se for despesa (negativo), soma o desconto para reduzir
// Se for receita (positivo), subtrai o desconto
const discount = Number(formState.discount) || 0;
return totalAmount < 0 ? totalAmount + discount : totalAmount - discount;
}, [totalAmount, formState.discount]);
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
if (selectedIds.length === 0) {
const message = "Selecione pelo menos uma parcela para antecipar.";
setErrorMessage(message);
toast.error(message);
return;
}
if (selectedIds.length === 0) {
const message = "Selecione pelo menos uma parcela para antecipar.";
setErrorMessage(message);
toast.error(message);
return;
}
if (formState.anticipationPeriod.length === 0) {
const message = "Informe o período da antecipação.";
setErrorMessage(message);
toast.error(message);
return;
}
if (formState.anticipationPeriod.length === 0) {
const message = "Informe o período da antecipação.";
setErrorMessage(message);
toast.error(message);
return;
}
const discount = Number(formState.discount) || 0;
if (discount > Math.abs(totalAmount)) {
const message =
"O desconto não pode ser maior que o valor total das parcelas.";
setErrorMessage(message);
toast.error(message);
return;
}
const discount = Number(formState.discount) || 0;
if (discount > Math.abs(totalAmount)) {
const message =
"O desconto não pode ser maior que o valor total das parcelas.";
setErrorMessage(message);
toast.error(message);
return;
}
startTransition(async () => {
const result = await createInstallmentAnticipationAction({
seriesId,
installmentIds: selectedIds,
anticipationPeriod: formState.anticipationPeriod,
discount: Number(formState.discount) || 0,
pagadorId: formState.pagadorId || undefined,
categoriaId: formState.categoriaId || undefined,
note: formState.note || undefined,
});
startTransition(async () => {
const result = await createInstallmentAnticipationAction({
seriesId,
installmentIds: selectedIds,
anticipationPeriod: formState.anticipationPeriod,
discount: Number(formState.discount) || 0,
pagadorId: formState.pagadorId || undefined,
categoriaId: formState.categoriaId || undefined,
note: formState.note || undefined,
});
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
} else {
const errorMsg = result.error || "Erro ao criar antecipação";
setErrorMessage(errorMsg);
toast.error(errorMsg);
}
});
},
[selectedIds, formState, seriesId, setDialogOpen]
);
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
} else {
const errorMsg = result.error || "Erro ao criar antecipação";
setErrorMessage(errorMsg);
toast.error(errorMsg);
}
});
},
[selectedIds, formState, seriesId, setDialogOpen, totalAmount],
);
const handleCancel = useCallback(() => {
setDialogOpen(false);
}, [setDialogOpen]);
const handleCancel = useCallback(() => {
setDialogOpen(false);
}, [setDialogOpen]);
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-6">
<DialogHeader>
<DialogTitle>Antecipar Parcelas</DialogTitle>
<DialogDescription>{lancamentoName}</DialogDescription>
</DialogHeader>
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-6">
<DialogHeader>
<DialogTitle>Antecipar Parcelas</DialogTitle>
<DialogDescription>{lancamentoName}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Seção 1: Seleção de Parcelas */}
<FieldGroup className="gap-1">
<FieldLegend>Parcelas Disponíveis</FieldLegend>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Seção 1: Seleção de Parcelas */}
<FieldGroup className="gap-1">
<FieldLegend>Parcelas Disponíveis</FieldLegend>
{isLoadingInstallments ? (
<div className="flex items-center justify-center rounded-lg border border-dashed p-8">
<RiLoader4Line className="size-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">
Carregando parcelas...
</span>
</div>
) : (
<div className="max-h-[280px] overflow-y-auto rounded-lg border">
<InstallmentSelectionTable
installments={eligibleInstallments}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
/>
</div>
)}
</FieldGroup>
{isLoadingInstallments ? (
<div className="flex items-center justify-center rounded-lg border border-dashed p-8">
<RiLoader4Line className="size-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">
Carregando parcelas...
</span>
</div>
) : (
<div className="max-h-[280px] overflow-y-auto rounded-lg border">
<InstallmentSelectionTable
installments={eligibleInstallments}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
/>
</div>
)}
</FieldGroup>
{/* Seção 2: Configuração da Antecipação */}
<FieldGroup className="gap-1">
<FieldLegend>Configuração</FieldLegend>
{/* Seção 2: Configuração da Antecipação */}
<FieldGroup className="gap-1">
<FieldLegend>Configuração</FieldLegend>
<div className="grid gap-2 sm:grid-cols-2">
<Field className="gap-1">
<FieldLabel htmlFor="anticipation-period">Período</FieldLabel>
<FieldContent>
<PeriodPicker
value={formState.anticipationPeriod}
onChange={(value) =>
updateField("anticipationPeriod", value)
}
disabled={isPending}
className="w-full"
/>
</FieldContent>
</Field>
<div className="grid gap-2 sm:grid-cols-2">
<Field className="gap-1">
<FieldLabel htmlFor="anticipation-period">Período</FieldLabel>
<FieldContent>
<PeriodPicker
value={formState.anticipationPeriod}
onChange={(value) =>
updateField("anticipationPeriod", value)
}
disabled={isPending}
className="w-full"
/>
</FieldContent>
</Field>
<Field className="gap-1">
<FieldLabel htmlFor="anticipation-discount">
Desconto
</FieldLabel>
<FieldContent>
<CurrencyInput
id="anticipation-discount"
value={formState.discount}
onValueChange={(value) =>
updateField("discount", value ?? 0)
}
placeholder="R$ 0,00"
disabled={isPending}
/>
</FieldContent>
</Field>
<Field className="gap-1">
<FieldLabel htmlFor="anticipation-discount">
Desconto
</FieldLabel>
<FieldContent>
<CurrencyInput
id="anticipation-discount"
value={formState.discount}
onValueChange={(value) =>
updateField("discount", value ?? 0)
}
placeholder="R$ 0,00"
disabled={isPending}
/>
</FieldContent>
</Field>
<Field className="gap-1">
<FieldLabel htmlFor="anticipation-pagador">Pagador</FieldLabel>
<FieldContent>
<Select
value={formState.pagadorId}
onValueChange={(value) => updateField("pagadorId", value)}
disabled={isPending}
>
<SelectTrigger id="anticipation-pagador" className="w-full">
<SelectValue placeholder="Padrão" />
</SelectTrigger>
<SelectContent>
{pagadores.map((pagador) => (
<SelectItem key={pagador.id} value={pagador.id}>
{pagador.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FieldContent>
</Field>
<Field className="gap-1">
<FieldLabel htmlFor="anticipation-pagador">Pagador</FieldLabel>
<FieldContent>
<Select
value={formState.pagadorId}
onValueChange={(value) => updateField("pagadorId", value)}
disabled={isPending}
>
<SelectTrigger id="anticipation-pagador" className="w-full">
<SelectValue placeholder="Padrão" />
</SelectTrigger>
<SelectContent>
{pagadores.map((pagador) => (
<SelectItem key={pagador.id} value={pagador.id}>
{pagador.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FieldContent>
</Field>
<Field className="gap-1">
<FieldLabel htmlFor="anticipation-categoria">
Categoria
</FieldLabel>
<FieldContent>
<Select
value={formState.categoriaId}
onValueChange={(value) => updateField("categoriaId", value)}
disabled={isPending}
>
<SelectTrigger
id="anticipation-categoria"
className="w-full"
>
<SelectValue placeholder="Padrão" />
</SelectTrigger>
<SelectContent>
{categorias.map((categoria) => (
<SelectItem key={categoria.id} value={categoria.id}>
<div className="flex items-center gap-2">
<CategoryIcon
name={categoria.icon ?? undefined}
className="size-4"
/>
<span>{categoria.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</FieldContent>
</Field>
<Field className="gap-1">
<FieldLabel htmlFor="anticipation-categoria">
Categoria
</FieldLabel>
<FieldContent>
<Select
value={formState.categoriaId}
onValueChange={(value) => updateField("categoriaId", value)}
disabled={isPending}
>
<SelectTrigger
id="anticipation-categoria"
className="w-full"
>
<SelectValue placeholder="Padrão" />
</SelectTrigger>
<SelectContent>
{categorias.map((categoria) => (
<SelectItem key={categoria.id} value={categoria.id}>
<div className="flex items-center gap-2">
<CategoryIcon
name={categoria.icon ?? undefined}
className="size-4"
/>
<span>{categoria.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</FieldContent>
</Field>
<Field className="sm:col-span-2">
<FieldLabel htmlFor="anticipation-note">Observação</FieldLabel>
<FieldContent>
<Textarea
id="anticipation-note"
value={formState.note}
onChange={(e) => updateField("note", e.target.value)}
placeholder="Observação (opcional)"
rows={2}
disabled={isPending}
/>
</FieldContent>
</Field>
</div>
</FieldGroup>
<Field className="sm:col-span-2">
<FieldLabel htmlFor="anticipation-note">Observação</FieldLabel>
<FieldContent>
<Textarea
id="anticipation-note"
value={formState.note}
onChange={(e) => updateField("note", e.target.value)}
placeholder="Observação (opcional)"
rows={2}
disabled={isPending}
/>
</FieldContent>
</Field>
</div>
</FieldGroup>
{/* Seção 3: Resumo */}
{selectedIds.length > 0 && (
<div className="rounded-lg border bg-muted/20 p-3">
<h4 className="text-sm font-semibold mb-2">Resumo</h4>
<dl className="space-y-1.5 text-sm">
<div className="flex items-center justify-between">
<dt className="text-muted-foreground">
{selectedIds.length} parcela
{selectedIds.length > 1 ? "s" : ""}
</dt>
<dd className="font-medium tabular-nums">
<MoneyValues amount={totalAmount} className="text-sm" />
</dd>
</div>
{Number(formState.discount) > 0 && (
<div className="flex items-center justify-between">
<dt className="text-muted-foreground">Desconto</dt>
<dd className="font-medium tabular-nums text-green-600">
-{" "}
<MoneyValues
amount={Number(formState.discount)}
className="text-sm"
/>
</dd>
</div>
)}
<div className="flex items-center justify-between border-t pt-1.5">
<dt className="font-medium">Total</dt>
<dd className="text-base font-semibold tabular-nums text-primary">
<MoneyValues amount={finalAmount} className="text-sm" />
</dd>
</div>
</dl>
</div>
)}
{/* Seção 3: Resumo */}
{selectedIds.length > 0 && (
<div className="rounded-lg border bg-muted/20 p-3">
<h4 className="text-sm font-semibold mb-2">Resumo</h4>
<dl className="space-y-1.5 text-sm">
<div className="flex items-center justify-between">
<dt className="text-muted-foreground">
{selectedIds.length} parcela
{selectedIds.length > 1 ? "s" : ""}
</dt>
<dd className="font-medium tabular-nums">
<MoneyValues amount={totalAmount} className="text-sm" />
</dd>
</div>
{Number(formState.discount) > 0 && (
<div className="flex items-center justify-between">
<dt className="text-muted-foreground">Desconto</dt>
<dd className="font-medium tabular-nums text-green-600">
-{" "}
<MoneyValues
amount={Number(formState.discount)}
className="text-sm"
/>
</dd>
</div>
)}
<div className="flex items-center justify-between border-t pt-1.5">
<dt className="font-medium">Total</dt>
<dd className="text-base font-semibold tabular-nums text-primary">
<MoneyValues amount={finalAmount} className="text-sm" />
</dd>
</div>
</dl>
</div>
)}
{/* Mensagem de erro */}
{errorMessage && (
<div
className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
role="alert"
>
{errorMessage}
</div>
)}
{/* Mensagem de erro */}
{errorMessage && (
<div
className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
role="alert"
>
{errorMessage}
</div>
)}
<DialogFooter className="gap-3">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={isPending}
>
Cancelar
</Button>
<Button
type="submit"
disabled={isPending || selectedIds.length === 0}
>
{isPending ? (
<>
<RiLoader4Line className="mr-2 size-4 animate-spin" />
Antecipando...
</>
) : (
"Confirmar Antecipação"
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
<DialogFooter className="gap-3">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={isPending}
>
Cancelar
</Button>
<Button
type="submit"
disabled={isPending || selectedIds.length === 0}
>
{isPending ? (
<>
<RiLoader4Line className="mr-2 size-4 animate-spin" />
Antecipando...
</>
) : (
"Confirmar Antecipação"
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,135 +1,143 @@
"use client";
import { getInstallmentAnticipationsAction } from "@/app/(dashboard)/lancamentos/anticipation-actions";
import { AnticipationCard } from "../../shared/anticipation-card";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from "@/components/ui/empty";
import { useControlledState } from "@/hooks/use-controlled-state";
import type { InstallmentAnticipationWithRelations } from "@/lib/installments/anticipation-types";
import { RiCalendarCheckLine, RiLoader4Line } from "@remixicon/react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { RiCalendarCheckLine, RiLoader4Line } from "@remixicon/react";
import { getInstallmentAnticipationsAction } from "@/app/(dashboard)/lancamentos/anticipation-actions";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { useControlledState } from "@/hooks/use-controlled-state";
import type { InstallmentAnticipationWithRelations } from "@/lib/installments/anticipation-types";
import { AnticipationCard } from "../../shared/anticipation-card";
interface AnticipationHistoryDialogProps {
trigger?: React.ReactNode;
seriesId: string;
lancamentoName: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
onViewLancamento?: (lancamentoId: string) => void;
trigger?: React.ReactNode;
seriesId: string;
lancamentoName: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
onViewLancamento?: (lancamentoId: string) => void;
}
export function AnticipationHistoryDialog({
trigger,
seriesId,
lancamentoName,
open,
onOpenChange,
onViewLancamento,
trigger,
seriesId,
lancamentoName,
open,
onOpenChange,
onViewLancamento,
}: AnticipationHistoryDialogProps) {
const [isLoading, setIsLoading] = useState(false);
const [anticipations, setAnticipations] = useState<
InstallmentAnticipationWithRelations[]
>([]);
const [isLoading, setIsLoading] = useState(false);
const [anticipations, setAnticipations] = useState<
InstallmentAnticipationWithRelations[]
>([]);
// Use controlled state hook for dialog open state
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange
);
// Use controlled state hook for dialog open state
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange,
);
// Buscar antecipações ao abrir o dialog
useEffect(() => {
if (dialogOpen) {
loadAnticipations();
}
}, [dialogOpen, seriesId]);
// Buscar antecipações ao abrir o dialog
useEffect(() => {
if (dialogOpen) {
loadAnticipations();
}
}, [dialogOpen, loadAnticipations]);
const loadAnticipations = async () => {
setIsLoading(true);
const loadAnticipations = async () => {
setIsLoading(true);
try {
const result = await getInstallmentAnticipationsAction(seriesId);
try {
const result = await getInstallmentAnticipationsAction(seriesId);
if (result.success && result.data) {
setAnticipations(result.data);
} else {
toast.error(result.error || "Erro ao carregar histórico de antecipações");
setAnticipations([]);
}
} catch (error) {
console.error("Erro ao buscar antecipações:", error);
toast.error("Erro ao carregar histórico de antecipações");
setAnticipations([]);
} finally {
setIsLoading(false);
}
};
if (result.success && result.data) {
setAnticipations(result.data);
} else {
toast.error(
result.error || "Erro ao carregar histórico de antecipações",
);
setAnticipations([]);
}
} catch (error) {
console.error("Erro ao buscar antecipações:", error);
toast.error("Erro ao carregar histórico de antecipações");
setAnticipations([]);
} finally {
setIsLoading(false);
}
};
const handleCanceled = () => {
// Recarregar lista após cancelamento
loadAnticipations();
};
const handleCanceled = () => {
// Recarregar lista após cancelamento
loadAnticipations();
};
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent className="max-w-3xl px-6 py-5 sm:px-8 sm:py-6">
<DialogHeader>
<DialogTitle>Histórico de Antecipações</DialogTitle>
<DialogDescription>{lancamentoName}</DialogDescription>
</DialogHeader>
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent className="max-w-3xl px-6 py-5 sm:px-8 sm:py-6">
<DialogHeader>
<DialogTitle>Histórico de Antecipações</DialogTitle>
<DialogDescription>{lancamentoName}</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] space-y-4 overflow-y-auto pr-2">
{isLoading ? (
<div className="flex items-center justify-center rounded-lg border border-dashed p-12">
<RiLoader4Line className="size-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">
Carregando histórico...
</span>
</div>
) : anticipations.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<RiCalendarCheckLine className="size-6 text-muted-foreground" />
</EmptyMedia>
<EmptyTitle>Nenhuma antecipação registrada</EmptyTitle>
<EmptyDescription>
As antecipações realizadas para esta compra parcelada aparecerão aqui.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
anticipations.map((anticipation) => (
<AnticipationCard
key={anticipation.id}
anticipation={anticipation}
onViewLancamento={onViewLancamento}
onCanceled={handleCanceled}
/>
))
)}
</div>
<div className="max-h-[60vh] space-y-4 overflow-y-auto pr-2">
{isLoading ? (
<div className="flex items-center justify-center rounded-lg border border-dashed p-12">
<RiLoader4Line className="size-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">
Carregando histórico...
</span>
</div>
) : anticipations.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<RiCalendarCheckLine className="size-6 text-muted-foreground" />
</EmptyMedia>
<EmptyTitle>Nenhuma antecipação registrada</EmptyTitle>
<EmptyDescription>
As antecipações realizadas para esta compra parcelada
aparecerão aqui.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
anticipations.map((anticipation) => (
<AnticipationCard
key={anticipation.id}
anticipation={anticipation}
onViewLancamento={onViewLancamento}
onCanceled={handleCanceled}
/>
))
)}
</div>
{!isLoading && anticipations.length > 0 && (
<div className="border-t pt-4 text-center text-sm text-muted-foreground">
{anticipations.length}{" "}
{anticipations.length === 1
? "antecipação encontrada"
: "antecipações encontradas"}
</div>
)}
</DialogContent>
</Dialog>
);
{!isLoading && anticipations.length > 0 && (
<div className="border-t pt-4 text-center text-sm text-muted-foreground">
{anticipations.length}{" "}
{anticipations.length === 1
? "antecipação encontrada"
: "antecipações encontradas"}
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -1,155 +1,155 @@
"use client";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import MoneyValues from "@/components/money-values";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { EligibleInstallment } from "@/lib/installments/anticipation-types";
import { formatCurrentInstallment } from "@/lib/installments/utils";
import { cn } from "@/lib/utils/ui";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import MoneyValues from "@/components/money-values";
interface InstallmentSelectionTableProps {
installments: EligibleInstallment[];
selectedIds: string[];
onSelectionChange: (ids: string[]) => void;
installments: EligibleInstallment[];
selectedIds: string[];
onSelectionChange: (ids: string[]) => void;
}
export function InstallmentSelectionTable({
installments,
selectedIds,
onSelectionChange,
installments,
selectedIds,
onSelectionChange,
}: InstallmentSelectionTableProps) {
const toggleSelection = (id: string) => {
const newSelection = selectedIds.includes(id)
? selectedIds.filter((selectedId) => selectedId !== id)
: [...selectedIds, id];
onSelectionChange(newSelection);
};
const toggleSelection = (id: string) => {
const newSelection = selectedIds.includes(id)
? selectedIds.filter((selectedId) => selectedId !== id)
: [...selectedIds, id];
onSelectionChange(newSelection);
};
const toggleAll = () => {
if (selectedIds.length === installments.length && installments.length > 0) {
onSelectionChange([]);
} else {
onSelectionChange(installments.map((inst) => inst.id));
}
};
const toggleAll = () => {
if (selectedIds.length === installments.length && installments.length > 0) {
onSelectionChange([]);
} else {
onSelectionChange(installments.map((inst) => inst.id));
}
};
const formatPeriod = (period: string) => {
const [year, month] = period.split("-");
const date = new Date(Number(year), Number(month) - 1);
return format(date, "MMM/yyyy", { locale: ptBR });
};
const formatPeriod = (period: string) => {
const [year, month] = period.split("-");
const date = new Date(Number(year), Number(month) - 1);
return format(date, "MMM/yyyy", { locale: ptBR });
};
const formatDate = (date: Date | null) => {
if (!date) return "—";
return format(date, "dd/MM/yyyy", { locale: ptBR });
};
const formatDate = (date: Date | null) => {
if (!date) return "—";
return format(date, "dd/MM/yyyy", { locale: ptBR });
};
if (installments.length === 0) {
return (
<div className="rounded-lg border border-dashed p-8 text-center">
<p className="text-sm text-muted-foreground">
Nenhuma parcela elegível para antecipação encontrada.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Todas as parcelas desta compra foram pagas ou antecipadas.
</p>
</div>
);
}
if (installments.length === 0) {
return (
<div className="rounded-lg border border-dashed p-8 text-center">
<p className="text-sm text-muted-foreground">
Nenhuma parcela elegível para antecipação encontrada.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Todas as parcelas desta compra foram pagas ou antecipadas.
</p>
</div>
);
}
return (
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={
selectedIds.length === installments.length &&
installments.length > 0
}
onCheckedChange={toggleAll}
aria-label="Selecionar todas as parcelas"
/>
</TableHead>
<TableHead>Parcela</TableHead>
<TableHead>Período</TableHead>
<TableHead>Vencimento</TableHead>
<TableHead className="text-right">Valor</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{installments.map((inst) => {
const isSelected = selectedIds.includes(inst.id);
return (
<TableRow
key={inst.id}
className={cn(
"cursor-pointer transition-colors",
isSelected && "bg-muted/50"
)}
onClick={() => toggleSelection(inst.id)}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelection(inst.id)}
aria-label={`Selecionar parcela ${inst.currentInstallment}`}
/>
</TableCell>
<TableCell>
<Badge variant="outline">
{formatCurrentInstallment(
inst.currentInstallment ?? 0,
inst.installmentCount ?? 0
)}
</Badge>
</TableCell>
<TableCell className="font-medium">
{formatPeriod(inst.period)}
</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(inst.dueDate)}
</TableCell>
<TableCell className="text-right font-semibold tabular-nums">
<MoneyValues amount={Number(inst.amount)} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
return (
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={
selectedIds.length === installments.length &&
installments.length > 0
}
onCheckedChange={toggleAll}
aria-label="Selecionar todas as parcelas"
/>
</TableHead>
<TableHead>Parcela</TableHead>
<TableHead>Período</TableHead>
<TableHead>Vencimento</TableHead>
<TableHead className="text-right">Valor</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{installments.map((inst) => {
const isSelected = selectedIds.includes(inst.id);
return (
<TableRow
key={inst.id}
className={cn(
"cursor-pointer transition-colors",
isSelected && "bg-muted/50",
)}
onClick={() => toggleSelection(inst.id)}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelection(inst.id)}
aria-label={`Selecionar parcela ${inst.currentInstallment}`}
/>
</TableCell>
<TableCell>
<Badge variant="outline">
{formatCurrentInstallment(
inst.currentInstallment ?? 0,
inst.installmentCount ?? 0,
)}
</Badge>
</TableCell>
<TableCell className="font-medium">
{formatPeriod(inst.period)}
</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(inst.dueDate)}
</TableCell>
<TableCell className="text-right font-semibold tabular-nums">
<MoneyValues amount={Number(inst.amount)} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
{selectedIds.length > 0 && (
<div className="border-t bg-muted/20 px-4 py-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{selectedIds.length}{" "}
{selectedIds.length === 1
? "parcela selecionada"
: "parcelas selecionadas"}
</span>
<span className="font-semibold">
Total:{" "}
<MoneyValues
amount={installments
.filter((inst) => selectedIds.includes(inst.id))
.reduce((sum, inst) => sum + Number(inst.amount), 0)}
/>
</span>
</div>
</div>
)}
</div>
);
{selectedIds.length > 0 && (
<div className="border-t bg-muted/20 px-4 py-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{selectedIds.length}{" "}
{selectedIds.length === 1
? "parcela selecionada"
: "parcelas selecionadas"}
</span>
<span className="font-semibold">
Total:{" "}
<MoneyValues
amount={installments
.filter((inst) => selectedIds.includes(inst.id))
.reduce((sum, inst) => sum + Number(inst.amount), 0)}
/>
</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,162 +1,162 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useState } from "react";
export type BulkActionScope = "current" | "future" | "all";
type BulkActionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
actionType: "edit" | "delete";
seriesType: "installment" | "recurring";
currentNumber?: number;
totalCount?: number;
onConfirm: (scope: BulkActionScope) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
actionType: "edit" | "delete";
seriesType: "installment" | "recurring";
currentNumber?: number;
totalCount?: number;
onConfirm: (scope: BulkActionScope) => void;
};
export function BulkActionDialog({
open,
onOpenChange,
actionType,
seriesType,
currentNumber,
totalCount,
onConfirm,
open,
onOpenChange,
actionType,
seriesType,
currentNumber,
totalCount,
onConfirm,
}: BulkActionDialogProps) {
const [scope, setScope] = useState<BulkActionScope>("current");
const [scope, setScope] = useState<BulkActionScope>("current");
const handleConfirm = () => {
onConfirm(scope);
onOpenChange(false);
};
const handleConfirm = () => {
onConfirm(scope);
onOpenChange(false);
};
const seriesLabel =
seriesType === "installment" ? "parcelamento" : "recorrência";
const actionLabel = actionType === "edit" ? "editar" : "remover";
const seriesLabel =
seriesType === "installment" ? "parcelamento" : "recorrência";
const actionLabel = actionType === "edit" ? "editar" : "remover";
const getDescription = () => {
if (seriesType === "installment" && currentNumber && totalCount) {
return `Este lançamento faz parte de um ${seriesLabel} (${currentNumber}/${totalCount}). Escolha o que deseja ${actionLabel}:`;
}
return `Este lançamento faz parte de uma ${seriesLabel}. Escolha o que deseja ${actionLabel}:`;
};
const getDescription = () => {
if (seriesType === "installment" && currentNumber && totalCount) {
return `Este lançamento faz parte de um ${seriesLabel} (${currentNumber}/${totalCount}). Escolha o que deseja ${actionLabel}:`;
}
return `Este lançamento faz parte de uma ${seriesLabel}. Escolha o que deseja ${actionLabel}:`;
};
const getCurrentLabel = () => {
if (seriesType === "installment" && currentNumber) {
return `Apenas esta parcela (${currentNumber}/${totalCount})`;
}
return "Apenas este lançamento";
};
const getCurrentLabel = () => {
if (seriesType === "installment" && currentNumber) {
return `Apenas esta parcela (${currentNumber}/${totalCount})`;
}
return "Apenas este lançamento";
};
const getFutureLabel = () => {
if (seriesType === "installment" && currentNumber && totalCount) {
const remaining = totalCount - currentNumber + 1;
return `Esta e as próximas parcelas (${remaining} ${
remaining === 1 ? "parcela" : "parcelas"
})`;
}
return "Este e os próximos lançamentos";
};
const getFutureLabel = () => {
if (seriesType === "installment" && currentNumber && totalCount) {
const remaining = totalCount - currentNumber + 1;
return `Esta e as próximas parcelas (${remaining} ${
remaining === 1 ? "parcela" : "parcelas"
})`;
}
return "Este e os próximos lançamentos";
};
const getAllLabel = () => {
if (seriesType === "installment" && totalCount) {
return `Todas as parcelas (${totalCount} ${
totalCount === 1 ? "parcela" : "parcelas"
})`;
}
return `Todos os lançamentos da ${seriesLabel}`;
};
const getAllLabel = () => {
if (seriesType === "installment" && totalCount) {
return `Todas as parcelas (${totalCount} ${
totalCount === 1 ? "parcela" : "parcelas"
})`;
}
return `Todos os lançamentos da ${seriesLabel}`;
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="capitalize">
{actionLabel} {seriesLabel}
</DialogTitle>
<DialogDescription>{getDescription()}</DialogDescription>
</DialogHeader>
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="capitalize">
{actionLabel} {seriesLabel}
</DialogTitle>
<DialogDescription>{getDescription()}</DialogDescription>
</DialogHeader>
<RadioGroup
value={scope}
onValueChange={(v) => setScope(v as BulkActionScope)}
>
<div className="space-y-4">
<div className="flex items-start space-x-3">
<RadioGroupItem value="current" id="current" className="mt-0.5" />
<div className="flex-1">
<Label
htmlFor="current"
className="text-sm cursor-pointer font-medium"
>
{getCurrentLabel()}
</Label>
<p className="text-xs text-muted-foreground">
Aplica a alteração apenas neste lançamento
</p>
</div>
</div>
<RadioGroup
value={scope}
onValueChange={(v) => setScope(v as BulkActionScope)}
>
<div className="space-y-4">
<div className="flex items-start space-x-3">
<RadioGroupItem value="current" id="current" className="mt-0.5" />
<div className="flex-1">
<Label
htmlFor="current"
className="text-sm cursor-pointer font-medium"
>
{getCurrentLabel()}
</Label>
<p className="text-xs text-muted-foreground">
Aplica a alteração apenas neste lançamento
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="future" id="future" className="mt-0.5" />
<div className="flex-1">
<Label
htmlFor="future"
className="text-sm cursor-pointer font-medium"
>
{getFutureLabel()}
</Label>
<p className="text-xs text-muted-foreground">
Aplica a alteração neste e nos próximos lançamentos da série
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="future" id="future" className="mt-0.5" />
<div className="flex-1">
<Label
htmlFor="future"
className="text-sm cursor-pointer font-medium"
>
{getFutureLabel()}
</Label>
<p className="text-xs text-muted-foreground">
Aplica a alteração neste e nos próximos lançamentos da série
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="all" id="all" className="mt-0.5" />
<div className="flex-1">
<Label
htmlFor="all"
className="text-sm cursor-pointer font-medium"
>
{getAllLabel()}
</Label>
<p className="text-xs text-muted-foreground">
Aplica a alteração em todos os lançamentos da série
</p>
</div>
</div>
</div>
</RadioGroup>
<div className="flex items-start space-x-3">
<RadioGroupItem value="all" id="all" className="mt-0.5" />
<div className="flex-1">
<Label
htmlFor="all"
className="text-sm cursor-pointer font-medium"
>
{getAllLabel()}
</Label>
<p className="text-xs text-muted-foreground">
Aplica a alteração em todos os lançamentos da série
</p>
</div>
</div>
</div>
</RadioGroup>
<DialogFooter className="gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancelar
</Button>
<Button
type="button"
onClick={handleConfirm}
variant={actionType === "delete" ? "destructive" : "default"}
>
Confirmar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
<DialogFooter className="gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancelar
</Button>
<Button
type="button"
onClick={handleConfirm}
variant={actionType === "delete" ? "destructive" : "default"}
>
Confirmar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,363 +1,380 @@
"use client";
import { useCallback, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { createLancamentoAction } from "@/app/(dashboard)/lancamentos/actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { groupAndSortCategorias } from "@/lib/lancamentos/categoria-helpers";
import { useCallback, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import type { LancamentoItem, SelectOption } from "../types";
import {
CategoriaSelectContent,
ContaCartaoSelectContent,
PagadorSelectContent,
CategoriaSelectContent,
ContaCartaoSelectContent,
PagadorSelectContent,
} from "../select-items";
import type { LancamentoItem, SelectOption } from "../types";
interface BulkImportDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
items: LancamentoItem[];
pagadorOptions: SelectOption[];
contaOptions: SelectOption[];
cartaoOptions: SelectOption[];
categoriaOptions: SelectOption[];
defaultPagadorId?: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
items: LancamentoItem[];
pagadorOptions: SelectOption[];
contaOptions: SelectOption[];
cartaoOptions: SelectOption[];
categoriaOptions: SelectOption[];
defaultPagadorId?: string | null;
}
export function BulkImportDialog({
open,
onOpenChange,
items,
pagadorOptions,
contaOptions,
cartaoOptions,
categoriaOptions,
defaultPagadorId,
open,
onOpenChange,
items,
pagadorOptions,
contaOptions,
cartaoOptions,
categoriaOptions,
defaultPagadorId,
}: BulkImportDialogProps) {
const [pagadorId, setPagadorId] = useState<string | undefined>(
defaultPagadorId ?? undefined
);
const [categoriaId, setCategoriaId] = useState<string | undefined>(undefined);
const [contaId, setContaId] = useState<string | undefined>(undefined);
const [cartaoId, setCartaoId] = useState<string | undefined>(undefined);
const [isPending, startTransition] = useTransition();
const [pagadorId, setPagadorId] = useState<string | undefined>(
defaultPagadorId ?? undefined,
);
const [categoriaId, setCategoriaId] = useState<string | undefined>(undefined);
const [contaId, setContaId] = useState<string | undefined>(undefined);
const [cartaoId, setCartaoId] = useState<string | undefined>(undefined);
const [isPending, startTransition] = useTransition();
// Reset form when dialog opens/closes
const handleOpenChange = useCallback(
(newOpen: boolean) => {
if (!newOpen) {
setPagadorId(defaultPagadorId ?? undefined);
setCategoriaId(undefined);
setContaId(undefined);
setCartaoId(undefined);
}
onOpenChange(newOpen);
},
[onOpenChange, defaultPagadorId]
);
// Reset form when dialog opens/closes
const handleOpenChange = useCallback(
(newOpen: boolean) => {
if (!newOpen) {
setPagadorId(defaultPagadorId ?? undefined);
setCategoriaId(undefined);
setContaId(undefined);
setCartaoId(undefined);
}
onOpenChange(newOpen);
},
[onOpenChange, defaultPagadorId],
);
const categoriaGroups = useMemo(() => {
// Get unique transaction types from items
const transactionTypes = new Set(items.map((item) => item.transactionType));
const categoriaGroups = useMemo(() => {
// Get unique transaction types from items
const transactionTypes = new Set(items.map((item) => item.transactionType));
// Filter categories based on transaction types
const filtered = categoriaOptions.filter((option) => {
if (!option.group) return false;
return Array.from(transactionTypes).some(
(type) => option.group?.toLowerCase() === type.toLowerCase()
);
});
// Filter categories based on transaction types
const filtered = categoriaOptions.filter((option) => {
if (!option.group) return false;
return Array.from(transactionTypes).some(
(type) => option.group?.toLowerCase() === type.toLowerCase(),
);
});
return groupAndSortCategorias(filtered);
}, [categoriaOptions, items]);
return groupAndSortCategorias(filtered);
}, [categoriaOptions, items]);
const handleSubmit = useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const handleSubmit = useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!pagadorId) {
toast.error("Selecione o pagador.");
return;
}
if (!pagadorId) {
toast.error("Selecione o pagador.");
return;
}
if (!categoriaId) {
toast.error("Selecione a categoria.");
return;
}
if (!categoriaId) {
toast.error("Selecione a categoria.");
return;
}
startTransition(async () => {
let successCount = 0;
let errorCount = 0;
startTransition(async () => {
let successCount = 0;
let errorCount = 0;
for (const item of items) {
const sanitizedAmount = Math.abs(item.amount);
for (const item of items) {
const sanitizedAmount = Math.abs(item.amount);
// Determine payment method based on original item
const isCredit = item.paymentMethod === "Cartão de crédito";
// Determine payment method based on original item
const isCredit = item.paymentMethod === "Cartão de crédito";
// Validate payment method fields
if (isCredit && !cartaoId) {
toast.error("Selecione um cartão de crédito.");
return;
}
// Validate payment method fields
if (isCredit && !cartaoId) {
toast.error("Selecione um cartão de crédito.");
return;
}
if (!isCredit && !contaId) {
toast.error("Selecione uma conta.");
return;
}
if (!isCredit && !contaId) {
toast.error("Selecione uma conta.");
return;
}
const payload = {
purchaseDate: item.purchaseDate,
period: item.period,
name: item.name,
transactionType: item.transactionType as "Despesa" | "Receita" | "Transferência",
amount: sanitizedAmount,
condition: item.condition as "À vista" | "Parcelado" | "Recorrente",
paymentMethod: item.paymentMethod as "Cartão de crédito" | "Cartão de débito" | "Pix" | "Dinheiro" | "Boleto" | "Pré-Pago | VR/VA" | "Transferência bancária",
pagadorId,
secondaryPagadorId: undefined,
isSplit: false,
contaId: isCredit ? undefined : contaId,
cartaoId: isCredit ? cartaoId : undefined,
categoriaId,
note: item.note || undefined,
isSettled: isCredit ? null : Boolean(item.isSettled),
installmentCount:
item.condition === "Parcelado" && item.installmentCount
? Number(item.installmentCount)
: undefined,
recurrenceCount:
item.condition === "Recorrente" && item.recurrenceCount
? Number(item.recurrenceCount)
: undefined,
dueDate:
item.paymentMethod === "Boleto" && item.dueDate
? item.dueDate
: undefined,
};
const payload = {
purchaseDate: item.purchaseDate,
period: item.period,
name: item.name,
transactionType: item.transactionType as
| "Despesa"
| "Receita"
| "Transferência",
amount: sanitizedAmount,
condition: item.condition as "À vista" | "Parcelado" | "Recorrente",
paymentMethod: item.paymentMethod as
| "Cartão de crédito"
| "Cartão de débito"
| "Pix"
| "Dinheiro"
| "Boleto"
| "Pré-Pago | VR/VA"
| "Transferência bancária",
pagadorId,
secondaryPagadorId: undefined,
isSplit: false,
contaId: isCredit ? undefined : contaId,
cartaoId: isCredit ? cartaoId : undefined,
categoriaId,
note: item.note || undefined,
isSettled: isCredit ? null : Boolean(item.isSettled),
installmentCount:
item.condition === "Parcelado" && item.installmentCount
? Number(item.installmentCount)
: undefined,
recurrenceCount:
item.condition === "Recorrente" && item.recurrenceCount
? Number(item.recurrenceCount)
: undefined,
dueDate:
item.paymentMethod === "Boleto" && item.dueDate
? item.dueDate
: undefined,
};
const result = await createLancamentoAction(payload as any);
const result = await createLancamentoAction(payload as any);
if (result.success) {
successCount++;
} else {
errorCount++;
console.error(`Failed to import ${item.name}:`, result.error);
}
}
if (result.success) {
successCount++;
} else {
errorCount++;
console.error(`Failed to import ${item.name}:`, result.error);
}
}
if (errorCount === 0) {
toast.success(
`${successCount} ${
successCount === 1 ? "lançamento importado" : "lançamentos importados"
} com sucesso!`
);
handleOpenChange(false);
} else if (successCount > 0) {
toast.warning(
`${successCount} importados, ${errorCount} falharam. Verifique o console para detalhes.`
);
} else {
toast.error("Falha ao importar lançamentos. Verifique o console.");
}
});
},
[items, pagadorId, categoriaId, contaId, cartaoId, handleOpenChange]
);
if (errorCount === 0) {
toast.success(
`${successCount} ${
successCount === 1
? "lançamento importado"
: "lançamentos importados"
} com sucesso!`,
);
handleOpenChange(false);
} else if (successCount > 0) {
toast.warning(
`${successCount} importados, ${errorCount} falharam. Verifique o console para detalhes.`,
);
} else {
toast.error("Falha ao importar lançamentos. Verifique o console.");
}
});
},
[items, pagadorId, categoriaId, contaId, cartaoId, handleOpenChange],
);
const itemCount = items.length;
const hasCredit = items.some((item) => item.paymentMethod === "Cartão de crédito");
const hasNonCredit = items.some((item) => item.paymentMethod !== "Cartão de crédito");
const itemCount = items.length;
const hasCredit = items.some(
(item) => item.paymentMethod === "Cartão de crédito",
);
const hasNonCredit = items.some(
(item) => item.paymentMethod !== "Cartão de crédito",
);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Importar Lançamentos</DialogTitle>
<DialogDescription>
Importando {itemCount} {itemCount === 1 ? "lançamento" : "lançamentos"}.
Selecione o pagador, categoria e forma de pagamento para aplicar a todos.
</DialogDescription>
</DialogHeader>
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Importar Lançamentos</DialogTitle>
<DialogDescription>
Importando {itemCount}{" "}
{itemCount === 1 ? "lançamento" : "lançamentos"}. Selecione o
pagador, categoria e forma de pagamento para aplicar a todos.
</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="pagador">Pagador *</Label>
<Select value={pagadorId} onValueChange={setPagadorId}>
<SelectTrigger id="pagador" className="w-full">
<SelectValue placeholder="Selecione o pagador">
{pagadorId &&
(() => {
const selectedOption = pagadorOptions.find(
(opt) => opt.value === pagadorId
);
return selectedOption ? (
<PagadorSelectContent
label={selectedOption.label}
avatarUrl={selectedOption.avatarUrl}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{pagadorOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<PagadorSelectContent
label={option.label}
avatarUrl={option.avatarUrl}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="pagador">Pagador *</Label>
<Select value={pagadorId} onValueChange={setPagadorId}>
<SelectTrigger id="pagador" className="w-full">
<SelectValue placeholder="Selecione o pagador">
{pagadorId &&
(() => {
const selectedOption = pagadorOptions.find(
(opt) => opt.value === pagadorId,
);
return selectedOption ? (
<PagadorSelectContent
label={selectedOption.label}
avatarUrl={selectedOption.avatarUrl}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{pagadorOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<PagadorSelectContent
label={option.label}
avatarUrl={option.avatarUrl}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="categoria">Categoria *</Label>
<Select value={categoriaId} onValueChange={setCategoriaId}>
<SelectTrigger id="categoria" className="w-full">
<SelectValue placeholder="Selecione a categoria">
{categoriaId &&
(() => {
const selectedOption = categoriaOptions.find(
(opt) => opt.value === categoriaId
);
return selectedOption ? (
<CategoriaSelectContent
label={selectedOption.label}
icon={selectedOption.icon}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{categoriaGroups.map((group) => (
<SelectGroup key={group.label}>
<SelectLabel>{group.label}</SelectLabel>
{group.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
<CategoriaSelectContent
label={option.label}
icon={option.icon}
/>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="categoria">Categoria *</Label>
<Select value={categoriaId} onValueChange={setCategoriaId}>
<SelectTrigger id="categoria" className="w-full">
<SelectValue placeholder="Selecione a categoria">
{categoriaId &&
(() => {
const selectedOption = categoriaOptions.find(
(opt) => opt.value === categoriaId,
);
return selectedOption ? (
<CategoriaSelectContent
label={selectedOption.label}
icon={selectedOption.icon}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{categoriaGroups.map((group) => (
<SelectGroup key={group.label}>
<SelectLabel>{group.label}</SelectLabel>
{group.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
<CategoriaSelectContent
label={option.label}
icon={option.icon}
/>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</div>
{hasNonCredit && (
<div className="space-y-2">
<Label htmlFor="conta">
Conta {hasCredit ? "(para não cartão)" : "*"}
</Label>
<Select value={contaId} onValueChange={setContaId}>
<SelectTrigger id="conta" className="w-full">
<SelectValue placeholder="Selecione a conta">
{contaId &&
(() => {
const selectedOption = contaOptions.find(
(opt) => opt.value === contaId
);
return selectedOption ? (
<ContaCartaoSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={false}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{contaOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ContaCartaoSelectContent
label={option.label}
logo={option.logo}
isCartao={false}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{hasNonCredit && (
<div className="space-y-2">
<Label htmlFor="conta">
Conta {hasCredit ? "(para não cartão)" : "*"}
</Label>
<Select value={contaId} onValueChange={setContaId}>
<SelectTrigger id="conta" className="w-full">
<SelectValue placeholder="Selecione a conta">
{contaId &&
(() => {
const selectedOption = contaOptions.find(
(opt) => opt.value === contaId,
);
return selectedOption ? (
<ContaCartaoSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={false}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{contaOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ContaCartaoSelectContent
label={option.label}
logo={option.logo}
isCartao={false}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{hasCredit && (
<div className="space-y-2">
<Label htmlFor="cartao">
Cartão {hasNonCredit ? "(para cartão de crédito)" : "*"}
</Label>
<Select value={cartaoId} onValueChange={setCartaoId}>
<SelectTrigger id="cartao" className="w-full">
<SelectValue placeholder="Selecione o cartão">
{cartaoId &&
(() => {
const selectedOption = cartaoOptions.find(
(opt) => opt.value === cartaoId
);
return selectedOption ? (
<ContaCartaoSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={true}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{cartaoOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ContaCartaoSelectContent
label={option.label}
logo={option.logo}
isCartao={true}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{hasCredit && (
<div className="space-y-2">
<Label htmlFor="cartao">
Cartão {hasNonCredit ? "(para cartão de crédito)" : "*"}
</Label>
<Select value={cartaoId} onValueChange={setCartaoId}>
<SelectTrigger id="cartao" className="w-full">
<SelectValue placeholder="Selecione o cartão">
{cartaoId &&
(() => {
const selectedOption = cartaoOptions.find(
(opt) => opt.value === cartaoId,
);
return selectedOption ? (
<ContaCartaoSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={true}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{cartaoOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ContaCartaoSelectContent
label={option.label}
logo={option.logo}
isCartao={true}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<DialogFooter className="gap-3">
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Importando..." : "Importar"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
<DialogFooter className="gap-3">
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Importando..." : "Importar"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -4,19 +4,19 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CardContent, CardDescription, CardHeader } from "@/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogTitle,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import {
currencyFormatter,
formatCondition,
formatDate,
formatPeriod,
getTransactionBadgeVariant,
currencyFormatter,
formatCondition,
formatDate,
formatPeriod,
getTransactionBadgeVariant,
} from "@/lib/lancamentos/formatting-helpers";
import { parseLocalDateString } from "@/lib/utils/date";
import { getPaymentMethodIcon } from "@/lib/utils/icons";
@@ -24,189 +24,189 @@ import { InstallmentTimeline } from "../shared/installment-timeline";
import type { LancamentoItem } from "../types";
interface LancamentoDetailsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
lancamento: LancamentoItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
lancamento: LancamentoItem | null;
}
export function LancamentoDetailsDialog({
open,
onOpenChange,
lancamento,
open,
onOpenChange,
lancamento,
}: LancamentoDetailsDialogProps) {
if (!lancamento) return null;
if (!lancamento) return null;
const isInstallment =
lancamento.condition?.toLowerCase() === "parcelado" &&
lancamento.currentInstallment &&
lancamento.installmentCount;
const isInstallment =
lancamento.condition?.toLowerCase() === "parcelado" &&
lancamento.currentInstallment &&
lancamento.installmentCount;
const valorParcela = Math.abs(lancamento.amount);
const totalParcelas = lancamento.installmentCount ?? 1;
const parcelaAtual = lancamento.currentInstallment ?? 1;
const valorTotal = isInstallment
? valorParcela * totalParcelas
: valorParcela;
const valorRestante = isInstallment
? valorParcela * (totalParcelas - parcelaAtual)
: 0;
const valorParcela = Math.abs(lancamento.amount);
const totalParcelas = lancamento.installmentCount ?? 1;
const parcelaAtual = lancamento.currentInstallment ?? 1;
const valorTotal = isInstallment
? valorParcela * totalParcelas
: valorParcela;
const valorRestante = isInstallment
? valorParcela * (totalParcelas - parcelaAtual)
: 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="p-0 sm:max-w-xl">
<div className="gap-2 space-y-4 py-6">
<CardHeader className="flex flex-row items-start border-b">
<div>
<DialogTitle className="group flex items-center gap-2 text-lg">
#{lancamento.id}
</DialogTitle>
<CardDescription>
{formatDate(lancamento.purchaseDate)}
</CardDescription>
</div>
</CardHeader>
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="p-0 sm:max-w-xl">
<div className="gap-2 space-y-4 py-6">
<CardHeader className="flex flex-row items-start border-b">
<div>
<DialogTitle className="group flex items-center gap-2 text-lg">
#{lancamento.id}
</DialogTitle>
<CardDescription>
{formatDate(lancamento.purchaseDate)}
</CardDescription>
</div>
</CardHeader>
<CardContent className="text-sm">
<div className="grid gap-3">
<ul className="grid gap-3">
<DetailRow label="Descrição" value={lancamento.name} />
<CardContent className="text-sm">
<div className="grid gap-3">
<ul className="grid gap-3">
<DetailRow label="Descrição" value={lancamento.name} />
<DetailRow
label="Período"
value={formatPeriod(lancamento.period)}
/>
<DetailRow
label="Período"
value={formatPeriod(lancamento.period)}
/>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
Forma de Pagamento
</span>
<span className="flex items-center gap-1.5">
{getPaymentMethodIcon(lancamento.paymentMethod)}
<span className="capitalize">
{lancamento.paymentMethod}
</span>
</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
Forma de Pagamento
</span>
<span className="flex items-center gap-1.5">
{getPaymentMethodIcon(lancamento.paymentMethod)}
<span className="capitalize">
{lancamento.paymentMethod}
</span>
</span>
</li>
<DetailRow
label={lancamento.cartaoName ? "Cartão" : "Conta"}
value={lancamento.cartaoName ?? lancamento.contaName ?? "—"}
/>
<DetailRow
label={lancamento.cartaoName ? "Cartão" : "Conta"}
value={lancamento.cartaoName ?? lancamento.contaName ?? "—"}
/>
<DetailRow
label="Categoria"
value={lancamento.categoriaName ?? "—"}
/>
<DetailRow
label="Categoria"
value={lancamento.categoriaName ?? "—"}
/>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
Tipo de Transação
</span>
<span className="capitalize">
<Badge
variant={getTransactionBadgeVariant(
lancamento.categoriaName === "Saldo inicial"
? "Saldo inicial"
: lancamento.transactionType,
)}
>
{lancamento.categoriaName === "Saldo inicial"
? "Saldo Inicial"
: lancamento.transactionType}
</Badge>
</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
Tipo de Transação
</span>
<span className="capitalize">
<Badge
variant={getTransactionBadgeVariant(
lancamento.categoriaName === "Saldo inicial"
? "Saldo inicial"
: lancamento.transactionType,
)}
>
{lancamento.categoriaName === "Saldo inicial"
? "Saldo Inicial"
: lancamento.transactionType}
</Badge>
</span>
</li>
<DetailRow
label="Condição"
value={formatCondition(lancamento.condition)}
/>
<DetailRow
label="Condição"
value={formatCondition(lancamento.condition)}
/>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">Responsável</span>
<span className="flex items-center gap-2 capitalize">
<span>{lancamento.pagadorName}</span>
</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">Responsável</span>
<span className="flex items-center gap-2 capitalize">
<span>{lancamento.pagadorName}</span>
</span>
</li>
<DetailRow
label="Status"
value={lancamento.isSettled ? "Pago" : "Pendente"}
/>
<DetailRow
label="Status"
value={lancamento.isSettled ? "Pago" : "Pendente"}
/>
{lancamento.note && (
<DetailRow label="Notas" value={lancamento.note} />
)}
</ul>
{lancamento.note && (
<DetailRow label="Notas" value={lancamento.note} />
)}
</ul>
<ul className="mb-6 grid gap-3">
{isInstallment && (
<li className="mt-4">
<InstallmentTimeline
purchaseDate={parseLocalDateString(
lancamento.purchaseDate,
)}
currentInstallment={parcelaAtual}
totalInstallments={totalParcelas}
period={lancamento.period}
/>
</li>
)}
<ul className="mb-6 grid gap-3">
{isInstallment && (
<li className="mt-4">
<InstallmentTimeline
purchaseDate={parseLocalDateString(
lancamento.purchaseDate,
)}
currentInstallment={parcelaAtual}
totalInstallments={totalParcelas}
period={lancamento.period}
/>
</li>
)}
<DetailRow
label={isInstallment ? "Valor da Parcela" : "Valor"}
value={currencyFormatter.format(valorParcela)}
/>
<DetailRow
label={isInstallment ? "Valor da Parcela" : "Valor"}
value={currencyFormatter.format(valorParcela)}
/>
{isInstallment && (
<DetailRow
label="Valor Restante"
value={currencyFormatter.format(valorRestante)}
/>
)}
{isInstallment && (
<DetailRow
label="Valor Restante"
value={currencyFormatter.format(valorRestante)}
/>
)}
{lancamento.recurrenceCount && (
<DetailRow
label="Quantidade de Recorrências"
value={`${lancamento.recurrenceCount} meses`}
/>
)}
{lancamento.recurrenceCount && (
<DetailRow
label="Quantidade de Recorrências"
value={`${lancamento.recurrenceCount} meses`}
/>
)}
{!isInstallment && <Separator className="my-2" />}
{!isInstallment && <Separator className="my-2" />}
<li className="flex items-center justify-between font-semibold">
<span className="text-muted-foreground">Total da Compra</span>
<span className="text-lg">
{currencyFormatter.format(valorTotal)}
</span>
</li>
</ul>
</div>
<li className="flex items-center justify-between font-semibold">
<span className="text-muted-foreground">Total da Compra</span>
<span className="text-lg">
{currencyFormatter.format(valorTotal)}
</span>
</li>
</ul>
</div>
<DialogFooter>
<DialogClose asChild>
<Button className="w-full" type="button">
Entendi
</Button>
</DialogClose>
</DialogFooter>
</CardContent>
</div>
</DialogContent>
</Dialog>
);
<DialogFooter>
<DialogClose asChild>
<Button className="w-full" type="button">
Entendi
</Button>
</DialogClose>
</DialogFooter>
</CardContent>
</div>
</DialogContent>
</Dialog>
);
}
interface DetailRowProps {
label: string;
value: string;
label: string;
value: string;
}
function DetailRow({ label, value }: DetailRowProps) {
return (
<li className="flex items-center justify-between">
<span className="text-muted-foreground">{label}</span>
<span className="capitalize">{value}</span>
</li>
);
return (
<li className="flex items-center justify-between">
<span className="text-muted-foreground">{label}</span>
<span className="capitalize">{value}</span>
</li>
);
}

View File

@@ -1,78 +1,78 @@
"use client";
import { Label } from "@/components/ui/label";
import { DatePicker } from "@/components/ui/date-picker";
import { RiCalculatorLine } from "@remixicon/react";
import { CalculatorDialogButton } from "@/components/calculadora/calculator-dialog";
import { PeriodPicker } from "@/components/period-picker";
import { CurrencyInput } from "@/components/ui/currency-input";
import { CalculatorDialogButton } from "@/components/calculadora/calculator-dialog";
import { RiCalculatorLine } from "@remixicon/react";
import { DatePicker } from "@/components/ui/date-picker";
import { Label } from "@/components/ui/label";
import { EstabelecimentoInput } from "../../shared/estabelecimento-input";
import type { BasicFieldsSectionProps } from "./lancamento-dialog-types";
export function BasicFieldsSection({
formState,
onFieldChange,
estabelecimentos,
formState,
onFieldChange,
estabelecimentos,
}: Omit<BasicFieldsSectionProps, "monthOptions">) {
return (
<>
<div className="flex w-full flex-col gap-2 md:flex-row">
<div className="w-1/2 space-y-1">
<Label htmlFor="purchaseDate">Data da transação</Label>
<DatePicker
id="purchaseDate"
value={formState.purchaseDate}
onChange={(value) => onFieldChange("purchaseDate", value)}
placeholder="Data da transação"
required
/>
</div>
return (
<>
<div className="flex w-full flex-col gap-2 md:flex-row">
<div className="w-1/2 space-y-1">
<Label htmlFor="purchaseDate">Data da transação</Label>
<DatePicker
id="purchaseDate"
value={formState.purchaseDate}
onChange={(value) => onFieldChange("purchaseDate", value)}
placeholder="Data da transação"
required
/>
</div>
<div className="w-1/2 space-y-1">
<Label htmlFor="period">Período</Label>
<PeriodPicker
value={formState.period}
onChange={(value) => onFieldChange("period", value)}
className="w-full"
/>
</div>
</div>
<div className="w-1/2 space-y-1">
<Label htmlFor="period">Período</Label>
<PeriodPicker
value={formState.period}
onChange={(value) => onFieldChange("period", value)}
className="w-full"
/>
</div>
</div>
<div className="flex w-full flex-col gap-2 md:flex-row">
<div className="w-1/2 space-y-1">
<Label htmlFor="name">Estabelecimento</Label>
<EstabelecimentoInput
id="name"
value={formState.name}
onChange={(value) => onFieldChange("name", value)}
estabelecimentos={estabelecimentos}
placeholder="Ex.: Padaria"
maxLength={20}
required
/>
</div>
<div className="flex w-full flex-col gap-2 md:flex-row">
<div className="w-1/2 space-y-1">
<Label htmlFor="name">Estabelecimento</Label>
<EstabelecimentoInput
id="name"
value={formState.name}
onChange={(value) => onFieldChange("name", value)}
estabelecimentos={estabelecimentos}
placeholder="Ex.: Padaria"
maxLength={20}
required
/>
</div>
<div className="w-1/2 space-y-1">
<Label htmlFor="amount">Valor</Label>
<div className="relative">
<CurrencyInput
id="amount"
value={formState.amount}
onValueChange={(value) => onFieldChange("amount", value)}
placeholder="R$ 0,00"
required
className="pr-10"
/>
<CalculatorDialogButton
variant="ghost"
size="icon-sm"
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2"
>
<RiCalculatorLine className="h-4 w-4 text-muted-foreground" />
</CalculatorDialogButton>
</div>
</div>
</div>
</>
);
<div className="w-1/2 space-y-1">
<Label htmlFor="amount">Valor</Label>
<div className="relative">
<CurrencyInput
id="amount"
value={formState.amount}
onValueChange={(value) => onFieldChange("amount", value)}
placeholder="R$ 0,00"
required
className="pr-10"
/>
<CalculatorDialogButton
variant="ghost"
size="icon-sm"
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2"
>
<RiCalculatorLine className="h-4 w-4 text-muted-foreground" />
</CalculatorDialogButton>
</div>
</div>
</div>
</>
);
}

Some files were not shown because too many files have changed in this diff Show More