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

@@ -1,8 +1,8 @@
"use server";
import { anotacoes } from "@/db/schema";
import { type ActionResult, handleActionError } from "@/lib/actions/helpers";
import { revalidateForEntity } from "@/lib/actions/helpers";
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
import type { ActionResult } from "@/lib/actions/types";
import { uuidSchema } from "@/lib/schemas/common";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server";
@@ -142,3 +142,45 @@ export async function deleteNoteAction(
return handleActionError(error);
}
}
const arquivarNoteSchema = z.object({
id: uuidSchema("Anotação"),
arquivada: z.boolean(),
});
type NoteArquivarInput = z.infer<typeof arquivarNoteSchema>;
export async function arquivarAnotacaoAction(
input: NoteArquivarInput
): Promise<ActionResult> {
try {
const user = await getUser();
const data = arquivarNoteSchema.parse(input);
const [updated] = await db
.update(anotacoes)
.set({
arquivada: data.arquivada,
})
.where(and(eq(anotacoes.id, data.id), eq(anotacoes.userId, user.id)))
.returning({ id: anotacoes.id });
if (!updated) {
return {
success: false,
error: "Anotação não encontrada.",
};
}
revalidateForEntity("anotacoes");
return {
success: true,
message: data.arquivada
? "Anotação arquivada com sucesso."
: "Anotação desarquivada com sucesso."
};
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -0,0 +1,14 @@
import { NotesPage } from "@/components/anotacoes/notes-page";
import { getUserId } from "@/lib/auth/server";
import { fetchArquivadasForUser } from "../data";
export default async function ArquivadasPage() {
const userId = await getUserId();
const notes = await fetchArquivadasForUser(userId);
return (
<main className="flex flex-col items-start gap-6">
<NotesPage notes={notes} isArquivadas={true} />
</main>
);
}

View File

@@ -1,6 +1,6 @@
import { anotacoes, type Anotacao } from "@/db/schema";
import { db } from "@/lib/db";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
export type Task = {
id: string;
@@ -14,12 +14,13 @@ export type NoteData = {
description: string;
type: "nota" | "tarefa";
tasks?: Task[];
arquivada: boolean;
createdAt: string;
};
export async function fetchNotesForUser(userId: string): Promise<NoteData[]> {
const noteRows = await db.query.anotacoes.findMany({
where: eq(anotacoes.userId, userId),
where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, false)),
orderBy: (note: typeof anotacoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(note.createdAt)],
});
@@ -42,6 +43,38 @@ export async function fetchNotesForUser(userId: string): Promise<NoteData[]> {
description: (note.description ?? "").trim(),
type: (note.type ?? "nota") as "nota" | "tarefa",
tasks,
arquivada: note.arquivada,
createdAt: note.createdAt.toISOString(),
};
});
}
export async function fetchArquivadasForUser(userId: string): Promise<NoteData[]> {
const noteRows = await db.query.anotacoes.findMany({
where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, true)),
orderBy: (note: typeof anotacoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(note.createdAt)],
});
return noteRows.map((note: Anotacao) => {
let tasks: Task[] | undefined;
// Parse tasks if they exist
if (note.tasks) {
try {
tasks = JSON.parse(note.tasks);
} catch (error) {
console.error("Failed to parse tasks for note", note.id, error);
tasks = undefined;
}
}
return {
id: note.id,
title: (note.title ?? "").trim(),
description: (note.description ?? "").trim(),
type: (note.type ?? "nota") as "nota" | "tarefa",
tasks,
arquivada: note.arquivada,
createdAt: note.createdAt.toISOString(),
};
});

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>
</div>
{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>
</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,9 +131,22 @@ 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">
{!isArquivadas && (
<div className="flex justify-start">
<NoteDialog
mode="create"
@@ -113,13 +160,22 @@ export function NotesPage({ notes }: NotesPageProps) {
}
/>
</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;
}

View File

@@ -5,22 +5,25 @@ import type { CalendarEvent } from "@/components/calendario/types";
import { cn } from "@/lib/utils/ui";
const LEGEND_ITEMS: Array<{
type: CalendarEvent["type"];
type?: CalendarEvent["type"];
label: string;
dotColor?: string;
}> = [
{ type: "lancamento", label: "Lançamento financeiro" },
{ 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) => {
const style = EVENT_TYPE_STYLES[item.type];
{LEGEND_ITEMS.map((item, index) => {
const dotColor =
item.dotColor || (item.type ? EVENT_TYPE_STYLES[item.type].dot : "");
return (
<span key={item.type} className="flex items-center gap-2">
<span className={cn("size-3 rounded-full", style.dot)} />
<span key={item.type || index} className="flex items-center gap-2">
<span className={cn("size-3 rounded-full", dotColor)} />
{item.label}
</span>
);

View File

@@ -18,17 +18,18 @@ export const EVENT_TYPE_STYLES: Record<
> = {
lancamento: {
wrapper:
"bg-orange-100 text-orange-600 dark:bg-orange-800 dark:text-orange-50",
"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-emerald-100 text-emerald-600 dark:bg-emerald-800 dark:text-emerald-50",
dot: "bg-emerald-600",
"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-blue-100 text-blue-600 dark:bg-blue-800 dark:text-blue-50",
dot: "bg-blue-600",
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",
},
};
@@ -75,10 +76,28 @@ const buildEventComplement = (event: CalendarEvent) => {
}
};
const isPagamentoFatura = (event: CalendarEvent) => {
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];
};
const DayEventPreview = ({ event }: { event: CalendarEvent }) => {
const complement = buildEventComplement(event);
const label = buildEventLabel(event);
const style = eventStyles[event.type];
const style = getEventStyle(event);
return (
<div
@@ -88,7 +107,6 @@ const DayEventPreview = ({ event }: { event: CalendarEvent }) => {
)}
>
<div className="flex min-w-0 items-center gap-1">
<span className={cn("size-1.5 rounded-full", style.dot)} />
<span className="truncate">{label}</span>
</div>
{complement ? (

View File

@@ -1,5 +1,7 @@
"use client";
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,
@@ -9,12 +11,12 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { friendlyDate, parseLocalDateString } from "@/lib/utils/date";
import { cn } from "@/lib/utils/ui";
import { useMemo, type ReactNode } from "react";
import type { CalendarDay, CalendarEvent } from "@/components/calendario/types";
import { parseDateKey } from "@/components/calendario/utils";
import { EVENT_TYPE_STYLES } from "@/components/calendario/day-cell";
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
import type { ReactNode } from "react";
import MoneyValues from "../money-values";
import { Badge } from "../ui/badge";
import { Card } from "../ui/card";
type EventModalProps = {
open: boolean;
@@ -23,36 +25,26 @@ type EventModalProps = {
onCreate: (date: string) => void;
};
const fullDateFormatter = new Intl.DateTimeFormat("pt-BR", {
day: "numeric",
month: "long",
year: "numeric",
});
const capitalize = (value: string) =>
value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value;
const formatCurrency = (value: number, isReceita: boolean) => {
const formatted = currencyFormatter.format(value ?? 0);
return isReceita ? `+${formatted}` : formatted;
};
const EventCard = ({
children,
type,
isPagamentoFatura = false,
}: {
children: ReactNode;
type: CalendarEvent["type"];
isPagamentoFatura?: boolean;
}) => {
const style = EVENT_TYPE_STYLES[type];
const style = isPagamentoFatura
? { dot: "bg-green-600" }
: EVENT_TYPE_STYLES[type];
return (
<div className="flex gap-3 rounded-xl border border-border/60 bg-card/85 p-4">
<Card className="flex flex-row gap-2 p-3 mb-1">
<span
className={cn("mt-1 size-2.5 shrink-0 rounded-full", style.dot)}
className={cn("mt-1 size-3 shrink-0 rounded-full", style.dot)}
aria-hidden
/>
<div className="flex flex-1 flex-col gap-2">{children}</div>
</div>
<div className="flex flex-1 flex-col">{children}</div>
</Card>
);
};
@@ -60,41 +52,38 @@ const renderLancamento = (
event: Extract<CalendarEvent, { type: "lancamento" }>
) => {
const isReceita = event.lancamento.transactionType === "Receita";
const subtitleParts = [
event.lancamento.categoriaName,
event.lancamento.paymentMethod,
event.lancamento.pagadorName,
].filter(Boolean);
const isPagamentoFatura =
event.lancamento.name.startsWith("Pagamento fatura -");
return (
<EventCard type="lancamento">
<EventCard type="lancamento" isPagamentoFatura={isPagamentoFatura}>
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col">
<span className="text-sm font-semibold">{event.lancamento.name}</span>
{subtitleParts.length ? (
<span className="text-xs text-muted-foreground">
{subtitleParts.join(" • ")}
<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>
) : null}
<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",
isReceita ? "text-emerald-600" : "text-foreground"
"text-sm font-semibold whitespace-nowrap",
isReceita ? "text-green-600 dark:text-green-400" : "text-foreground"
)}
>
{formatCurrency(event.lancamento.amount, isReceita)}
</span>
</div>
<div className="flex flex-wrap gap-2 text-[11px] font-medium text-muted-foreground">
<span className="rounded-full bg-muted px-2 py-0.5">
{capitalize(event.lancamento.transactionType)}
</span>
<span className="rounded-full bg-muted px-2 py-0.5">
{event.lancamento.condition}
</span>
<span className="rounded-full bg-muted px-2 py-0.5">
{event.lancamento.paymentMethod}
<MoneyValues
showPositiveSign
className="text-base"
amount={event.lancamento.amount}
/>
</span>
</div>
</EventCard>
@@ -111,26 +100,25 @@ const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
return (
<EventCard type="boleto">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col">
<span className="text-sm font-semibold">{event.lancamento.name}</span>
<span className="text-xs text-muted-foreground">
Boleto{formattedDueDate ? ` - Vence em ${formattedDueDate}` : ""}
<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>
</div>
<span className="text-sm font-semibold text-foreground">
{currencyFormatter.format(event.lancamento.amount ?? 0)}
{formattedDueDate && (
<span className="text-xs text-muted-foreground leading-tight">
Vence em {formattedDueDate}
</span>
</div>
<span
className={cn(
"inline-flex w-fit items-center rounded-full px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-wide",
isPaid
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200"
: "bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200"
)}
>
{isPaid ? "Pago" : "Pendente"}
</div>
<Badge variant={"outline"}>{isPaid ? "Pago" : "Pendente"}</Badge>
</div>
<span className="font-semibold">
<MoneyValues amount={event.lancamento.amount} />
</span>
</div>
</EventCard>
);
};
@@ -138,25 +126,18 @@ const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
const renderCard = (event: Extract<CalendarEvent, { type: "cartao" }>) => (
<EventCard type="cartao">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col">
<span className="text-sm font-semibold">Cartão {event.card.name}</span>
<span className="text-xs text-muted-foreground">
Vencimento dia {event.card.dueDay}
<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="text-sm font-semibold text-foreground">
{currencyFormatter.format(event.card.totalDue)}
</span>
) : null}
</div>
<div className="flex flex-wrap gap-2 text-[11px] font-medium text-muted-foreground">
<span className="rounded-full bg-muted px-2 py-0.5">
Status: {event.card.status ?? "Indefinido"}
</span>
{event.card.closingDay ? (
<span className="rounded-full bg-muted px-2 py-0.5">
Fechamento dia {event.card.closingDay}
<span className="font-semibold">
<MoneyValues amount={event.card.totalDue} />
</span>
) : null}
</div>
@@ -177,11 +158,9 @@ const renderEvent = (event: CalendarEvent) => {
};
export function EventModal({ open, day, onClose, onCreate }: EventModalProps) {
const formattedDate = useMemo(() => {
if (!day) return "";
const parsed = parseDateKey(day.date);
return capitalize(fullDateFormatter.format(parsed));
}, [day]);
const formattedDate = !day
? ""
: friendlyDate(parseLocalDateString(day.date));
const handleCreate = () => {
if (!day) return;
@@ -201,7 +180,7 @@ export function EventModal({ open, day, onClose, onCreate }: EventModalProps) {
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="max-h-[380px] space-y-3 overflow-y-auto pr-2">
<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>

View File

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

View File

@@ -1,6 +1,6 @@
import { ChangelogNotification } from "@/components/changelog/changelog-notification";
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
import { NotificationBell } from "@/components/notifications/notification-bell";
import { NotificationBell } from "@/components/notificacoes/notification-bell";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { getUser } from "@/lib/auth/server";
import { getUnreadUpdates } from "@/lib/changelog/data";

View File

@@ -135,8 +135,8 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
<div className="flex flex-col gap-6">
{/* Privacy Warning */}
<Alert className="border-none">
<RiAlertLine className="size-4" />
<AlertDescription className="text-sm">
<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.

View File

@@ -309,6 +309,7 @@ const buildColumns = ({
return (
<MoneyValues
amount={row.original.amount}
showPositiveSign={isReceita}
className={cn(
"whitespace-nowrap",
isReceita

View File

@@ -7,9 +7,10 @@ import { usePrivacyMode } from "./privacy-provider";
type Props = {
amount: number;
className?: string;
showPositiveSign?: boolean;
};
function MoneyValues({ amount, className }: Props) {
function MoneyValues({ amount, className, showPositiveSign = false }: Props) {
const { privacyMode } = usePrivacyMode();
const formattedValue = amount.toLocaleString("pt-BR", {
@@ -18,6 +19,10 @@ function MoneyValues({ amount, className }: Props) {
maximumFractionDigits: 2,
});
const displayValue = showPositiveSign && amount > 0
? `+${formattedValue}`
: formattedValue;
return (
<span
className={cn(
@@ -27,13 +32,13 @@ function MoneyValues({ amount, className }: Props) {
"blur-[6px] select-none hover:blur-none focus-within:blur-none",
className
)}
aria-label={privacyMode ? "Valor oculto" : formattedValue}
aria-label={privacyMode ? "Valor oculto" : displayValue}
data-privacy={privacyMode ? "hidden" : undefined}
title={
privacyMode ? "Valor oculto - passe o mouse para revelar" : undefined
}
>
{formattedValue}
{displayValue}
</span>
);
}

View File

@@ -1,4 +1,5 @@
import {
RiArchiveLine,
RiArrowLeftRightLine,
RiBankCardLine,
RiBankLine,
@@ -142,6 +143,14 @@ export function createSidebarNavData(pagadores: PagadorLike[]): SidebarNavData {
title: "Anotações",
url: "/anotacoes",
icon: RiTodoLine,
items: [
{
title: "Arquivadas",
url: "/anotacoes/arquivadas",
key: "anotacoes-arquivadas",
icon: RiArchiveLine,
},
],
},
{
title: "Insights",

View File

@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 border-transparent border drop-shadow-xs py-6 rounded-md hover:border-primary/50 transition-colors",
"bg-card text-card-foreground flex flex-col gap-6 border-transparent border drop-shadow-xs py-6 rounded-md hover:border-primary/50 transition-all ease-in-out duration-300",
className
)}
{...props}

View File

@@ -347,6 +347,7 @@ export const anotacoes = pgTable("anotacoes", {
description: text("descricao"),
type: text("tipo").notNull().default("nota"), // "nota" ou "tarefa"
tasks: text("tasks"), // JSON stringificado com array de tarefas
arquivada: boolean("arquivada").notNull().default(false),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,

View File

@@ -30,7 +30,7 @@ export const revalidateConfig = {
categorias: ["/categorias"],
orcamentos: ["/orcamentos"],
pagadores: ["/pagadores"],
anotacoes: ["/anotacoes"],
anotacoes: ["/anotacoes", "/anotacoes/arquivadas"],
lancamentos: ["/lancamentos", "/contas"],
} as const;

View File

@@ -28,10 +28,10 @@
"docker:rebuild": "docker compose up --build --force-recreate"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.56",
"@ai-sdk/google": "^2.0.46",
"@ai-sdk/openai": "^2.0.86",
"@openrouter/ai-sdk-provider": "^1.5.3",
"@ai-sdk/anthropic": "^3.0.1",
"@ai-sdk/google": "^3.0.1",
"@ai-sdk/openai": "^3.0.1",
"@openrouter/ai-sdk-provider": "^1.5.4",
"@radix-ui/react-alert-dialog": "1.1.15",
"@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3",
@@ -56,41 +56,41 @@
"@tanstack/react-table": "8.21.3",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.3.1",
"ai": "^5.0.113",
"ai": "^6.0.3",
"babel-plugin-react-compiler": "^1.0.0",
"better-auth": "1.4.7",
"better-auth": "1.4.9",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "0.45.1",
"motion": "^12.23.26",
"next": "16.0.10",
"next": "16.1.1",
"next-themes": "0.4.6",
"pg": "8.16.3",
"react": "19.2.3",
"react-day-picker": "^9.12.0",
"react-day-picker": "^9.13.0",
"react-dom": "19.2.3",
"recharts": "3.6.0",
"resend": "^6.6.0",
"sonner": "2.0.7",
"tailwind-merge": "3.4.0",
"vaul": "1.1.2",
"zod": "4.2.0"
"zod": "4.2.1"
},
"devDependencies": {
"@tailwindcss/postcss": "4.1.18",
"@types/d3-array": "^3.2.2",
"@types/node": "25.0.2",
"@types/node": "25.0.3",
"@types/pg": "^8.16.0",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"baseline-browser-mapping": "^2.9.7",
"baseline-browser-mapping": "^2.9.11",
"depcheck": "^1.4.7",
"dotenv": "^17.2.3",
"drizzle-kit": "0.31.8",
"eslint": "9.39.2",
"eslint-config-next": "16.0.10",
"eslint-config-next": "16.1.1",
"tailwindcss": "4.1.18",
"tsx": "4.21.0",
"typescript": "5.9.3"

1333
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff