feat: adicionar página de anotações arquivadas e componente de notificação

- Implementa a página de anotações arquivadas, que busca as notas
  arquivadas do usuário e as exibe utilizando o componente NotesPage.

- Cria o componente NotificationBell para gerenciar e exibir
  notificações de pagamentos, incluindo a formatação de datas e
  valores monetários. O componente também apresenta um sistema de
  tooltip e dropdown para interação do usuário.
This commit is contained in:
Felipe Coutinho
2025-12-24 19:36:39 +00:00
parent e7cb9c9db1
commit 3eca48c71a
23 changed files with 848 additions and 1029 deletions

View File

@@ -3,9 +3,11 @@
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import {
RiArchiveLine,
RiCheckLine,
RiDeleteBin5Line,
RiEyeLine,
RiInboxUnarchiveLine,
RiPencilLine,
} from "@remixicon/react";
import { useMemo } from "react";
@@ -20,9 +22,18 @@ interface NoteCardProps {
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 }: NoteCardProps) {
export function NoteCard({
note,
onEdit,
onDetails,
onRemove,
onArquivar,
isArquivadas = false,
}: NoteCardProps) {
const { formattedDate, displayTitle } = useMemo(() => {
const resolvedTitle = note.title.trim().length
? note.title
@@ -52,6 +63,16 @@ export function NoteCard({ note, onEdit, onDetails, onRemove }: NoteCardProps) {
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 />,
@@ -68,20 +89,17 @@ export function NoteCard({ note, onEdit, onDetails, onRemove }: NoteCardProps) {
<h3 className="text-lg font-semibold leading-tight text-foreground wrap-break-word">
{displayTitle}
</h3>
{isTask && (
<Badge variant="outline" className="text-xs">
{completedCount}/{totalCount} concluídas
</Badge>
)}
</div>
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground whitespace-nowrap">
{formattedDate}
</span>
{isTask && (
<Badge variant="outline" className="text-xs">
{completedCount}/{totalCount} concluídas
</Badge>
)}
</div>
{isTask ? (
<div className="flex-1 overflow-auto space-y-2">
{tasks.slice(0, 4).map((task) => (
<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 ${
@@ -96,24 +114,22 @@ export function NoteCard({ note, onEdit, onDetails, onRemove }: NoteCardProps) {
</div>
<span
className={`leading-relaxed ${
task.completed
? "line-through text-muted-foreground"
: "text-foreground"
task.completed ? "text-muted-foreground" : "text-foreground"
}`}
>
{task.text}
</span>
</div>
))}
{tasks.length > 4 && (
{tasks.length > 5 && (
<p className="text-xs text-muted-foreground pl-5 py-1">
+{tasks.length - 4}{" "}
{tasks.length - 4 === 1 ? "tarefa" : "tarefas"}...
+{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">
<p className="flex-1 overflow-auto whitespace-pre-line text-sm text-muted-foreground wrap-break-word leading-relaxed mt-2">
{note.description}
</p>
)}

View File

@@ -13,6 +13,7 @@ import {
} 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", {
@@ -71,9 +72,9 @@ export function NoteDetailsDialog({
{isTask ? (
<div className="max-h-[320px] overflow-auto space-y-3">
{tasks.map((task) => (
<div
<Card
key={task.id}
className="flex items-start gap-3 p-3 rounded-lg border bg-card"
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 ${
@@ -88,14 +89,12 @@ export function NoteDetailsDialog({
</div>
<span
className={`text-sm ${
task.completed
? "line-through text-muted-foreground"
: "text-foreground"
task.completed ? "text-muted-foreground" : "text-foreground"
}`}
>
{task.text}
</span>
</div>
</Card>
))}
</div>
) : (

View File

@@ -30,6 +30,7 @@ import {
useTransition,
} from "react";
import { toast } from "sonner";
import { Card } from "../ui/card";
import type { Note, NoteFormValues, Task } from "./types";
type NoteDialogMode = "create" | "update";
@@ -404,11 +405,12 @@ export function NoteDialog({
</label>
<div className="space-y-2 max-h-[240px] overflow-y-auto pr-1">
{formState.tasks.map((task) => (
<div
<Card
key={task.id}
className="flex items-center gap-3 p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
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}
@@ -419,7 +421,7 @@ export function NoteDialog({
<span
className={`flex-1 text-sm wrap-break-word ${
task.completed
? "line-through text-muted-foreground"
? "text-muted-foreground"
: "text-foreground"
}`}
>
@@ -436,7 +438,7 @@ export function NoteDialog({
>
<RiDeleteBinLine className="h-4 w-4" />
</Button>
</div>
</Card>
))}
</div>
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import { deleteNoteAction } from "@/app/(dashboard)/anotacoes/actions";
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";
@@ -15,9 +15,10 @@ import type { Note } from "./types";
interface NotesPageProps {
notes: Note[];
isArquivadas?: boolean;
}
export function NotesPage({ notes }: NotesPageProps) {
export function NotesPage({ notes, isArquivadas = false }: NotesPageProps) {
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [noteToEdit, setNoteToEdit] = useState<Note | null>(null);
@@ -25,6 +26,8 @@ export function NotesPage({ notes }: NotesPageProps) {
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(
() =>
@@ -60,6 +63,13 @@ export function NotesPage({ notes }: NotesPageProps) {
}
}, []);
const handleArquivarOpenChange = useCallback((open: boolean) => {
setArquivarOpen(open);
if (!open) {
setNoteToArquivar(null);
}
}, []);
const handleEditRequest = useCallback((note: Note) => {
setNoteToEdit(note);
setEditOpen(true);
@@ -75,6 +85,30 @@ export function NotesPage({ notes }: NotesPageProps) {
setRemoveOpen(true);
}, []);
const handleArquivarRequest = useCallback((note: Note) => {
setNoteToArquivar(note);
setArquivarOpen(true);
}, []);
const handleArquivarConfirm = useCallback(async () => {
if (!noteToArquivar) {
return;
}
const result = await arquivarAnotacaoAction({
id: noteToArquivar.id,
arquivada: !isArquivadas,
});
if (result.success) {
toast.success(result.message);
return;
}
toast.error(result.error);
throw new Error(result.error);
}, [noteToArquivar, isArquivadas]);
const handleRemoveConfirm = useCallback(async () => {
if (!noteToRemove) {
return;
@@ -97,29 +131,51 @@ export function NotesPage({ notes }: NotesPageProps) {
: "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?";
return (
<>
<div className="flex w-full flex-col gap-6">
<div className="flex justify-start">
<NoteDialog
mode="create"
open={createOpen}
onOpenChange={handleCreateOpenChange}
trigger={
<Button>
<RiAddCircleLine className="size-4" />
Nova anotação
</Button>
}
/>
</div>
{!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="Nenhuma anotação registrada"
description="Crie anotações personalizadas para acompanhar lembretes, decisões ou observações financeiras importantes."
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>
) : (
@@ -131,6 +187,8 @@ export function NotesPage({ notes }: NotesPageProps) {
onEdit={handleEditRequest}
onDetails={handleDetailsRequest}
onRemove={handleRemoveRequest}
onArquivar={handleArquivarRequest}
isArquivadas={isArquivadas}
/>
))}
</div>
@@ -150,6 +208,21 @@ export function NotesPage({ notes }: NotesPageProps) {
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={removeOpen}
onOpenChange={handleRemoveOpenChange}

View File

@@ -12,6 +12,7 @@ export interface Note {
description: string;
type: NoteType;
tasks?: Task[];
arquivada: boolean;
createdAt: string;
}