mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-03-10 04:51:47 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
Já tem uma conta?{" "}
|
||||
<a href="/login" className="underline underline-offset-4">
|
||||
Entrar
|
||||
</a>
|
||||
</FieldDescription>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
<FieldDescription className="text-center">
|
||||
Já 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 há limite registrado para este cartão.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
<Progress value={usagePercent} className="h-3" />
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ainda não há 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 há 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 há 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
InstallmentAnalysisData,
|
||||
InstallmentGroup,
|
||||
PendingInvoice,
|
||||
InstallmentAnalysisData,
|
||||
InstallmentGroup,
|
||||
PendingInvoice,
|
||||
} from "@/lib/dashboard/expenses/installment-analysis";
|
||||
|
||||
export type { InstallmentAnalysisData, InstallmentGroup, PendingInvoice };
|
||||
|
||||
@@ -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
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 já 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 já 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user